generated from ttipo/project-base
chore(docker): 메일 엔진·네트워크 분리·같은 주소 배포와 Node 24 고정 #2
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
// 화면을 떠나기 전 확인 — 세션 만료 대화상자와 같은 모양 (ModalDialog · role=alertdialog)
|
||||
// 쓰는 곳: 쓰기 화면(ComposeLeaveDialog — 저장하지 못한 내용) · 설정 › 메일 › 서명(저장하지 않은 서명)
|
||||
// 쓰는 곳: 쓰기 화면(ComposeLeaveDialog — 저장하지 못한 내용) · 설정 › 메일 › 서명(저장하지 않은 서명) · 부재중 자동 응답 ·
|
||||
// 필터 고치기 창(MailFilterDialog — 창 위에 뜬다: [취소] · Esc 의 "창을 닫으면 …" [닫기] · 다른 화면으로 갈 때 [나가기])
|
||||
// [머무르기](계속 쓰기 · 계속 고치기)가 처음 초점이고 Esc 도 같다. 쓰는 쪽이 답을 받아 이동을 취소하거나(머무르기) 그대로 보낸다(나가기).
|
||||
// 단추는 오른쪽 정렬 — 머무르기는 채운 기본 단추 · 나가기는 보조 단추 (디자인 README "나가기 전 확인").
|
||||
// 고정 id — {idPrefix}-dialog · {idPrefix}-title · {idPrefix}-message · {idPrefix}-stay · {idPrefix}-confirm
|
||||
|
||||
@@ -13,6 +13,16 @@ afterEach(() => {
|
||||
for (const wrapper of mounted.splice(0)) wrapper.unmount()
|
||||
})
|
||||
|
||||
/**
|
||||
* 브라우저가 스스로 닫는 것을 흉내 낸다 (Esc 를 거듭 눌러 막을 수 없게 닫힘) — open 을 떼고, 초점을 연 때의 자리(창 밖)로 옮긴 뒤 close 를 쏜다
|
||||
* (브라우저는 close 이벤트를 쏘기 전에 초점을 창 밖으로 옮긴다)
|
||||
*/
|
||||
function browserCloses(dialog: HTMLDialogElement) {
|
||||
dialog.removeAttribute('open')
|
||||
;(document.activeElement as HTMLElement | null)?.blur()
|
||||
dialog.dispatchEvent(new Event('close'))
|
||||
}
|
||||
|
||||
function mountDialog(open: boolean) {
|
||||
const wrapper = mount(ModalDialog, {
|
||||
attachTo: document.body,
|
||||
@@ -75,6 +85,49 @@ describe('ModalDialog', () => {
|
||||
expect(wrapper.emitted('cancel')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('브라우저가 스스로 닫았는데 쓰는 쪽이 닫지 않기로 했으면 reshow() 가 다시 띄운다 — 열려 있으면 아무것도 하지 않는다', async () => {
|
||||
const wrapper = mountDialog(true)
|
||||
await flushPromises()
|
||||
const dialog = wrapper.get('dialog').element
|
||||
const reshow = () => (wrapper.vm as unknown as { reshow: () => void }).reshow()
|
||||
|
||||
// 열려 있는 동안은 그대로 (초점도 옮기지 않는다)
|
||||
;(wrapper.get('#leave').element as HTMLElement).focus()
|
||||
reshow()
|
||||
expect(dialog.hasAttribute('open')).toBe(true)
|
||||
expect(document.activeElement?.id).toBe('leave')
|
||||
|
||||
// Esc 를 거듭 눌러 브라우저가 막을 수 없게 닫았다 — 쓰는 쪽은 확인 창을 띄우고 열린 채로 둔다
|
||||
browserCloses(dialog)
|
||||
expect(wrapper.emitted('cancel')).toHaveLength(1)
|
||||
reshow()
|
||||
expect(dialog.hasAttribute('open')).toBe(true)
|
||||
|
||||
// 쓰는 쪽이 닫았으면 다시 띄우지 않는다
|
||||
await wrapper.setProps({ open: false })
|
||||
reshow()
|
||||
expect(dialog.hasAttribute('open')).toBe(false)
|
||||
})
|
||||
|
||||
it('reshow() 의 초점 — 닫히기 전 창 안에서 마지막으로 있던 요소로, 그 요소가 없어졌으면 처음 초점(autofocus)으로', async () => {
|
||||
const wrapper = mountDialog(true)
|
||||
await flushPromises()
|
||||
const dialog = wrapper.get('dialog').element
|
||||
const reshow = () => (wrapper.vm as unknown as { reshow: () => void }).reshow()
|
||||
|
||||
;(wrapper.get('#leave').element as HTMLElement).focus()
|
||||
browserCloses(dialog)
|
||||
reshow()
|
||||
expect(document.activeElement?.id).toBe('leave')
|
||||
|
||||
// 마지막 자리가 사라졌다 — 처음 초점으로
|
||||
const leave = wrapper.get('#leave').element
|
||||
browserCloses(dialog)
|
||||
leave.remove()
|
||||
reshow()
|
||||
expect(document.activeElement?.id).toBe('stay')
|
||||
})
|
||||
|
||||
it('한 대화상자가 닫히며 다른 대화상자가 열리면 닫기가 먼저다 — 여는 쪽을 먼저 고쳐도 둘이 겹쳐 열리지 않는다', async () => {
|
||||
const first = ref<boolean>(true)
|
||||
// 여는 쪽(B)을 앞에 둔다 — 화면을 고치는 순서가 B 먼저여도 A 가 먼저 닫혀야 한다 (세션 만료 대화상자가 앞에 있는 앱과 같다)
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
// 모달 대화상자 틀 — 브라우저 <dialog>(showModal)로 띄운다. 모양은 디자인 원본 States "세션 만료".
|
||||
// 쓰는 곳: SessionExpiredDialog(세션 만료) · LeaveConfirmDialog(떠나기 전 확인 — 쓰기 화면 · 설정 › 서명 · 부재중 자동 응답) ·
|
||||
// 쓰는 곳: SessionExpiredDialog(세션 만료) · LeaveConfirmDialog(떠나기 전 확인 — 쓰기 화면 · 설정 › 서명 · 부재중 자동 응답 · 필터 고치기 창 위) ·
|
||||
// MailPurgeDialog(영구 삭제 확인) · 메일함 관리(MailboxNameDialog — 이름 칸 · MailboxDeleteDialog · MailboxEmptyDialog) ·
|
||||
// 메일 필터 고치기 창(MailFilterDialog — 넓은 창 620, wide)
|
||||
// - 모달이라 뒤의 화면을 조작할 수 없다. 처음 초점은 autofocus 를 붙인 단추 (없으면 브라우저가 첫 단추로)
|
||||
// - Esc(cancel)는 브라우저가 닫지 않게 막고 cancel 로 알린다 — 무엇을 할지는 쓰는 쪽이 정한다
|
||||
// 브라우저가 막지 못하게 스스로 닫았는데(Esc 를 거듭 누름) 쓰는 쪽이 닫지 않기로 했으면(나가기 확인의 "계속 고치기") reshow() 로 다시 띄운다 —
|
||||
// 초점은 닫히기 전에 창 안에서 마지막으로 있던 요소로 돌려준다(브라우저는 close 이벤트 전에 초점을 창 밖으로 옮기므로 열린 동안 focusin 으로 기억해 둔다)
|
||||
// - 닫기는 열기보다 먼저 한다 — 한 대화상자가 닫히고 다른 대화상자가 열리는 때(세션 만료) 둘이 겹치지 않게
|
||||
// 단추는 actions 슬롯에 둔다: class="modal-dialog__action" (보조 단추는 modal-dialog__action--secondary, 되돌릴 수 없는 일은 --danger 를 더한다)
|
||||
// 글(message) 대신 · 글 아래에 입력 칸을 둘 때는 기본 슬롯에 넣는다 — 글이 없으면 describedBy 로 칸의 안내를 가리킨다
|
||||
@@ -45,6 +47,8 @@ const descriptionId = computed(() => (props.message ? props.messageId : props.de
|
||||
const dialogRef = ref<HTMLDialogElement | null>(null)
|
||||
/** 열기 전에 초점이 있던 곳 — 닫으면 돌려준다 (브라우저가 돌려주지 않는 환경에서도) */
|
||||
let returnFocus: HTMLElement | null = null
|
||||
/** 열린 동안 창 안에서 마지막으로 초점이 있던 요소 — 브라우저가 스스로 닫은 뒤 다시 띄울 때(reshow) 그 자리로 돌려준다 */
|
||||
let lastFocused: HTMLElement | null = null
|
||||
|
||||
function show() {
|
||||
const dialog = dialogRef.value
|
||||
@@ -66,6 +70,11 @@ function hide() {
|
||||
const active = document.activeElement
|
||||
if (active === null || active === document.body || dialog.contains(active)) returnFocus?.focus()
|
||||
returnFocus = null
|
||||
lastFocused = null
|
||||
}
|
||||
|
||||
function onFocusIn(event: FocusEvent) {
|
||||
if (event.target instanceof HTMLElement) lastFocused = event.target
|
||||
}
|
||||
|
||||
function onCancel(event: Event) {
|
||||
@@ -78,6 +87,21 @@ function onClose() {
|
||||
if (props.open) emit('cancel')
|
||||
}
|
||||
|
||||
/**
|
||||
* 열려 있어야 하는데 브라우저가 닫았으면 다시 띄운다 — 위 onClose 의 cancel 에 쓰는 쪽이 닫지 않기로 했을 때 부른다
|
||||
* (예: 필터 고치기 창 — 저장하지 않은 것이 있어 확인 창을 띄웠고 "계속 고치기"를 골랐다). 열려 있으면 아무것도 하지 않는다.
|
||||
* 초점은 닫히기 전에 창 안에서 마지막으로 있던 요소로 — 그 요소가 아직 창 안에 있을 때만 (없으면 처음 초점 autofocus 그대로)
|
||||
*/
|
||||
function reshow() {
|
||||
const dialog = dialogRef.value
|
||||
if (!props.open || !dialog || dialog.open) return
|
||||
const previous = lastFocused
|
||||
show()
|
||||
if (previous?.isConnected && dialog.contains(previous)) previous.focus()
|
||||
}
|
||||
|
||||
defineExpose({ reshow })
|
||||
|
||||
// 닫기는 쓰는 쪽이 다시 그릴 때 바로, 열기는 화면을 다 고친 뒤에 (위 "닫기는 열기보다 먼저")
|
||||
watch(
|
||||
() => props.open,
|
||||
@@ -107,6 +131,7 @@ onMounted(() => {
|
||||
:aria-describedby="descriptionId"
|
||||
@cancel="onCancel"
|
||||
@close="onClose"
|
||||
@focusin="onFocusIn"
|
||||
>
|
||||
<h2
|
||||
:id="titleId"
|
||||
|
||||
@@ -4,7 +4,7 @@ import { createPinia, setActivePinia, type Pinia } from 'pinia'
|
||||
import { nextTick } from 'vue'
|
||||
|
||||
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
|
||||
import type { MailVacation, Mailbox } from '@/shared/types/mail'
|
||||
import type { MailVacationState, Mailbox } from '@/shared/types/mail'
|
||||
import { endOfDayIso } from '@/shared/utils/mail-vacation'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
@@ -458,12 +458,13 @@ describe('MailboxPanel — 메일함 관리', () => {
|
||||
})
|
||||
|
||||
it('부재중 자동 응답이 켜져 있으면 사용량 위에 안내 — 누르면 설정으로, 꺼지면 사라진다', async () => {
|
||||
const vacation: MailVacation = {
|
||||
const vacation: MailVacationState = {
|
||||
isEnabled: true,
|
||||
fromDate: null,
|
||||
toDate: endOfDayIso('2099-11-03'),
|
||||
subject: '부재중 안내',
|
||||
textBody: '휴가입니다.',
|
||||
isActive: true,
|
||||
}
|
||||
const { wrapper } = await mountPanel()
|
||||
useMailVacationStore().vacation = vacation
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
<script setup lang="ts">
|
||||
// 고치기 창의 「받은편지함에 이미 있는 메일에도 적용」 (7단계 — 디자인 원본 MailFilterApply · MailFilterApplyStates) — 할 일 아래, 6단계의 안내 띠 자리
|
||||
// 회색 상자(바탕 #F8F9FB) 하나: 체크 칸(이름 굵게) + 칸 아래 설명(12.5 — 칸의 aria-describedby) → 상자 아래 한 줄 "스팸으로 판정되어 …"
|
||||
// - 켤지는 부모가 정한다(창을 열 때마다 꺼짐 — 필터의 설정이 아니라 이번 저장에 한 번 쓴다)
|
||||
// - 고를 수 없으면(block — mail-filter-run.ts 의 filterApplyBlock) 칸을 disabled · 이름을 흐리게, 설명이 까닭을 말한다:
|
||||
// 이 탭에서 다른 필터를 적용하는 중 · 옮길 메일함이 지워짐 · 받은편지함으로 옮기기만 · 전달만 하는 필터 (이미 받은 메일에 할 일이 없다).
|
||||
// 고를 수 있을 때 전달도 하는 필터면 설명에 "전달은 새로 오는 메일에만 합니다."를 더한다
|
||||
// - 줄 높이는 할 일 칸과 같은 44 (보드는 40 — 고치기 창의 밀도 예외는 칸 · 단추 40 뿐이라 새 예외를 만들지 않는다)
|
||||
// 고정 id — #mail-filter-apply · #mail-filter-apply-help
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { MAIL_FILTER_APPLY_TEXT } from '@/shared/constants/mail-filter'
|
||||
import type { MailFilterApplyBlock } from '@/shared/utils/mail-filter-run'
|
||||
|
||||
interface Props {
|
||||
/** 골랐다 — 고를 수 없는 동안은 꺼진 채로 보인다 */
|
||||
checked: boolean
|
||||
/** 고를 수 없는 까닭 — null 이면 고를 수 있다 */
|
||||
block: MailFilterApplyBlock | null
|
||||
/** 전달도 하는 필터 */
|
||||
forwards: boolean
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:checked', checked: boolean): void
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
/** 고를 수 없는 까닭의 설명 */
|
||||
const BLOCK_HELP: Readonly<Record<MailFilterApplyBlock, string>> = {
|
||||
busy: MAIL_FILTER_APPLY_TEXT.busy,
|
||||
missingMailbox: MAIL_FILTER_APPLY_TEXT.missingMailbox,
|
||||
inboxOnly: MAIL_FILTER_APPLY_TEXT.inboxOnly,
|
||||
forwardOnly: MAIL_FILTER_APPLY_TEXT.forwardOnly,
|
||||
}
|
||||
|
||||
const help = computed(() => {
|
||||
if (props.block !== null) return BLOCK_HELP[props.block]
|
||||
return props.forwards ? `${MAIL_FILTER_APPLY_TEXT.help}${MAIL_FILTER_APPLY_TEXT.helpForward}` : MAIL_FILTER_APPLY_TEXT.help
|
||||
})
|
||||
|
||||
function onChange(event: Event) {
|
||||
emit('update:checked', (event.target as HTMLInputElement).checked)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="filter-apply"
|
||||
data-filter-apply
|
||||
>
|
||||
<div class="filter-apply__box">
|
||||
<label :class="['filter-apply__check', { 'is-disabled': block !== null }]">
|
||||
<input
|
||||
id="mail-filter-apply"
|
||||
type="checkbox"
|
||||
class="filter-apply__input"
|
||||
:checked="checked && block === null"
|
||||
:disabled="block !== null"
|
||||
aria-describedby="mail-filter-apply-help"
|
||||
@change="onChange"
|
||||
>
|
||||
<span class="filter-apply__label">{{ MAIL_FILTER_APPLY_TEXT.label }}</span>
|
||||
</label>
|
||||
<p
|
||||
id="mail-filter-apply-help"
|
||||
class="form-message filter-apply__help"
|
||||
>
|
||||
{{ help }}
|
||||
</p>
|
||||
</div>
|
||||
<p
|
||||
class="form-message"
|
||||
data-filter-apply-spam
|
||||
>
|
||||
{{ MAIL_FILTER_APPLY_TEXT.spam }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 상자와 아래 한 줄 사이 10 (디자인 원본 MailFilterApply — 창의 묶음 사이 16 에서 6 을 당긴다) */
|
||||
.filter-apply {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-10);
|
||||
}
|
||||
|
||||
.filter-apply__box {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-6) var(--space-12) var(--space-10);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface-panel);
|
||||
}
|
||||
|
||||
.filter-apply__check {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-10);
|
||||
min-height: var(--size-touch);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.filter-apply__check.is-disabled {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.filter-apply__input {
|
||||
width: var(--size-option-input);
|
||||
height: var(--size-option-input);
|
||||
margin: 0;
|
||||
accent-color: var(--color-brand);
|
||||
cursor: inherit;
|
||||
}
|
||||
|
||||
.filter-apply__label {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.filter-apply__check.is-disabled .filter-apply__label {
|
||||
color: var(--color-icon-muted);
|
||||
}
|
||||
|
||||
/* 고르기 상자(18) + 사이(10) 만큼 들여 쓴다 — 할 일 칸의 안내와 같은 선 */
|
||||
.filter-apply__help {
|
||||
margin-left: calc(var(--size-option-input) + var(--space-10));
|
||||
}
|
||||
</style>
|
||||
@@ -1,7 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
// 필터 고치기 창 (디자인 원본 SettingsFilter "새 필터" · "필터 고치기" — 모달 620, 공용 모양 ModalDialog 의 넓은 창)
|
||||
// 이름(선택, 50자) → "어떤 메일에"(MailFilterConditionFields — 모두 · 하나라도 · 모든 메일 + 조건 줄 5개까지) →
|
||||
// 할 일(MailFilterActionFields) → 안내 띠 → [이 필터 지우기](고칠 때만, 왼쪽) · [저장] [취소].
|
||||
// 할 일(MailFilterActionFields) → 「받은편지함에 이미 있는 메일에도 적용」(MailFilterApplyField — 7단계, 6단계 안내 띠 자리) →
|
||||
// [이 필터 지우기](고칠 때만, 왼쪽) · [저장] [취소].
|
||||
// - 잘못은 [저장]을 누를 때 칸 아래 빨간 글로만 — 빈 조건 · `${` · 할 일 없음 · 전달 주소(빈 칸 · 형식 · 사내 주소가 아님 · 내 주소) ·
|
||||
// 이름 · 찾을 글의 길이 · 제어 문자(사전의 VAL_001).
|
||||
// 서버와 같은 규칙(filterDraftProblems)이고, 첫 잘못된 칸에 초점을 준다(알림 없음). 칸을 고치면 그 칸의 잘못을 내린다.
|
||||
@@ -10,20 +11,36 @@
|
||||
// - 열 때마다 칸을 새로 채운다 — 새 필터는 빈 조건 한 줄(읽기 화면에서 왔으면 "보낸 사람 · 포함 · 그 주소"), 고치기는 그 규칙.
|
||||
// 처음 초점은 이름 칸, 읽기 화면에서 만든 새 필터는 할 일의 첫 칸 (디자인 원본 MailFilterStates)
|
||||
// - 보내는 · 다시 받는 동안(pending)과 다른 저장을 기다리는 동안(busy)은 [저장] · [이 필터 지우기]를 aria-disabled 로 막는다 — 누른 단추가 초점을 잃지 않게.
|
||||
// [취소] · Esc 는 언제든 닫는다(보낸 저장의 결과는 부모가 알림으로 알린다)
|
||||
// - 「받은편지함에 이미 있는 메일에도 적용」은 열 때마다 꺼져 있다(필터에 저장하지 않는다). 이 탭에서 다른 적용이 진행 중(applyBusy)이거나
|
||||
// 이미 받은 메일에 할 일이 없으면(옮길 메일함이 지워짐 · 받은편지함으로 옮기기만 · 전달만 — filterApplyBlock) 고를 수 없다.
|
||||
// 고른 채 [저장]하면 save 의 apply 가 true — 저장한 뒤의 적용 · 알림은 부모가 한다(useMailFilterActions)
|
||||
// - 나가기 확인(7단계 — 6단계 한계를 고친다, useDialogLeaveConfirm): 창을 연 때의 값(새 필터는 빈 값 · 읽기 화면에서 채운 값)과 다르면
|
||||
// [취소] · Esc 는 확인 창 "저장하지 않은 필터가 있습니다"([계속 고치기] — 처음 초점 · Esc, [닫기]), 창을 연 채 다른 화면으로 가면 같은 창에
|
||||
// "이 화면을 나가면 …" [나가기], 탭 닫기 · 새로고침은 브라우저 확인창. 보내는 중(저장 · 지우기 — 결과는 부모가 알림으로 알린다) ·
|
||||
// 세션이 끝났을 때는 묻지 않는다. 확인 창은 고치기 창 위에 뜨고, 닫으면 초점이 제자리로 간다
|
||||
// 고정 id — #mail-filter-dialog · #mail-filter-dialog-title · #mail-filter-name · #mail-filter-name-error · #mail-filter-error · #mail-filter-reload ·
|
||||
// #mail-filter-remove · #mail-filter-save · #mail-filter-cancel (조건 · 할 일 칸의 id 는 각 칸 컴포넌트에)
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
// #mail-filter-remove · #mail-filter-save · #mail-filter-cancel · 나가기 확인 #mail-filter-leave-dialog · #mail-filter-leave-stay · #mail-filter-leave-confirm
|
||||
// (조건 · 할 일 · 적용 칸의 id 는 각 칸 컴포넌트에)
|
||||
import { computed, nextTick, reactive, ref, shallowRef, watch } from 'vue'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import LeaveConfirmDialog from '@/components/feedback/LeaveConfirmDialog.vue'
|
||||
import ModalDialog from '@/components/feedback/ModalDialog.vue'
|
||||
import MailFilterActionFields from '@/components/settings/MailFilterActionFields.vue'
|
||||
import MailFilterApplyField from '@/components/settings/MailFilterApplyField.vue'
|
||||
import MailFilterConditionFields from '@/components/settings/MailFilterConditionFields.vue'
|
||||
import { getErrorMessage } from '@/composables/useApi'
|
||||
import { useDialogLeaveConfirm } from '@/composables/useDialogLeaveConfirm'
|
||||
import type { MailFilterDialogPending } from '@/composables/useMailFilterDialog'
|
||||
import { MAIL_FILTER_DIALOG_TEXT, MAIL_FILTER_NAME_MAX_LENGTH, MAIL_FILTER_TEXT } from '@/shared/constants/mail-filter'
|
||||
import {
|
||||
MAIL_FILTER_DIALOG_TEXT,
|
||||
MAIL_FILTER_LEAVE_DIALOG,
|
||||
MAIL_FILTER_NAME_MAX_LENGTH,
|
||||
MAIL_FILTER_TEXT,
|
||||
} from '@/shared/constants/mail-filter'
|
||||
import type { Mailbox, MailFilterCondition, MailFilterMatch, MailFilterRule } from '@/shared/types/mail'
|
||||
import {
|
||||
copyFilterDraft,
|
||||
defaultMoveTarget,
|
||||
emptyCondition,
|
||||
filterDraftOf,
|
||||
@@ -36,12 +53,14 @@ import {
|
||||
newFilterDraft,
|
||||
newFilterRuleBase,
|
||||
noFilterProblems,
|
||||
sameFilterDraft,
|
||||
type MailFilterActionDraft,
|
||||
type MailFilterConditionProblem,
|
||||
type MailFilterDraft,
|
||||
type MailFilterForwardProblem,
|
||||
type MailFilterProblems,
|
||||
} from '@/shared/utils/mail-filter'
|
||||
import { filterApplyBlock } from '@/shared/utils/mail-filter-run'
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
@@ -60,10 +79,13 @@ interface Props {
|
||||
error?: string | null
|
||||
/** 그 거절이 그사이 바뀜(MAIL_012)이다 — 옆에 [다시 불러오기]를 둔다 */
|
||||
conflict?: boolean
|
||||
/** 이 탭에서 다른 필터를 받은편지함에 적용하는 중 — 「…에도 적용」을 고를 수 없다 */
|
||||
applyBusy?: boolean
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'save', rule: MailFilterRule): void
|
||||
/** apply — 「받은편지함에 이미 있는 메일에도 적용」을 골랐다 (저장한 뒤 이번에 한 번) */
|
||||
(e: 'save', rule: MailFilterRule, apply: boolean): void
|
||||
(e: 'remove'): void
|
||||
(e: 'cancel'): void
|
||||
(e: 'clear-error'): void
|
||||
@@ -77,9 +99,11 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
busy: false,
|
||||
error: null,
|
||||
conflict: false,
|
||||
applyBusy: false,
|
||||
})
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const modalRef = ref<InstanceType<typeof ModalDialog> | null>(null)
|
||||
const nameRef = ref<HTMLInputElement | null>(null)
|
||||
const conditionsRef = ref<InstanceType<typeof MailFilterConditionFields> | null>(null)
|
||||
const actionsRef = ref<InstanceType<typeof MailFilterActionFields> | null>(null)
|
||||
@@ -98,6 +122,18 @@ const draft = reactive<MailFilterDraft>(initialDraft())
|
||||
const problems = ref<MailFilterProblems>(noFilterProblems(draft.conditions.length))
|
||||
/** 새 규칙의 id · 켜짐 — 창을 열 때 한 번 만든다 (서버가 거절한 뒤 다시 눌러도 같은 규칙이다) */
|
||||
let base = baseOf()
|
||||
/** 창을 연 때의 칸 값 — 이것과 다르면 바꾼 것이다 (나가기 확인) */
|
||||
const opened = shallowRef<MailFilterDraft>(copyFilterDraft(draft))
|
||||
/** 「받은편지함에 이미 있는 메일에도 적용」을 골랐다 — 열 때마다 꺼진다 */
|
||||
const applyChosen = ref<boolean>(false)
|
||||
|
||||
const applyBlock = computed(() => filterApplyBlock(draft, props.mailboxes, props.applyBusy))
|
||||
/** 저장할 때 적용할지 — 고를 수 없는 동안은 골라 둔 것도 쓰지 않는다 (다시 고를 수 있게 되면 돌아온다) */
|
||||
const applyChecked = computed(() => applyChosen.value && applyBlock.value === null)
|
||||
/** 창을 연 뒤 바꾼 것이 있다 — 적용 칸을 고른 것도 바꾼 것이다 */
|
||||
const changed = computed(() => props.open && (applyChecked.value || !sameFilterDraft(draft, opened.value)))
|
||||
/** 저장 · 지우기를 보내는 중 — 닫거나 떠나도 묻지 않는다 (결과는 부모가 알림으로 알린다) */
|
||||
const sending = computed(() => props.pending === 'save' || props.pending === 'remove')
|
||||
|
||||
const actionsValue = computed<MailFilterActionDraft>(() => ({
|
||||
move: draft.move,
|
||||
@@ -123,11 +159,13 @@ function baseOf(): Pick<MailFilterRule, 'id' | 'isEnabled'> {
|
||||
return props.rule ? { id: props.rule.id, isEnabled: props.rule.isEnabled } : newFilterRuleBase()
|
||||
}
|
||||
|
||||
/** 열 때마다 — 그 규칙(또는 새 필터)으로 칸을 다시 채운다 */
|
||||
/** 열 때마다 — 그 규칙(또는 새 필터)으로 칸을 다시 채운다. 적용 칸은 꺼진다 */
|
||||
function resetDraft() {
|
||||
Object.assign(draft, initialDraft())
|
||||
problems.value = noFilterProblems(draft.conditions.length)
|
||||
base = baseOf()
|
||||
opened.value = copyFilterDraft(draft)
|
||||
applyChosen.value = false
|
||||
}
|
||||
|
||||
function conditionMessage(problem: MailFilterConditionProblem | null): string | null {
|
||||
@@ -187,6 +225,11 @@ function updateActions(patch: Partial<MailFilterActionDraft>) {
|
||||
})
|
||||
}
|
||||
|
||||
function setApply(checked: boolean) {
|
||||
applyChosen.value = checked
|
||||
edited({})
|
||||
}
|
||||
|
||||
/** 첫 잘못된 칸 — 이름 → 조건(차례대로) → 할 일 → 전달 주소 */
|
||||
function focusFirstProblem(found: MailFilterProblems) {
|
||||
const condition = found.conditions.findIndex((problem) => problem !== null)
|
||||
@@ -204,7 +247,7 @@ function save() {
|
||||
focusFirstProblem(found)
|
||||
return
|
||||
}
|
||||
emit('save', filterRuleOf(draft, base))
|
||||
emit('save', filterRuleOf(draft, base), applyChecked.value)
|
||||
}
|
||||
|
||||
function remove() {
|
||||
@@ -215,6 +258,17 @@ function reload() {
|
||||
if (props.pending === null) emit('reload')
|
||||
}
|
||||
|
||||
const { asking: leaveAsking, requestClose, stay: stayEditing, leave: leaveEditing } = useDialogLeaveConfirm({
|
||||
changed: () => changed.value,
|
||||
sending: () => sending.value,
|
||||
close: () => emit('cancel'),
|
||||
// 확인 창이 닫힌 뒤 — 브라우저가 고치기 창을 스스로 닫았으면(Esc 를 거듭 누름) 다시 띄운다
|
||||
stayed: () => void nextTick(() => modalRef.value?.reshow()),
|
||||
})
|
||||
const leaveCopy = computed(() =>
|
||||
leaveAsking.value === 'route' ? MAIL_FILTER_LEAVE_DIALOG.route : MAIL_FILTER_LEAVE_DIALOG.close,
|
||||
)
|
||||
|
||||
defineExpose({
|
||||
/** 다시 받은 뒤 — [저장]으로 (다시 받은 목록 위에 저장한다) */
|
||||
focusSave: () => saveRef.value?.focus(),
|
||||
@@ -232,11 +286,12 @@ watch(
|
||||
<template>
|
||||
<ModalDialog
|
||||
id="mail-filter-dialog"
|
||||
ref="modalRef"
|
||||
:open="open"
|
||||
:title="title"
|
||||
title-id="mail-filter-dialog-title"
|
||||
wide
|
||||
@cancel="emit('cancel')"
|
||||
@cancel="requestClose"
|
||||
>
|
||||
<div class="filter-dialog">
|
||||
<div class="filter-dialog__field">
|
||||
@@ -299,9 +354,12 @@ watch(
|
||||
@update="updateActions"
|
||||
/>
|
||||
|
||||
<p class="filter-dialog__notice">
|
||||
{{ MAIL_FILTER_DIALOG_TEXT.notice }}
|
||||
</p>
|
||||
<MailFilterApplyField
|
||||
:checked="applyChecked"
|
||||
:block="applyBlock"
|
||||
:forwards="draft.forward"
|
||||
@update:checked="setApply"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-if="error"
|
||||
@@ -365,12 +423,21 @@ watch(
|
||||
id="mail-filter-cancel"
|
||||
type="button"
|
||||
class="modal-dialog__action modal-dialog__action--secondary"
|
||||
@click="emit('cancel')"
|
||||
@click="requestClose"
|
||||
>
|
||||
{{ MAIL_FILTER_DIALOG_TEXT.cancel }}
|
||||
</button>
|
||||
</template>
|
||||
</ModalDialog>
|
||||
|
||||
<!-- 저장하지 않고 닫거나 떠나려 할 때 — 고치기 창 위에 뜬다 (닫기는 답을 받아 창을 닫고, 떠나기는 이동이 답을 기다린다) -->
|
||||
<LeaveConfirmDialog
|
||||
:open="leaveAsking !== null"
|
||||
id-prefix="mail-filter-leave"
|
||||
:copy="leaveCopy"
|
||||
@stay="stayEditing"
|
||||
@leave="leaveEditing"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@@ -431,15 +498,6 @@ watch(
|
||||
cursor: progress;
|
||||
}
|
||||
|
||||
.filter-dialog__notice {
|
||||
margin: 0;
|
||||
padding: var(--space-10) var(--space-12);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface-panel);
|
||||
font-size: var(--font-size-label);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
/* [이 필터 지우기] — 왼쪽 끝, 바탕 없는 빨간 글 (디자인 원본 SettingsFilter) */
|
||||
.modal-dialog__action.filter-dialog__remove {
|
||||
gap: var(--space-6);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
// 설정 › 필터 아래 안내 상자 (디자인 원본 SettingsFilter — 바탕 #F3F8F8, 최대 700):
|
||||
// 차례로 적용 · 옮기면 멈춤 / 스팸 제외 / 사내 전달만 · 한 곳 · 사본 남김 / 부재중과 함께 동작. 첫 줄의 "위에서부터 차례로"는 굵게
|
||||
// 설정 › 필터 아래 안내 상자 (디자인 원본 SettingsFilter · 7단계 MailFilterApply 의 글 — 바탕 #F3F8F8, 최대 700):
|
||||
// 차례로 적용 · 옮기면 멈춤 / 이미 받은 메일에도 적용하려면 저장할 때 고른다(7단계) / 스팸 제외 / 사내 전달만 · 새로 오는 메일만 · 한 곳 · 사본 남김 /
|
||||
// 부재중과 함께 동작. 첫 줄의 "위에서부터 차례로" · 둘째 줄의 칸 이름은 굵게
|
||||
import { MAIL_FILTER_TEXT } from '@/shared/constants/mail-filter'
|
||||
|
||||
const NOTES = [MAIL_FILTER_TEXT.noteSpam, MAIL_FILTER_TEXT.noteForward, MAIL_FILTER_TEXT.noteVacation] as const
|
||||
@@ -17,6 +18,15 @@ const NOTES = [MAIL_FILTER_TEXT.noteSpam, MAIL_FILTER_TEXT.noteForward, MAIL_FIL
|
||||
MAIL_FILTER_TEXT.noteOrderAfter
|
||||
}}</span>
|
||||
</p>
|
||||
<p
|
||||
class="filter-notes__note"
|
||||
data-filter-note-apply
|
||||
>
|
||||
<span aria-hidden="true">·</span>
|
||||
<span>{{ MAIL_FILTER_TEXT.noteApplyBefore }}<strong class="filter-notes__strong">{{
|
||||
MAIL_FILTER_TEXT.noteApplyStrong
|
||||
}}</strong>{{ MAIL_FILTER_TEXT.noteApplyAfter }}</span>
|
||||
</p>
|
||||
<p
|
||||
v-for="note in NOTES"
|
||||
:key="note"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
// 필터 목록 위 노란 줄 (디자인 원본 MailFilterStates) — 바탕 #FFF7E6 · 글 #7A5200, 아이콘 16 · 글 · 단추(36 — 밀도 예외 --size-notice-action)
|
||||
// 다른 곳에서 먼저 바꿈(409 MAIL_012 — [다시 불러오기]) · 동작하지 않음(isActive: false — [다시 켜기]) 두 곳에 쓴다.
|
||||
// 설정 화면 머리 아래 노란 줄 (디자인 원본 MailFilterStates · MailFilterApplyStates) — 바탕 #FFF7E6 · 글 #7A5200, 아이콘 16 · 글 · 단추(36 — 밀도 예외 --size-notice-action)
|
||||
// 필터 목록 위의 다른 곳에서 먼저 바꿈(409 MAIL_012 — [다시 불러오기]) · 동작하지 않음(isActive: false — [다시 켜기]),
|
||||
// 그리고 부재중 자동 응답 화면의 "켜 둔 자동 응답이 지금은 동작하지 않습니다"(7단계 — 단추 없음: actionLabel 을 주지 않는다)에 쓴다.
|
||||
// 단추를 누르고 기다리는 동안은 disabled 가 아니라 aria-disabled · aria-busy — 초점이 그대로 남게 (다시 누르는 것은 부모가 막는다)
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import type { IconName } from '@/shared/constants/icons'
|
||||
@@ -8,7 +9,8 @@ import type { IconName } from '@/shared/constants/icons'
|
||||
interface Props {
|
||||
icon: IconName
|
||||
message: string
|
||||
actionLabel: string
|
||||
/** 줄 안의 단추 — 없으면 글만 */
|
||||
actionLabel?: string
|
||||
busy?: boolean
|
||||
/** 저장이 막혀 바로 알려야 하면 alert(그사이 바뀜), 들어올 때부터 있던 상태면 status(동작하지 않음) */
|
||||
live?: 'alert' | 'status'
|
||||
@@ -19,6 +21,7 @@ interface Emits {
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
actionLabel: '',
|
||||
busy: false,
|
||||
live: 'status',
|
||||
})
|
||||
@@ -31,7 +34,7 @@ function onAction() {
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="filter-notice"
|
||||
:class="['filter-notice', { 'filter-notice--plain': !actionLabel }]"
|
||||
:role="live"
|
||||
>
|
||||
<AppIcon
|
||||
@@ -42,6 +45,7 @@ function onAction() {
|
||||
/>
|
||||
<span class="filter-notice__text">{{ message }}</span>
|
||||
<button
|
||||
v-if="actionLabel"
|
||||
type="button"
|
||||
class="filter-notice__action"
|
||||
:aria-disabled="busy ? 'true' : undefined"
|
||||
@@ -64,6 +68,11 @@ function onAction() {
|
||||
color: var(--color-warning-text);
|
||||
}
|
||||
|
||||
/* 단추 없는 줄 — 좌우 여백이 같다 (디자인 원본 MailFilterApplyStates 의 부재중 안내) */
|
||||
.filter-notice--plain {
|
||||
padding-right: var(--space-12);
|
||||
}
|
||||
|
||||
.filter-notice__icon {
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import { useLeaveConfirm } from './useLeaveConfirm'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
|
||||
// 고치던 창(모달)을 저장하지 않고 닫거나 떠나기 전 확인 — 필터 고치기 창 (디자인 README "7단계 › 저장하지 않고 닫을 때")
|
||||
// 화면의 나가기 확인(useLeaveConfirm)에 "창 닫기" 길을 더한다. 컴포넌트의 setup 에서 부른다 (이동 가드가 RouterView 의 화면에 걸린다)
|
||||
// - [취소] · Esc(브라우저가 스스로 닫은 것도 — ModalDialog 의 cancel): 바꾼 것이 있으면 확인 창(asking 'close') —
|
||||
// [계속 고치기]면 창을 그대로 두고(stayed — 브라우저가 닫았으면 다시 띄운다), [닫기]면 close
|
||||
// - 창을 연 채 다른 화면으로: 이동 가드가 답을 기다린다(asking 'route' — [나가기]면 그대로 간다, [계속 고치기]면 이동을 취소한다)
|
||||
// - 탭 닫기 · 새로고침: 바꾼 것이 있는 동안 beforeunload (브라우저 확인창 — 보내는 중에도)
|
||||
// - 묻지 않는 때: 바꾼 것이 없다 · 보내는 중(저장 · 지우기 — 결과는 알림으로 알린다) · 세션이 끝났다 (서명 · 부재중 설정과 같다).
|
||||
// 묻는 중에 그렇게 되면 확인 창을 닫는다 — 닫기 확인은 창을 그대로 두고, 이동 확인은 그 이동을 취소한다(useLeaveConfirm)
|
||||
// - 닫기 확인이 떠 있는 동안 다른 화면으로 가려 하면(뒤로 가기) 이동 확인이 이어받는다 — 같은 확인 창에 글만 바뀐다
|
||||
|
||||
export interface DialogLeaveConfirmOptions {
|
||||
/** 창이 열려 있고 연 때와 다르다 — 반응형으로 읽는다 */
|
||||
changed: () => boolean
|
||||
/** 보내는 중 — 이때는 묻지 않는다 (반응형) */
|
||||
sending: () => boolean
|
||||
/** 창을 닫는다 — 바꾼 것이 없거나 [닫기]를 골랐다 */
|
||||
close: () => void
|
||||
/** [계속 고치기]를 골랐다 — 창이 보이게 한다 (브라우저가 스스로 닫았을 수 있다 — ModalDialog 의 reshow) */
|
||||
stayed?: () => void
|
||||
}
|
||||
|
||||
/** 지금 띄운 확인 — close: [취소] · Esc 로 닫으려 함 · route: 다른 화면으로 가려 함 · null: 묻지 않는 중 */
|
||||
export type DialogLeaveAsking = 'close' | 'route' | null
|
||||
|
||||
export function useDialogLeaveConfirm(options: DialogLeaveConfirmOptions) {
|
||||
const authStore = useAuthStore()
|
||||
const closeConfirming = ref<boolean>(false)
|
||||
|
||||
/** 지금 닫거나 떠나면 잃는 것이 있다 — 이때만 묻는다 */
|
||||
const guarded = () => options.changed() && !options.sending() && authStore.isLoggedIn
|
||||
|
||||
const route = useLeaveConfirm({
|
||||
active: guarded,
|
||||
unloadActive: () => options.changed() && authStore.isLoggedIn,
|
||||
})
|
||||
|
||||
const asking = computed<DialogLeaveAsking>(() => {
|
||||
if (route.confirming.value) return 'route'
|
||||
return closeConfirming.value ? 'close' : null
|
||||
})
|
||||
|
||||
/** [취소] · Esc — 잃을 것이 있으면 묻고, 없으면 닫는다 */
|
||||
function requestClose() {
|
||||
if (guarded()) closeConfirming.value = true
|
||||
else options.close()
|
||||
}
|
||||
|
||||
/** [계속 고치기] (확인 창의 Esc 포함) */
|
||||
function stay() {
|
||||
if (route.confirming.value) route.stay()
|
||||
closeConfirming.value = false
|
||||
options.stayed?.()
|
||||
}
|
||||
|
||||
/** [닫기] · [나가기] */
|
||||
function leave() {
|
||||
if (route.confirming.value) {
|
||||
route.leave()
|
||||
return
|
||||
}
|
||||
closeConfirming.value = false
|
||||
options.close()
|
||||
}
|
||||
|
||||
watch(route.confirming, (confirming) => {
|
||||
if (confirming) closeConfirming.value = false
|
||||
})
|
||||
watch(guarded, (active) => {
|
||||
if (!active) closeConfirming.value = false
|
||||
})
|
||||
|
||||
return { asking, requestClose, stay, leave }
|
||||
}
|
||||
@@ -1,7 +1,14 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
|
||||
import type { MailFilterSet, RejectedRecipient, SendMessagePayload } from '@/shared/types/mail'
|
||||
import { MAIL_FILTER_RUN_TIMEOUT_MS } from '@/shared/constants/mail'
|
||||
import type {
|
||||
MailFilterRun,
|
||||
MailFilterRunRequest,
|
||||
MailFilterSet,
|
||||
RejectedRecipient,
|
||||
SendMessagePayload,
|
||||
} from '@/shared/types/mail'
|
||||
import { installMockApi, ok, restoreApiAdapter } from '@/test-utils/mock-api'
|
||||
import { useMail } from './useMail'
|
||||
|
||||
@@ -79,3 +86,31 @@ describe('useMail 메일 필터 — GET · PUT /mail-filters (계약 2절 "메
|
||||
expect(requests[1]?.data).toEqual({ rules: SET.rules, version: 'v1' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('useMail 이미 받은 메일에 적용 — POST /mail-filter-runs (계약 2절 "이미 받은 메일에 적용")', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
restoreApiAdapter()
|
||||
})
|
||||
|
||||
it('규칙(전달 없이)과 cursor 를 본문으로 보내고 — 한 요청이 25초까지 걸리므로 기본 10초보다 길게 기다린다', async () => {
|
||||
const run: MailFilterRun = { scanned: 4500, matched: 37, remaining: 3912, cursor: 'c1' }
|
||||
const requests = installMockApi(() => ok(run))
|
||||
const request: MailFilterRunRequest = {
|
||||
match: 'all',
|
||||
conditions: [{ field: 'from', operator: 'contains', value: '@hanbit-partners.co.kr' }],
|
||||
actions: { moveToMailboxId: 'vendor', markAsRead: false, flag: false },
|
||||
cursor: null,
|
||||
}
|
||||
|
||||
await expect(useMail().runFilter(request, { silent: ['REQ_001'] })).resolves.toEqual(run)
|
||||
|
||||
expect(requests.map((item) => `${item.method} ${item.url}`)).toEqual(['POST /mail-filter-runs'])
|
||||
expect(requests[0]?.data).toEqual(request)
|
||||
expect(requests[0]?.config.timeout).toBe(MAIL_FILTER_RUN_TIMEOUT_MS)
|
||||
expect(requests[0]?.config.silent).toEqual(['REQ_001'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { AxiosRequestConfig } from 'axios'
|
||||
|
||||
import { buildApiUrl, confirmSession, useApi } from './useApi'
|
||||
import {
|
||||
MAIL_FILTER_RUN_TIMEOUT_MS,
|
||||
MAIL_IDEMPOTENCY_HEADER,
|
||||
MAIL_MAILBOX_OPERATION_TIMEOUT_MS,
|
||||
MAIL_SEND_TIMEOUT_MS,
|
||||
@@ -12,12 +13,15 @@ import type {
|
||||
Draft,
|
||||
DraftPayload,
|
||||
DraftSaved,
|
||||
MailFilterRun,
|
||||
MailFilterRunRequest,
|
||||
MailFilterSet,
|
||||
MailFilterSetPayload,
|
||||
MailQuota,
|
||||
MailSignature,
|
||||
MailState,
|
||||
MailVacation,
|
||||
MailVacationState,
|
||||
Mailbox,
|
||||
MailboxBulkResult,
|
||||
MailboxCreatePayload,
|
||||
@@ -54,6 +58,17 @@ export interface SendMessageOptions extends MailRequestOptions {
|
||||
/** POST /messages 가 실제로 주는 모양 — 옛 서버는 rejectedRecipients 가 없다 (sendMessage 가 배열로 맞춘다) */
|
||||
type SentMessageReply = Omit<SentMessage, 'rejectedRecipients'> & { rejectedRecipients?: unknown }
|
||||
|
||||
/** GET · PUT /mail-vacation 이 실제로 주는 모양 — 7단계 앞의 서버는 isActive 가 없다 (vacationStateOf 가 맞춘다) */
|
||||
type MailVacationReply = MailVacation & { isActive?: unknown }
|
||||
|
||||
/**
|
||||
* 부재중 응답 → 화면의 값. isActive 가 없거나 불리언이 아니면(옛 서버 · 응답을 바꾼 게이트웨이) 동작하는 것으로 본다 —
|
||||
* "지금은 동작하지 않습니다" 줄을 잘못 띄우지 않게 (계약 2절 "부재중 자동 응답"의 MailVacationState)
|
||||
*/
|
||||
function vacationStateOf(reply: MailVacationReply): MailVacationState {
|
||||
return { ...reply, isActive: typeof reply.isActive === 'boolean' ? reply.isActive : true }
|
||||
}
|
||||
|
||||
export interface UploadOptions {
|
||||
/** 0~100 */
|
||||
onProgress?: (percent: number) => void
|
||||
@@ -137,14 +152,24 @@ export function useMail() {
|
||||
|
||||
// ── 부재중 자동 응답 (계약 2절 "부재중 자동 응답" — 4단계) ──────────
|
||||
|
||||
/** 설정한 적 없으면 꺼짐 · 빈 제목 · 빈 내용이 온다 */
|
||||
function getVacation(options?: MailRequestOptions): Promise<MailVacation> {
|
||||
return api.get<MailVacation, MailVacation>('/mail-vacation', options)
|
||||
/** 설정한 적 없으면 꺼짐 · 빈 제목 · 빈 내용이 온다. isActive 는 엔진에서 지금 동작하는지 (7단계) */
|
||||
async function getVacation(options?: MailRequestOptions): Promise<MailVacationState> {
|
||||
return vacationStateOf(await api.get<MailVacationReply, MailVacationReply>('/mail-vacation', options))
|
||||
}
|
||||
|
||||
/** 전체 교체 — 꺼도 제목 · 내용은 남긴다 */
|
||||
function saveVacation(payload: MailVacation, options?: MailRequestOptions): Promise<MailVacation> {
|
||||
return api.put<MailVacation, MailVacation>('/mail-vacation', payload, options)
|
||||
/**
|
||||
* 전체 교체 — 꺼도 제목 · 내용은 남긴다. 응답은 저장한 값 + isActive (저장하면 엔진에서 다시 켜진다).
|
||||
* 본문은 다섯 칸만 골라 보낸다 — 받은 응답(isActive 가 든 MailVacationState)을 그대로 넘겨도 isActive 가 실리지 않게 (서버가 400 VAL_001)
|
||||
*/
|
||||
async function saveVacation(payload: MailVacation, options?: MailRequestOptions): Promise<MailVacationState> {
|
||||
const body: MailVacation = {
|
||||
isEnabled: payload.isEnabled,
|
||||
fromDate: payload.fromDate,
|
||||
toDate: payload.toDate,
|
||||
subject: payload.subject,
|
||||
textBody: payload.textBody,
|
||||
}
|
||||
return vacationStateOf(await api.put<MailVacationReply, MailVacationReply>('/mail-vacation', body, options))
|
||||
}
|
||||
|
||||
// ── 메일 필터 (계약 2절 "메일 필터" — 6단계) ──────────────────────
|
||||
@@ -162,6 +187,18 @@ export function useMail() {
|
||||
return api.put<MailFilterSet, MailFilterSet>('/mail-filters', payload, options)
|
||||
}
|
||||
|
||||
/**
|
||||
* 받은편지함에 이미 있는 메일에 규칙 하나를 적용한다 (계약 2절 "이미 받은 메일에 적용" — 7단계). 한 요청은 25초 안에 끝나고
|
||||
* cursor 를 돌려준다 — null 이 될 때까지 같은 본문에 그 cursor 를 넣어 다시 보낸다(부르는 쪽 — mail-filter-run.store).
|
||||
* 같은 직원의 적용이 다른 곳에서 진행 중이면 429 REQ_001, 규칙 · cursor 가 틀리면 400 VAL_001, 하나도 확인하지 못했으면 503 MAIL_001
|
||||
*/
|
||||
function runFilter(request: MailFilterRunRequest, options?: MailRequestOptions): Promise<MailFilterRun> {
|
||||
return api.post<MailFilterRun, MailFilterRun>('/mail-filter-runs', request, {
|
||||
timeout: MAIL_FILTER_RUN_TIMEOUT_MS,
|
||||
...options,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 새 메일 확인 — 메일함이나 메일이 하나라도 바뀌면 state 가 달라진다 (계약 2절 "새 메일 확인").
|
||||
* 메일을 읽지 않는 가벼운 조회라 화면이 보이는 동안 1분마다 부른다 (useMailStatePolling)
|
||||
@@ -358,6 +395,7 @@ export function useMail() {
|
||||
saveVacation,
|
||||
getFilters,
|
||||
saveFilters,
|
||||
runFilter,
|
||||
messageSourceUrl,
|
||||
downloadMessageSource,
|
||||
getMailState,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { notifyApiError } from './useApi'
|
||||
import { useMailFilterRun } from './useMailFilterRun'
|
||||
import { useToast } from './useToast'
|
||||
import { MAIL_FILTER_TEXT } from '@/shared/constants/mail-filter'
|
||||
import type { MailFilterRule } from '@/shared/types/mail'
|
||||
@@ -10,21 +11,32 @@ import {
|
||||
} from '@/stores/mail-filter.store'
|
||||
|
||||
// 메일 필터 설정 화면의 저장 · 지우기 알림 (디자인 README "6단계 › 상태 · 들어오는 길" · 디자인 원본 MailFilterStates "알림 문구")
|
||||
// - 고치기 창의 [저장]이 끝나면 "필터를 저장했습니다."
|
||||
// - 고치기 창의 [저장]이 끝나면 "필터를 저장했습니다." — 「받은편지함에 이미 있는 메일에도 적용」을 골랐으면(7단계) 그 대신
|
||||
// 저장한 그 규칙(응답 목록의 같은 id — 서버가 앞뒤 공백을 지운 값)으로 받은편지함에 적용하는 진행 알림 (useMailFilterRun)
|
||||
// - 지우기는 확인 없이 하고 "‘거래처 메일’ 필터를 지웠습니다." + [되돌리기](10초 — 단추가 있는 알림, 띄운 세션에서만 움직인다 — useToast).
|
||||
// 되돌리기는 지운 규칙을 지운 자리(앞 · 뒤 규칙 기준)에 다시 저장한다 → "필터를 되돌렸습니다." 알림 여럿의 [되돌리기]는 누른 차례대로 보낸다(스토어).
|
||||
// 실패는 인터셉터가 알린다. 그사이 바뀜(409)만은 알림을 끄고 목록 위 줄이 알리는데, 필터 화면이 떠 있지 않으면 볼 곳이 없으니
|
||||
// 알림으로 알린다(고치기 창의 notifyUnseen 과 같은 규칙). 다시 넣으면 50개를 넘으면 보내지 않고 오류 알림(fullNote)
|
||||
// - 켜기 · 끄기 · 순서 바꾸기는 알리지 않는다 — 목록이 바로 바뀐다(실패하면 스토어가 되돌리고 인터셉터가 알린다)
|
||||
|
||||
export interface MailFilterSaveOptions {
|
||||
/** 「받은편지함에 이미 있는 메일에도 적용」 — 저장한 뒤 이번에 한 번 (필터의 설정이 아니다) */
|
||||
apply?: boolean
|
||||
}
|
||||
|
||||
export function useMailFilterActions() {
|
||||
const filterStore = useMailFilterStore()
|
||||
const filterRun = useMailFilterRun()
|
||||
const toast = useToast()
|
||||
|
||||
/** 고치기 창의 [저장] — 칸 안에 알릴 거절(VAL_001 · MAIL_013 · MAIL_012 · 50개)은 부르는 쪽이 그린다 */
|
||||
async function save(rule: MailFilterRule): Promise<MailFilterSaveOutcome> {
|
||||
async function save(rule: MailFilterRule, options: MailFilterSaveOptions = {}): Promise<MailFilterSaveOutcome> {
|
||||
const outcome = await filterStore.saveRule(rule)
|
||||
if (outcome.ok) toast.success(MAIL_FILTER_TEXT.saved)
|
||||
if (!outcome.ok) return outcome
|
||||
const saved = outcome.filterSet.rules.find((item) => item.id === rule.id) ?? rule
|
||||
// 적용을 시작했으면 진행 알림이 저장 알림을 대신한다 (할 일이 전달뿐이면 시작하지 않는다)
|
||||
if (options.apply && filterRun.start(saved)) return outcome
|
||||
toast.success(MAIL_FILTER_TEXT.saved)
|
||||
return outcome
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { onBeforeUnmount, ref } from 'vue'
|
||||
|
||||
import { notifyApiError, type ApiError } from './useApi'
|
||||
import { useMailFilterActions } from './useMailFilterActions'
|
||||
import { useMailFilterActions, type MailFilterSaveOptions } from './useMailFilterActions'
|
||||
import type { ErrorCode } from '@/shared/constants/error-codes.generated'
|
||||
import { MAIL_FILTER_TEXT } from '@/shared/constants/mail-filter'
|
||||
import type { MailFilterRule } from '@/shared/types/mail'
|
||||
@@ -14,6 +14,8 @@ import { useMailFilterStore } from '@/stores/mail-filter.store'
|
||||
// - 그사이 바뀜이면 창 안에 [다시 불러오기]를 둔다(conflict — 창이 모달이라 목록 위 줄을 누를 수 없다). 다시 받으면 창 · 쓴 내용은 그대로 두고 안내를 내린다 —
|
||||
// 다음 [저장]은 다시 받은 목록 · version 위에 넣는다(스토어의 saveRule). 칸을 고쳐도 이 안내는 남는다(다시 받아야 풀린다)
|
||||
// - 끝나면 초점을 줄 규칙을 돌려준다 — 저장은 그 규칙, 지우기는 이웃 행(다음, 마지막이었으면 앞). 초점은 화면이 옮긴다
|
||||
// - [저장]이 「받은편지함에 이미 있는 메일에도 적용」을 골랐으면(7단계 — options.apply) 저장한 뒤 적용한다(useMailFilterActions) —
|
||||
// 창을 그사이 닫았어도 저장했으면 적용한다(고른 사람이 저장을 보냈다)
|
||||
|
||||
export type MailFilterDialogPending = 'save' | 'remove' | 'reload' | null
|
||||
|
||||
@@ -88,12 +90,12 @@ export function useMailFilterDialog() {
|
||||
}
|
||||
|
||||
/** [저장] — 지금 목록 위에 넣는다(있는 규칙은 그 자리, 새 규칙 · 다른 곳에서 지워진 규칙은 맨 아래 — 스토어의 saveRule) */
|
||||
async function save(rule: MailFilterRule): Promise<MailFilterDialogResult> {
|
||||
async function save(rule: MailFilterRule, options: MailFilterSaveOptions = {}): Promise<MailFilterDialogResult> {
|
||||
const current = state.value
|
||||
if (!current || pending.value || filterStore.saving) return KEPT
|
||||
pending.value = 'save'
|
||||
showError(null)
|
||||
const outcome = await filterActions.save(rule)
|
||||
const outcome = await filterActions.save(rule, options)
|
||||
if (!isCurrent(current.token)) {
|
||||
if (!outcome.ok) notifyUnseen(outcome.error, DIALOG_ERROR_CODES)
|
||||
return KEPT
|
||||
|
||||
@@ -0,0 +1,403 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { nextTick } from 'vue'
|
||||
|
||||
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
|
||||
import { SEARCH_SCOPE_ALL } from '@/shared/constants/mail'
|
||||
import type { Mailbox, MailFilterRule, MailFilterRun, MailFilterRunRequest } from '@/shared/types/mail'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useMailFilterRunStore } from '@/stores/mail-filter-run.store'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
import { useToastStore, type Toast } from '@/stores/toast.store'
|
||||
import {
|
||||
fail,
|
||||
flushPromises,
|
||||
installMockApi,
|
||||
ok,
|
||||
restoreApiAdapter,
|
||||
type MockRequest,
|
||||
type MockResult,
|
||||
} from '@/test-utils/mock-api'
|
||||
import { useMailFilterRun } from './useMailFilterRun'
|
||||
|
||||
// 이미 받은 메일에 필터 적용 — 진행 · 결과 알림 · 끝난 뒤 개수 · 목록 맞추기 (디자인 원본 MailFilterApply · MailFilterApplyStates · 계약 2절 "이미 받은 메일에 적용")
|
||||
|
||||
const USER = { email: 'ara.kim@arasystem.it.kr', name: '김아라' }
|
||||
|
||||
function mailbox(id: string, name: string, role: Mailbox['role'] = null, totalCount = 0): Mailbox {
|
||||
return { id, name, role, parentId: null, sortOrder: 0, totalCount, unreadCount: 0 }
|
||||
}
|
||||
|
||||
const MAILBOXES: Mailbox[] = [
|
||||
mailbox('inbox', '받은편지함', 'inbox', 8412),
|
||||
mailbox('vendor', '거래처'),
|
||||
mailbox('notice', '전체 공지'),
|
||||
]
|
||||
|
||||
function rule(patch: Partial<MailFilterRule> = {}): MailFilterRule {
|
||||
return {
|
||||
id: 'r2',
|
||||
name: '거래처 메일',
|
||||
isEnabled: true,
|
||||
match: 'all',
|
||||
conditions: [{ field: 'from', operator: 'contains', value: '@hanbit-partners.co.kr' }],
|
||||
actions: { moveToMailboxId: 'vendor', markAsRead: false, flag: false, forwardTo: null },
|
||||
...patch,
|
||||
}
|
||||
}
|
||||
|
||||
const NO_ACTIONS = { moveToMailboxId: null, markAsRead: false, flag: false, forwardTo: null }
|
||||
|
||||
function step(scanned: number, matched: number, remaining: number, cursor: string | null): MockResult {
|
||||
const run: MailFilterRun = { scanned, matched, remaining, cursor }
|
||||
return ok(run)
|
||||
}
|
||||
|
||||
function deferred() {
|
||||
let finish: (result: MockResult) => void = () => undefined
|
||||
const promise = new Promise<MockResult>((resolve) => {
|
||||
finish = resolve
|
||||
})
|
||||
return { promise, finish }
|
||||
}
|
||||
|
||||
describe('useMailFilterRun', () => {
|
||||
let requests: MockRequest[]
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
useAuthStore().startSession(USER)
|
||||
useMailStore().mailboxes = MAILBOXES.map((item) => ({ ...item }))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
useToastStore().clear()
|
||||
restoreApiAdapter()
|
||||
})
|
||||
|
||||
/** 적용 요청에 차례로 돌려줄 응답 — 다 쓰면 마지막 것을 되풀이한다. 개수 · 목록 맞추기의 조회는 그대로 답한다 */
|
||||
function replies(...results: (MockResult | Promise<MockResult>)[]) {
|
||||
let next = 0
|
||||
requests = installMockApi((request) => {
|
||||
if (request.url === '/mail-filter-runs') {
|
||||
const result = results[Math.min(next, results.length - 1)] as MockResult | Promise<MockResult>
|
||||
next += 1
|
||||
return result
|
||||
}
|
||||
if (request.url === '/mailboxes') {
|
||||
return ok({ items: MAILBOXES, pageInfo: { hasNext: false, nextCursor: null, total: MAILBOXES.length } })
|
||||
}
|
||||
if (request.url === '/messages') return ok({ items: [], pageInfo: { hasNext: false, nextCursor: null, total: 0 } })
|
||||
return ok(null)
|
||||
})
|
||||
}
|
||||
|
||||
const runs = () => requests.filter((request) => request.url === '/mail-filter-runs')
|
||||
const cursors = () => runs().map((request) => (request.data as MailFilterRunRequest).cursor)
|
||||
const shown = () => useToastStore().toasts.map((toast) => [toast.kind, toast.message])
|
||||
/** 개수 · 목록 맞추기로 보낸 조회 — "GET /mailboxes" · "GET /mail-quota" · "GET /messages?inbox" */
|
||||
const syncs = () =>
|
||||
requests
|
||||
.filter((request) => request.url !== '/mail-filter-runs')
|
||||
.map((request) => {
|
||||
const mailboxId = request.params.mailboxId
|
||||
return `${request.method} ${request.url}${typeof mailboxId === 'string' ? `?${mailboxId}` : ''}`
|
||||
})
|
||||
|
||||
function press(toast: Toast | undefined) {
|
||||
if (toast?.action && 'run' in toast.action) toast.action.run()
|
||||
}
|
||||
|
||||
/** 단추가 있는 알림 — 진행 알림이 바뀐 결과 · 바쁨 알림 (사전 문구의 오류 알림은 단추가 없다) */
|
||||
const withAction = () => useToastStore().toasts.find((toast) => toast.action !== null)
|
||||
|
||||
/** 메일 화면이 그 메일함의 목록을 보이고 있다 */
|
||||
function viewing(mailboxId: string) {
|
||||
const mailStore = useMailStore()
|
||||
mailStore.listSource = { kind: 'mailbox', mailboxId }
|
||||
mailStore.listStatus = 'ready'
|
||||
}
|
||||
|
||||
it('진행 알림이 남았다가 끝나면 그 자리에서 결과로 — "…메일 37통에 적용했습니다." + [거래처 보기](그 메일함으로)', async () => {
|
||||
const first = deferred()
|
||||
const second = deferred()
|
||||
replies(first.promise, second.promise)
|
||||
|
||||
expect(useMailFilterRun().start(rule())).toBe(true)
|
||||
// 첫 응답 전 — 전체는 받은편지함의 메일 수
|
||||
expect(shown()).toEqual([['progress', '‘거래처 메일’ 필터를 받은편지함에 적용하는 중 · 0 / 8,412통 확인']])
|
||||
const id = useToastStore().toasts[0]?.id
|
||||
|
||||
first.finish(step(4500, 30, 3912, 'c1'))
|
||||
await flushPromises()
|
||||
expect(shown()).toEqual([['progress', '‘거래처 메일’ 필터를 받은편지함에 적용하는 중 · 4,500 / 8,412통 확인']])
|
||||
second.finish(step(3912, 7, 0, null))
|
||||
await flushPromises()
|
||||
|
||||
const [toast] = useToastStore().toasts
|
||||
expect(useToastStore().toasts).toHaveLength(1)
|
||||
expect(toast).toMatchObject({
|
||||
id,
|
||||
kind: 'success',
|
||||
message: '‘거래처 메일’ 필터를 받은편지함의 메일 37통에 적용했습니다.',
|
||||
action: { label: '거래처 보기', to: { name: 'mail-mailbox', params: { mailboxId: 'vendor' } } },
|
||||
})
|
||||
expect(cursors()).toEqual([null, 'c1'])
|
||||
})
|
||||
|
||||
it.each([
|
||||
['읽음 · 중요만 하는 필터', rule({ name: '결재 알림', actions: { ...NO_ACTIONS, markAsRead: true, flag: true } }), 12, '‘결재 알림’ 필터를 받은편지함의 메일 12통에 적용했습니다.'],
|
||||
['받은편지함으로 옮기며 읽음 표시하는 필터', rule({ actions: { ...NO_ACTIONS, moveToMailboxId: 'inbox', markAsRead: true } }), 3, '‘거래처 메일’ 필터를 받은편지함의 메일 3통에 적용했습니다.'],
|
||||
['지금 메일함 목록에 없는 메일함으로 옮기며 읽음 표시하는 필터', rule({ actions: { ...NO_ACTIONS, moveToMailboxId: 'gone', markAsRead: true } }), 3, '‘거래처 메일’ 필터를 받은편지함의 메일 3통에 적용했습니다.'],
|
||||
['맞는 메일이 없는 옮기는 필터', rule(), 0, '받은편지함에 ‘거래처 메일’ 필터에 맞는 메일이 없습니다.'],
|
||||
])('%s — 단추 없이 끝난다', async (_label, target, matched, message) => {
|
||||
replies(step(8412, matched, 0, null))
|
||||
|
||||
useMailFilterRun().start(target)
|
||||
await flushPromises()
|
||||
|
||||
expect(useToastStore().toasts).toMatchObject([{ kind: 'success', message, action: null }])
|
||||
})
|
||||
|
||||
it.each([
|
||||
['전달만 하는 규칙', { ...NO_ACTIONS, forwardTo: 'doyun.lee@arasystem.it.kr' }],
|
||||
['받은편지함으로 옮기기만 하는 규칙', { ...NO_ACTIONS, moveToMailboxId: 'inbox' }],
|
||||
['지워진 메일함으로 옮기기만 하는 규칙', { ...NO_ACTIONS, moveToMailboxId: 'gone' }],
|
||||
])('%s — 이미 받은 메일에 할 일이 없어 시작하지 않는다 (보내지도 알리지도 않는다 — 부르는 쪽이 "필터를 저장했습니다.")', (_label, actions) => {
|
||||
replies(step(1, 1, 0, null))
|
||||
|
||||
expect(useMailFilterRun().start(rule({ actions }))).toBe(false)
|
||||
|
||||
expect(requests).toHaveLength(0)
|
||||
expect(shown()).toEqual([])
|
||||
})
|
||||
|
||||
it('메일함을 아직 받지 않아 받은편지함의 수를 모르면 첫 진행 문구에 수를 뺀다', async () => {
|
||||
useMailStore().mailboxes = []
|
||||
const first = deferred()
|
||||
replies(first.promise)
|
||||
|
||||
useMailFilterRun().start(rule({ actions: { ...NO_ACTIONS, markAsRead: true } }))
|
||||
|
||||
expect(shown()).toEqual([['progress', '‘거래처 메일’ 필터를 받은편지함에 적용하는 중']])
|
||||
first.finish(step(10, 0, 0, null))
|
||||
await flushPromises()
|
||||
})
|
||||
|
||||
it('도중에 멈추면 멈춤 문구 + [다시 시도] — 마지막 cursor 로 이어서 수를 이어 센다 (까닭은 사전 문구 알림)', async () => {
|
||||
replies(step(4200, 12, 4212, 'c1'), fail(503, 'MAIL_001'), step(4212, 25, 0, null))
|
||||
|
||||
useMailFilterRun().start(rule())
|
||||
await flushPromises()
|
||||
await flushPromises()
|
||||
|
||||
// 진행 알림이 그 자리에서 멈춤 문구로 — 까닭은 오류 알림이 함께
|
||||
expect(shown()).toEqual([
|
||||
['error', '‘거래처 메일’ 필터 — 8,412통 가운데 4,200통까지 확인하고 멈췄습니다(12통에 적용). 다시 시도해 주세요.'],
|
||||
['error', ERROR_CODE_MESSAGES.MAIL_001],
|
||||
])
|
||||
const stopped = withAction()
|
||||
expect(stopped?.action?.label).toBe('다시 시도')
|
||||
useToastStore().clear()
|
||||
|
||||
press(stopped)
|
||||
expect(shown()).toEqual([['progress', '‘거래처 메일’ 필터를 받은편지함에 적용하는 중 · 4,200 / 8,412통 확인']])
|
||||
await flushPromises()
|
||||
|
||||
expect(cursors()).toEqual([null, 'c1', 'c1'])
|
||||
expect(shown()).toEqual([['success', '‘거래처 메일’ 필터를 받은편지함의 메일 37통에 적용했습니다.']])
|
||||
})
|
||||
|
||||
it('처음 요청부터 실패하면 "필터는 저장했지만 …" + [다시 시도] — 처음부터 다시 한다', async () => {
|
||||
replies(fail(500, 'SYS_001'), step(8412, 37, 0, null))
|
||||
|
||||
useMailFilterRun().start(rule())
|
||||
await flushPromises()
|
||||
|
||||
expect(shown()).toEqual([
|
||||
['error', '필터는 저장했지만 받은편지함에 적용하지 못했습니다. 다시 시도해 주세요.'],
|
||||
['error', ERROR_CODE_MESSAGES.SYS_001],
|
||||
])
|
||||
const failed = withAction()
|
||||
expect(failed?.action?.label).toBe('다시 시도')
|
||||
useToastStore().clear()
|
||||
press(failed)
|
||||
expect(shown()).toEqual([['progress', '‘거래처 메일’ 필터를 받은편지함에 적용하는 중 · 0 / 8,412통 확인']])
|
||||
await flushPromises()
|
||||
|
||||
expect(cursors()).toEqual([null, null])
|
||||
expect(shown()).toEqual([['success', '‘거래처 메일’ 필터를 받은편지함의 메일 37통에 적용했습니다.']])
|
||||
})
|
||||
|
||||
it('다른 창 · PC 에서 적용하는 중이면(429) "다른 창에서 …" 알림 하나만 — 처음 요청이면 단추가 없다', async () => {
|
||||
replies(fail(429, 'REQ_001'))
|
||||
|
||||
useMailFilterRun().start(rule())
|
||||
await flushPromises()
|
||||
|
||||
expect(useToastStore().toasts).toMatchObject([
|
||||
{ kind: 'error', message: '다른 창에서 필터를 받은편지함에 적용하는 중입니다. 끝난 뒤 다시 시도해 주세요.', action: null },
|
||||
])
|
||||
})
|
||||
|
||||
it('도중의 429 — 같은 문구에 [다시 시도]가 마지막 cursor 로 이어서', async () => {
|
||||
replies(step(4200, 12, 4212, 'c1'), fail(429, 'REQ_001'), step(4212, 25, 0, null))
|
||||
|
||||
useMailFilterRun().start(rule())
|
||||
await flushPromises()
|
||||
await flushPromises()
|
||||
|
||||
const [toast] = useToastStore().toasts
|
||||
expect(useToastStore().toasts).toHaveLength(1)
|
||||
expect(toast).toMatchObject({
|
||||
kind: 'error',
|
||||
message: '다른 창에서 필터를 받은편지함에 적용하는 중입니다. 끝난 뒤 다시 시도해 주세요.',
|
||||
action: { label: '다시 시도' },
|
||||
})
|
||||
press(toast)
|
||||
await flushPromises()
|
||||
expect(cursors()).toEqual([null, 'c1', 'c1'])
|
||||
})
|
||||
|
||||
it('세션이 바뀌면 진행 알림을 닫고 다음 요청을 보내지 않는다 — 늦게 온 응답으로 알림을 다시 띄우지 않는다 · 목록도 맞추지 않는다', async () => {
|
||||
const first = deferred()
|
||||
replies(first.promise)
|
||||
|
||||
useMailFilterRun().start(rule())
|
||||
expect(shown()).toHaveLength(1)
|
||||
useAuthStore().clearAuth()
|
||||
await nextTick()
|
||||
expect(shown()).toEqual([])
|
||||
|
||||
first.finish(step(4500, 30, 3912, 'c1'))
|
||||
await flushPromises()
|
||||
expect(runs()).toHaveLength(1)
|
||||
expect(shown()).toEqual([])
|
||||
expect(syncs()).toEqual([])
|
||||
})
|
||||
|
||||
it('세션이 바뀐 뒤 늦게 온 실패 — 알림이 남지 않는다 (사전 문구 알림도 · 멈춤 알림도)', async () => {
|
||||
const first = deferred()
|
||||
replies(first.promise)
|
||||
|
||||
useMailFilterRun().start(rule())
|
||||
useAuthStore().clearAuth()
|
||||
useAuthStore().startSession(USER)
|
||||
await nextTick()
|
||||
|
||||
// 같은 코드의 알림은 3초 안에 다시 띄우지 않는다(useApi) — 이 파일에서 이 테스트만 쓰는 코드로
|
||||
first.finish(fail(403, 'AUTH_003'))
|
||||
await flushPromises()
|
||||
expect(shown()).toEqual([])
|
||||
expect(useMailFilterRunStore().running).toBe(false)
|
||||
})
|
||||
|
||||
it('[다시 시도]는 띄운 세션에서만 움직인다 — 다시 로그인한 뒤 눌러도 보내지 않는다', async () => {
|
||||
replies(fail(500, 'SYS_001'))
|
||||
useMailFilterRun().start(rule())
|
||||
await flushPromises()
|
||||
const failed = withAction()
|
||||
|
||||
useAuthStore().clearAuth()
|
||||
useAuthStore().startSession(USER)
|
||||
await nextTick()
|
||||
press(failed)
|
||||
await flushPromises()
|
||||
|
||||
expect(runs()).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('이 탭에서 다른 필터를 적용하는 중이면 시작하지 않고 알린다 — 그 알림의 [다시 시도]로 앞 적용이 끝난 뒤 같은 자리에서 이어 한다', async () => {
|
||||
const other = deferred()
|
||||
// 하나: 4,200통에서 멈춘다 → 둘이 도는 동안 하나의 [다시 시도] → 바쁨 알림 → 둘이 끝난 뒤 바쁨 알림의 [다시 시도]
|
||||
replies(step(4200, 12, 4212, 'c1'), fail(503, 'MAIL_001'), other.promise, step(4212, 25, 0, null))
|
||||
const run = useMailFilterRun()
|
||||
run.start(rule({ id: 'a', name: '하나' }))
|
||||
await flushPromises()
|
||||
await flushPromises()
|
||||
const stopped = withAction()
|
||||
useToastStore().clear()
|
||||
|
||||
expect(run.start(rule({ id: 'b', name: '둘' }))).toBe(true)
|
||||
expect(useMailFilterRunStore().running).toBe(true)
|
||||
// 저장 쪽에서 온 시작도 바쁘면 알리기만 한다 — [다시 시도](처음부터)를 붙여서
|
||||
expect(run.start(rule({ id: 'c', name: '셋' }))).toBe(false)
|
||||
press(stopped)
|
||||
await flushPromises()
|
||||
|
||||
expect(runs()).toHaveLength(3)
|
||||
const busy = '다른 필터를 받은편지함에 적용하는 중입니다. 끝난 뒤 다시 시도해 주세요.'
|
||||
expect(shown()).toEqual([
|
||||
['progress', '‘둘’ 필터를 받은편지함에 적용하는 중 · 0 / 8,412통 확인'],
|
||||
['error', busy],
|
||||
['error', busy],
|
||||
])
|
||||
const [, fromSave, resumed] = useToastStore().toasts
|
||||
expect(fromSave?.action?.label).toBe('다시 시도')
|
||||
expect(resumed?.action?.label).toBe('다시 시도')
|
||||
|
||||
other.finish(step(8412, 0, 0, null))
|
||||
await flushPromises()
|
||||
useToastStore().clear()
|
||||
|
||||
// 앞 적용이 끝났다 — 바쁨 알림의 [다시 시도]는 멈춘 자리(cursor c1 · 4,200통 · 12통)에서 이어 한다
|
||||
press(resumed)
|
||||
expect(shown()).toEqual([['progress', '‘하나’ 필터를 받은편지함에 적용하는 중 · 4,200 / 8,412통 확인']])
|
||||
await flushPromises()
|
||||
expect(cursors()).toEqual([null, 'c1', null, 'c1'])
|
||||
expect(shown()).toEqual([['success', '‘하나’ 필터를 받은편지함의 메일 37통에 적용했습니다.']])
|
||||
})
|
||||
|
||||
describe('끝난 뒤 개수 · 목록 맞추기 (4단계 오래 걸리는 처리와 같은 결)', () => {
|
||||
it('끝나면 메일함 개수 · 사용량을 다시 받고, 받은편지함 목록을 보고 있으면 그 목록도 맞춘다', async () => {
|
||||
viewing('inbox')
|
||||
replies(step(8412, 37, 0, null))
|
||||
|
||||
useMailFilterRun().start(rule())
|
||||
await flushPromises()
|
||||
|
||||
expect(syncs().sort()).toEqual(['GET /mail-quota', 'GET /mailboxes', 'GET /messages?inbox'])
|
||||
})
|
||||
|
||||
it('옮긴 메일함의 목록을 보고 있어도 맞추고, 다른 메일함이면 개수만 — 전체 메일함 검색은 건드리지 않는다', async () => {
|
||||
viewing('vendor')
|
||||
replies(step(8412, 37, 0, null))
|
||||
useMailFilterRun().start(rule())
|
||||
await flushPromises()
|
||||
expect(syncs().sort()).toEqual(['GET /mail-quota', 'GET /mailboxes', 'GET /messages?vendor'])
|
||||
|
||||
viewing('notice')
|
||||
replies(step(8412, 37, 0, null))
|
||||
useMailFilterRun().start(rule())
|
||||
await flushPromises()
|
||||
expect(syncs().sort()).toEqual(['GET /mail-quota', 'GET /mailboxes'])
|
||||
|
||||
useMailStore().listSource = { kind: 'search', q: '견적', scope: SEARCH_SCOPE_ALL }
|
||||
replies(step(8412, 37, 0, null))
|
||||
useMailFilterRun().start(rule())
|
||||
await flushPromises()
|
||||
expect(syncs().sort()).toEqual(['GET /mail-quota', 'GET /mailboxes'])
|
||||
})
|
||||
|
||||
it('이번에 나아간 뒤 멈췄으면 맞추고, 처음 요청부터 실패했거나 이어 하기가 곧바로 멈췄으면 맞추지 않는다', async () => {
|
||||
viewing('inbox')
|
||||
replies(step(4200, 12, 4212, 'c1'), fail(503, 'MAIL_001'))
|
||||
useMailFilterRun().start(rule())
|
||||
await flushPromises()
|
||||
await flushPromises()
|
||||
expect(syncs().sort()).toEqual(['GET /mail-quota', 'GET /mailboxes', 'GET /messages?inbox'])
|
||||
const stopped = withAction()
|
||||
|
||||
// 이어 하기가 곧바로 멈췄다 — 앞 멈춤이 이미 맞췄다
|
||||
replies(fail(503, 'MAIL_001'))
|
||||
press(stopped)
|
||||
await flushPromises()
|
||||
expect(syncs()).toEqual([])
|
||||
|
||||
replies(fail(503, 'MAIL_001'))
|
||||
useMailFilterRun().start(rule())
|
||||
await flushPromises()
|
||||
expect(syncs()).toEqual([])
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,135 @@
|
||||
import { useToast, type ProgressToast } from './useToast'
|
||||
import { MAIL_FILTER_RUN_TEXT } from '@/shared/constants/mail-filter'
|
||||
import type { MailFilterRule, MailFilterRunRule } from '@/shared/types/mail'
|
||||
import {
|
||||
filterRunRuleOf,
|
||||
filterRunTarget,
|
||||
mailFilterRunCopy,
|
||||
type MailFilterRunCopy,
|
||||
type MailFilterRunTally,
|
||||
} from '@/shared/utils/mail-filter-run'
|
||||
import { listLocationOf, scopeMailboxIdOf } from '@/shared/utils/mail-list-source'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useMailFilterRunStore, type MailFilterRunOutcome } from '@/stores/mail-filter-run.store'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
import type { ToastAction } from '@/stores/toast.store'
|
||||
|
||||
// 이미 받은 메일에 필터 적용 — 진행 · 결과 알림 (디자인 README "7단계 › 저장한 뒤 — 진행 · 결과 알림" · 디자인 원본 MailFilterApply · MailFilterApplyStates)
|
||||
// - 고치기 창의 [저장]이 성공했고 「받은편지함에 이미 있는 메일에도 적용」을 골랐으면 "필터를 저장했습니다." 대신 진행 알림
|
||||
// "‘거래처 메일’ 필터를 받은편지함에 적용하는 중 · 4,200 / 8,412통 확인"(첫 응답 전의 전체는 받은편지함의 메일 수 — 모르면 수를 뺀다)이
|
||||
// 남았다가 끝나면 그 자리에서 결과로 바뀐다 (4단계 오래 걸리는 처리 — useMailboxActions 의 runOperation 과 같은 결)
|
||||
// - 끝: "‘…’ 필터를 받은편지함의 메일 N통에 적용했습니다." + 옮기는 필터이고 1통 이상이면 [{메일함} 보기](filterRunTarget — 받은편지함 ·
|
||||
// 지금 메일함 목록에 없는 메일함이면 단추 없음). 맞는 메일이 없으면 "받은편지함에 ‘…’ 필터에 맞는 메일이 없습니다."
|
||||
// - 멈춤(이어 할 자리가 있다): "‘…’ 필터 — {전체}통 가운데 {확인}통까지 확인하고 멈췄습니다(N통에 적용). 다시 시도해 주세요." +
|
||||
// [다시 시도](그 cursor 로 이어서 — 수는 이어 센다)
|
||||
// - 처음 요청부터 실패: "필터는 저장했지만 받은편지함에 적용하지 못했습니다. 다시 시도해 주세요." + [다시 시도](처음부터)
|
||||
// - 429 REQ_001(같은 직원의 적용이 다른 창 · PC 에서 진행 중 — 알림은 이것 하나): "다른 창에서 …".
|
||||
// 이어 할 자리가 있을 때만 [다시 시도] (처음 요청이면 보드처럼 단추 없음)
|
||||
// 실패 까닭(서버 · 연결)은 스토어가 사전 문구로 알렸다 — 진행 알림은 결과로만 바뀌고 같은 오류를 다시 띄우지 않는다(4단계와 같다).
|
||||
// 단추는 띄운 세션에서만 움직인다 (useToast)
|
||||
// - 끝났거나 이번에 나아간 뒤 멈췄으면 개수 · 목록을 맞춘다(4단계 settle 과 같은 결) — 실시간 연결이 없는 탭에서 1분까지 옛 개수가 보이지 않게
|
||||
// - 이 탭에서 다른 적용이 돌고 있으면 시작하지 않고 "다른 필터를 … 끝난 뒤 다시 시도해 주세요." + 같은 [다시 시도](같은 자리 · 같은 수) —
|
||||
// 앞 적용이 끝난 뒤 다시 누를 수 있다 (고치기 창은 그동안 칸을 끈다 — 이 길은 앞 실패 알림의 [다시 시도]에서 온다)
|
||||
// - 그동안 다른 화면으로 가도 이어 한다 — 스토어가 돌리고, 이 함수가 만든 단추도 화면이 사라진 뒤에 움직인다. 세션이 바뀌면 멈추고 알림을 닫는다
|
||||
|
||||
/** 시작할 자리 — 처음부터(cursor null · 수 0) 또는 앞 실패의 자리 */
|
||||
type RunFrom = MailFilterRunTally & { cursor: string | null }
|
||||
|
||||
type FailedOutcome = Extract<MailFilterRunOutcome, { status: 'failed' }>
|
||||
|
||||
export function useMailFilterRun() {
|
||||
const runStore = useMailFilterRunStore()
|
||||
const mailStore = useMailStore()
|
||||
const authStore = useAuthStore()
|
||||
const toast = useToast()
|
||||
|
||||
/** 처음부터 — 진행 알림의 전체는 받은편지함의 메일 수 (메일함을 아직 받지 않았으면 0 — 수를 뺀다) */
|
||||
function fromStart(): RunFrom {
|
||||
const inboxTotal = Math.max(0, mailStore.mailboxByRole('inbox')?.totalCount ?? 0)
|
||||
return { cursor: null, scanned: 0, matched: 0, total: inboxTotal }
|
||||
}
|
||||
|
||||
/** 끝 알림의 단추 — 옮기는 필터이고 1통 이상 적용했을 때만 */
|
||||
function viewAction(rule: MailFilterRule, matched: number): ToastAction | undefined {
|
||||
const target = matched > 0 ? filterRunTarget(rule, mailStore.mailboxes) : null
|
||||
if (!target) return undefined
|
||||
return {
|
||||
label: MAIL_FILTER_RUN_TEXT.viewMailbox(target.name),
|
||||
to: listLocationOf({ kind: 'mailbox', mailboxId: target.id }),
|
||||
}
|
||||
}
|
||||
|
||||
function retryAction(rule: MailFilterRule, request: MailFilterRunRule, from: RunFrom): ToastAction {
|
||||
return { label: MAIL_FILTER_RUN_TEXT.retry, run: () => void execute(rule, request, from) }
|
||||
}
|
||||
|
||||
/**
|
||||
* 적용으로 바뀐 개수 · 목록을 맞춘다 (4단계 mailbox-admin 의 settle 과 같은 결) — 메일함 개수 · 사용량을 다시 받고,
|
||||
* 보이는 목록이 받은편지함이나 옮긴 메일함(그 안의 검색 포함)이면 그 목록도 맞춘다. 전체 메일함 검색은 건드리지 않는다
|
||||
*/
|
||||
function syncAfterRun(rule: MailFilterRule) {
|
||||
mailStore.syncCounts()
|
||||
const scope = scopeMailboxIdOf(mailStore.listSource)
|
||||
if (scope === null) return
|
||||
const inboxId = mailStore.mailboxByRole('inbox')?.id ?? null
|
||||
if (scope === inboxId || scope === rule.actions.moveToMailboxId) void mailStore.syncVisibleList({ includeSearch: true })
|
||||
}
|
||||
|
||||
/** 멈췄다 — 이어 할 자리가 있으면 그 자리에서, 없으면 처음부터 다시 하는 단추 (429 의 처음 요청만 단추가 없다) */
|
||||
function settleFailure(
|
||||
rule: MailFilterRule,
|
||||
request: MailFilterRunRule,
|
||||
copy: MailFilterRunCopy,
|
||||
outcome: FailedOutcome,
|
||||
progress: ProgressToast,
|
||||
) {
|
||||
const { cursor, scanned, matched, total } = outcome
|
||||
const resume = cursor !== null ? retryAction(rule, request, { cursor, scanned, matched, total }) : undefined
|
||||
if (outcome.error.code === 'REQ_001') progress.fail(MAIL_FILTER_RUN_TEXT.otherWindow, resume)
|
||||
else if (resume) progress.fail(copy.stopped(outcome), resume)
|
||||
else progress.fail(MAIL_FILTER_RUN_TEXT.notApplied, retryAction(rule, request, fromStart()))
|
||||
}
|
||||
|
||||
/**
|
||||
* 진행 알림을 띄우고 끝날 때까지 보낸다. 이 탭에서 다른 적용이 돌고 있으면 시작하지 않고 알린다 —
|
||||
* 같은 자리 · 같은 수의 [다시 시도]를 붙여 앞 적용이 끝난 뒤 다시 누를 수 있게
|
||||
*/
|
||||
async function execute(rule: MailFilterRule, request: MailFilterRunRule, from: RunFrom): Promise<void> {
|
||||
if (runStore.running) {
|
||||
toast.error(MAIL_FILTER_RUN_TEXT.busy, retryAction(rule, request, from))
|
||||
return
|
||||
}
|
||||
const session = authStore.generation
|
||||
const copy = mailFilterRunCopy(rule)
|
||||
const progress = toast.progress(copy.progress(from))
|
||||
const outcome = await runStore.run({ ...from, rule: request }, (tally) => progress.update(copy.progress(tally)))
|
||||
// 세션이 바뀌었다(진행 알림은 이미 닫혔다 — 다음 사람의 목록을 맞추지 않는다) · 이 탭의 다른 적용과 겹쳤다(위에서 막는다)
|
||||
if (outcome.status === 'canceled' || outcome.status === 'busy' || authStore.generation !== session) {
|
||||
progress.dismiss()
|
||||
return
|
||||
}
|
||||
if (outcome.status === 'done') {
|
||||
progress.succeed(copy.done(outcome.matched), viewAction(rule, outcome.matched))
|
||||
syncAfterRun(rule)
|
||||
return
|
||||
}
|
||||
settleFailure(rule, request, copy, outcome, progress)
|
||||
// 이번에 나아간 것이 있으면 — 거기까지 바뀐 개수 · 목록을 맞춘다 (이어 하기에서 곧바로 멈췄으면 앞 멈춤이 이미 맞췄다)
|
||||
if (outcome.scanned > from.scanned) syncAfterRun(rule)
|
||||
}
|
||||
|
||||
/**
|
||||
* 저장한 규칙(응답 목록의 그 규칙 — 앞뒤 공백을 지운 값)을 받은편지함에 적용한다. 진행 알림이 저장 알림을 대신하면 true.
|
||||
* 이미 받은 메일에 할 일이 없으면(고치기 창의 칸과 같은 판정 — 칸이 꺼져 있었다) 보내지 않는다. 이 탭에서 다른 적용이 돌고 있으면
|
||||
* 그렇게 알리기만 한다([다시 시도] 단추와 함께). 둘 다 false — 부르는 쪽이 "필터를 저장했습니다."를 띄운다
|
||||
*/
|
||||
function start(rule: MailFilterRule): boolean {
|
||||
const request = filterRunRuleOf(rule, mailStore.mailboxes)
|
||||
if (!request) return false
|
||||
const started = !runStore.running
|
||||
void execute(rule, request, fromStart())
|
||||
return started
|
||||
}
|
||||
|
||||
return { start }
|
||||
}
|
||||
@@ -1,14 +1,17 @@
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useToastStore, type TimedToastKind, type ToastAction } from '@/stores/toast.store'
|
||||
|
||||
/** 오래 걸리는 처리의 진행 알림 — 하는 중에는 남아 있다가 끝나면 그 자리에서 결과로 바뀐다 */
|
||||
/**
|
||||
* 오래 걸리는 처리의 진행 알림 — 하는 중에는 남아 있다가 끝나면 그 자리에서 결과로 바뀐다.
|
||||
* 결과에 단추 · 링크(action)를 붙일 수 있다 — 단추가 있는 알림의 시간(10초)과 세션 묶기(띄운 세션에서만 움직인다)는 다른 알림과 같다
|
||||
*/
|
||||
export interface ProgressToast {
|
||||
/** 진행 문구를 바꾼다 ("…하는 중 · 600 / 1,234통") */
|
||||
update: (message: string) => void
|
||||
/** 끝났다 — 성공 알림으로 바꾼다 (그사이 닫혔으면 새로 띄운다) */
|
||||
succeed: (message: string) => void
|
||||
/** 도중에 멈췄다 — 오류 알림으로 바꾼다 (그사이 닫혔으면 새로 띄운다) */
|
||||
fail: (message: string) => void
|
||||
/** 끝났다 — 성공 알림으로 바꾼다 (그사이 닫혔으면 새로 띄운다). 예: 필터 적용의 [거래처 보기] */
|
||||
succeed: (message: string, action?: ToastAction) => void
|
||||
/** 도중에 멈췄다 — 오류 알림으로 바꾼다 (그사이 닫혔으면 새로 띄운다). 예: 필터 적용의 [다시 시도] */
|
||||
fail: (message: string, action?: ToastAction) => void
|
||||
/** 알리지 않고 닫는다 */
|
||||
dismiss: () => void
|
||||
}
|
||||
@@ -54,17 +57,18 @@ export function useToast() {
|
||||
let id = store.show({ kind: 'progress', message })
|
||||
const sameSession = () => authStore.generation === session
|
||||
|
||||
function settle(kind: TimedToastKind, text: string) {
|
||||
function settle(kind: TimedToastKind, text: string, action?: ToastAction) {
|
||||
if (!sameSession()) return
|
||||
if (!store.update(id, { kind, message: text })) id = store.show({ kind, message: text })
|
||||
const bound = action ? sessionBound(action) : null
|
||||
if (!store.update(id, { kind, message: text, action: bound })) id = store.show({ kind, message: text, action: bound })
|
||||
}
|
||||
|
||||
return {
|
||||
update: (text) => {
|
||||
if (sameSession()) store.update(id, { message: text })
|
||||
},
|
||||
succeed: (text) => settle('success', text),
|
||||
fail: (text) => settle('error', text),
|
||||
succeed: (text, action) => settle('success', text, action),
|
||||
fail: (text, action) => settle('error', text, action),
|
||||
dismiss: () => store.dismiss(id),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,15 @@ import { RouterView } from 'vue-router'
|
||||
|
||||
import MessageReader from '@/components/mail/MessageReader.vue'
|
||||
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
|
||||
import type { Mailbox, MailFilterRule, MailFilterSet, MailFilterSetPayload, MessageDetail } from '@/shared/types/mail'
|
||||
import type {
|
||||
Mailbox,
|
||||
MailFilterRule,
|
||||
MailFilterRun,
|
||||
MailFilterRunRequest,
|
||||
MailFilterSet,
|
||||
MailFilterSetPayload,
|
||||
MessageDetail,
|
||||
} from '@/shared/types/mail'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
import { useToastStore } from '@/stores/toast.store'
|
||||
@@ -19,10 +27,10 @@ import {
|
||||
type MockRequest,
|
||||
type MockResult,
|
||||
} from '@/test-utils/mock-api'
|
||||
import { createTestRouter } from '@/test-utils/router'
|
||||
import { createTestRouter, routeViewProvide } from '@/test-utils/router'
|
||||
import MailFilterPage from './MailFilterPage.vue'
|
||||
|
||||
// 설정 › 메일 › 필터 (디자인 원본 SettingsFilter · MailFilterStates · 계약 2절 "메일 필터")
|
||||
// 설정 › 메일 › 필터 (디자인 원본 SettingsFilter · MailFilterStates · MailFilterApply · MailFilterApplyStates · 계약 2절 "메일 필터" · "이미 받은 메일에 적용")
|
||||
|
||||
const USER = { email: 'ara.kim@arasystem.it.kr', name: '김아라' }
|
||||
|
||||
@@ -95,6 +103,8 @@ const SET: MailFilterSet = { rules: RULES, version: 'v1', isActive: true }
|
||||
interface Server {
|
||||
filters?: MailFilterSet | MockResult
|
||||
save?: (request: MockRequest) => MockResult | Promise<MockResult>
|
||||
/** POST /mail-filter-runs — 이미 받은 메일에 적용 (7단계) */
|
||||
run?: (request: MockRequest) => MockResult | Promise<MockResult>
|
||||
}
|
||||
|
||||
/** 서버 흉내 — 받은 목록을 그대로 저장하고 version 을 올린다 */
|
||||
@@ -103,6 +113,12 @@ function echo(request: MockRequest): MockResult {
|
||||
return ok({ rules: body.rules, version: `${body.version}+`, isActive: true })
|
||||
}
|
||||
|
||||
/** 적용 한 차례의 응답 */
|
||||
function step(scanned: number, matched: number, remaining: number, cursor: string | null): MockResult {
|
||||
const run: MailFilterRun = { scanned, matched, remaining, cursor }
|
||||
return ok(run)
|
||||
}
|
||||
|
||||
function deferred() {
|
||||
let finish: (result: MockResult) => void = () => undefined
|
||||
const promise = new Promise<MockResult>((resolve) => {
|
||||
@@ -141,6 +157,9 @@ describe('MailFilterPage', () => {
|
||||
return typeof filters === 'string' || 'status' in filters ? filters : ok(filters)
|
||||
}
|
||||
if (request.url === '/mail-filters' && request.method === 'PUT') return (server.save ?? echo)(request)
|
||||
if (request.url === '/mail-filter-runs' && request.method === 'POST') {
|
||||
return server.run ? server.run(request) : step(0, 0, 0, null)
|
||||
}
|
||||
return ok(null)
|
||||
})
|
||||
}
|
||||
@@ -150,7 +169,10 @@ describe('MailFilterPage', () => {
|
||||
const router = createTestRouter()
|
||||
await router.push(path)
|
||||
await router.isReady()
|
||||
const wrapper = mount(MailFilterPage, { attachTo: document.body, global: { plugins: [pinia, router] } })
|
||||
const wrapper = mount(MailFilterPage, {
|
||||
attachTo: document.body,
|
||||
global: { plugins: [pinia, router], provide: routeViewProvide(router) },
|
||||
})
|
||||
mounted.push(wrapper)
|
||||
await flushPromises()
|
||||
return { wrapper, router }
|
||||
@@ -223,7 +245,17 @@ describe('MailFilterPage', () => {
|
||||
expect(row(wrapper, 'r1').get('[data-filter-down]').attributes('aria-disabled')).toBeUndefined()
|
||||
expect(row(wrapper, 'r5').get('[data-filter-down]').attributes('aria-disabled')).toBe('true')
|
||||
expect(wrapper.get('[data-filter-count]').text()).toBe('필터 5개 · 50개까지 만들 수 있습니다. 행을 누르면 고칩니다.')
|
||||
expect(wrapper.get('[data-filter-notes]').findAll('p')).toHaveLength(4)
|
||||
expect(wrapper.get('[data-filter-notes]').findAll('p').map((note) => note.text())).toEqual([
|
||||
'·위에서부터 차례로 적용합니다. 메일함으로 옮기는 필터에 맞으면 그 아래 필터는 적용하지 않습니다 — 순서는 ▲ ▼ 로 바꿉니다.',
|
||||
'·이미 받은 메일에도 적용하려면 필터를 저장할 때 「받은편지함에 이미 있는 메일에도 적용」을 고릅니다. 받은편지함의 메일만 보고, 그 필터 하나만 적용합니다.',
|
||||
'·스팸으로 판정되어 들어오는 메일에는 필터를 적용하지 않습니다 — 그대로 스팸메일함으로 갑니다.',
|
||||
'·전달은 사내 주소로만, 새로 오는 메일만 합니다. 메일 한 통은 한 곳으로만 전달됩니다(위의 필터가 먼저). 전달해도 내 메일함에 그대로 남습니다.',
|
||||
'·부재중 자동 응답은 필터와 함께 동작합니다.',
|
||||
])
|
||||
expect(wrapper.get('[data-filter-note-apply] strong').text()).toBe('「받은편지함에 이미 있는 메일에도 적용」')
|
||||
expect(wrapper.get('.settings-page__description').text()).toBe(
|
||||
'받은 메일을 조건에 따라 메일함으로 옮기거나, 읽음 · 중요로 표시하거나, 동료에게 전달합니다. 새로 오는 메일에 적용하고, 고를 때는 받은편지함에 이미 있는 메일에도 적용합니다.',
|
||||
)
|
||||
expect(wrapper.find('[data-filter-empty]').exists()).toBe(false)
|
||||
expect(wrapper.find('[data-filter-conflict]').exists()).toBe(false)
|
||||
expect(wrapper.find('[data-filter-inactive]').exists()).toBe(false)
|
||||
@@ -781,4 +813,475 @@ describe('MailFilterPage', () => {
|
||||
expect(document.activeElement?.id).toBe('mail-filter-move')
|
||||
expect(router.currentRoute.value.fullPath).toBe('/settings/mail/filters')
|
||||
})
|
||||
|
||||
describe('받은편지함에 이미 있는 메일에도 적용 (7단계 — 디자인 원본 MailFilterApply · MailFilterApplyStates)', () => {
|
||||
const applyBox = (wrapper: VueWrapper) => wrapper.get('#mail-filter-apply')
|
||||
const applyInput = (wrapper: VueWrapper) => applyBox(wrapper).element as HTMLInputElement
|
||||
const applyHelp = (wrapper: VueWrapper) => wrapper.get('#mail-filter-apply-help').text()
|
||||
const runs = () =>
|
||||
requests.filter((request) => request.url === '/mail-filter-runs').map((request) => request.data as MailFilterRunRequest)
|
||||
const HELP = '저장한 뒤 받은편지함에서 조건에 맞는 메일을 찾아 바로 적용합니다.'
|
||||
|
||||
it('할 일 아래 — 창을 열 때마다 꺼져 있고 설명은 칸의 aria-describedby, 전달도 하면 설명에 더한다 · 상자 아래 스팸 한 줄', async () => {
|
||||
const { wrapper } = await mountPage()
|
||||
await openRule(wrapper, 'r2')
|
||||
|
||||
expect(applyInput(wrapper).checked).toBe(false)
|
||||
expect(applyBox(wrapper).attributes('disabled')).toBeUndefined()
|
||||
expect(applyBox(wrapper).attributes('aria-describedby')).toBe('mail-filter-apply-help')
|
||||
expect(wrapper.get('[data-filter-apply] label').text()).toBe('받은편지함에 이미 있는 메일에도 적용')
|
||||
expect(applyHelp(wrapper)).toBe(HELP)
|
||||
expect(wrapper.get('[data-filter-apply-spam]').text()).toBe('스팸으로 판정되어 들어오는 메일에는 적용하지 않습니다.')
|
||||
// 6단계의 안내 띠는 없다
|
||||
expect(wrapper.text()).not.toContain('새로 오는 메일에만 적용됩니다')
|
||||
|
||||
await wrapper.get('#mail-filter-forward').setValue(true)
|
||||
expect(applyHelp(wrapper)).toBe(`${HELP} 전달은 새로 오는 메일에만 합니다.`)
|
||||
|
||||
// 고르고 저장하지 않고 닫았다가 다시 열면 꺼져 있다 (필터의 설정이 아니다)
|
||||
await applyBox(wrapper).setValue(true)
|
||||
await click(wrapper.get('#mail-filter-cancel'))
|
||||
await click(wrapper.get('#mail-filter-leave-confirm'))
|
||||
await openRule(wrapper, 'r2')
|
||||
expect(applyInput(wrapper).checked).toBe(false)
|
||||
})
|
||||
|
||||
it('전달만 하는 필터는 칸을 끄고 이름을 흐리게, 설명이 까닭 — 읽음을 고르면 다시 고를 수 있다', async () => {
|
||||
const { wrapper } = await mountPage()
|
||||
await openRule(wrapper, 'r1')
|
||||
|
||||
expect(applyBox(wrapper).attributes('disabled')).toBeDefined()
|
||||
expect(wrapper.get('[data-filter-apply] label').classes()).toContain('is-disabled')
|
||||
expect(applyHelp(wrapper)).toBe('전달만 하는 필터는 이미 받은 메일에 할 일이 없습니다 — 전달은 새로 오는 메일에만 합니다.')
|
||||
|
||||
await wrapper.get('#mail-filter-read').setValue(true)
|
||||
expect(applyBox(wrapper).attributes('disabled')).toBeUndefined()
|
||||
expect(applyHelp(wrapper)).toBe(`${HELP} 전달은 새로 오는 메일에만 합니다.`)
|
||||
})
|
||||
|
||||
it('옮길 메일함이 지워진 필터 · 받은편지함으로 옮기기만 하는 필터도 칸을 끈다 — 읽음 · 중요를 고르면 다시 고를 수 있다', async () => {
|
||||
const { wrapper } = await mountPage()
|
||||
await openRule(wrapper, 'r5')
|
||||
expect(applyBox(wrapper).attributes('disabled')).toBeDefined()
|
||||
expect(applyHelp(wrapper)).toBe('옮길 메일함이 지워져 이미 받은 메일에 할 일이 없습니다 — 다른 메일함을 고르세요.')
|
||||
await click(wrapper.get('#mail-filter-cancel'))
|
||||
|
||||
await openRule(wrapper, 'r2')
|
||||
await wrapper.get('#mail-filter-move-target').setValue('inbox')
|
||||
expect(applyBox(wrapper).attributes('disabled')).toBeDefined()
|
||||
expect(applyHelp(wrapper)).toBe('받은편지함으로 옮기기만 하는 필터는 이미 받은 메일에 할 일이 없습니다.')
|
||||
|
||||
await wrapper.get('#mail-filter-flag').setValue(true)
|
||||
expect(applyBox(wrapper).attributes('disabled')).toBeUndefined()
|
||||
expect(applyHelp(wrapper)).toBe(HELP)
|
||||
})
|
||||
|
||||
it('고른 채 전달만 하는 필터로 바꾸면 꺼진 채로 보이고 — 되돌리면 골라 둔 값이 다시 켜진다', async () => {
|
||||
const { wrapper } = await mountPage()
|
||||
await openRule(wrapper, 'r3')
|
||||
await applyBox(wrapper).setValue(true)
|
||||
await wrapper.get('#mail-filter-forward').setValue(true)
|
||||
expect(applyInput(wrapper).checked).toBe(true)
|
||||
|
||||
await wrapper.get('#mail-filter-read').setValue(false)
|
||||
await wrapper.get('#mail-filter-flag').setValue(false)
|
||||
expect(applyBox(wrapper).attributes('disabled')).toBeDefined()
|
||||
expect(applyInput(wrapper).checked).toBe(false)
|
||||
|
||||
await wrapper.get('#mail-filter-read').setValue(true)
|
||||
expect(applyBox(wrapper).attributes('disabled')).toBeUndefined()
|
||||
expect(applyInput(wrapper).checked).toBe(true)
|
||||
})
|
||||
|
||||
it('저장한 뒤 — 응답 목록의 그 규칙으로(전달 없이) cursor 가 null 이 될 때까지 보내고, "필터를 저장했습니다." 대신 진행 → 끝 알림 [거래처 보기]', async () => {
|
||||
const second = deferred()
|
||||
const { wrapper } = await mountPage({
|
||||
// 서버가 저장한 값(앞뒤 공백을 지운 값 등)이 보낸 값과 달라도 적용은 저장한 그 값으로 한다
|
||||
save: (request) => {
|
||||
const body = request.data as MailFilterSetPayload
|
||||
const rules = body.rules.map((item) =>
|
||||
item.id === 'r2'
|
||||
? { ...item, conditions: [{ field: 'from' as const, operator: 'contains' as const, value: '@hanbit.co.kr' }] }
|
||||
: item,
|
||||
)
|
||||
return ok({ rules, version: 'v2', isActive: true })
|
||||
},
|
||||
run: (request) =>
|
||||
(request.data as MailFilterRunRequest).cursor === null ? step(4500, 30, 3912, 'c1') : second.promise,
|
||||
})
|
||||
await openRule(wrapper, 'r2')
|
||||
await wrapper.get('#mail-filter-forward').setValue(true)
|
||||
await wrapper.get('#mail-filter-forward-to').setValue('doyun.lee@arasystem.it.kr')
|
||||
await applyBox(wrapper).setValue(true)
|
||||
await save(wrapper)
|
||||
|
||||
expect(isOpen(wrapper)).toBe(false)
|
||||
expect(puts()).toHaveLength(1)
|
||||
const rule: Omit<MailFilterRunRequest, 'cursor'> = {
|
||||
match: 'all',
|
||||
conditions: [{ field: 'from', operator: 'contains', value: '@hanbit.co.kr' }],
|
||||
actions: { moveToMailboxId: 'vendor', markAsRead: false, flag: false },
|
||||
}
|
||||
expect(runs()).toEqual([
|
||||
{ ...rule, cursor: null },
|
||||
{ ...rule, cursor: 'c1' },
|
||||
])
|
||||
expect(useToastStore().toasts.map((toast) => [toast.kind, toast.message])).toEqual([
|
||||
['progress', '‘거래처 메일’ 필터를 받은편지함에 적용하는 중 · 4,500 / 8,412통 확인'],
|
||||
])
|
||||
|
||||
second.finish(step(3912, 7, 0, null))
|
||||
await flushPromises()
|
||||
const [done] = useToastStore().toasts
|
||||
expect(useToastStore().toasts).toHaveLength(1)
|
||||
expect(done).toMatchObject({
|
||||
kind: 'success',
|
||||
message: '‘거래처 메일’ 필터를 받은편지함의 메일 37통에 적용했습니다.',
|
||||
action: { label: '거래처 보기', to: { name: 'mail-mailbox', params: { mailboxId: 'vendor' } } },
|
||||
})
|
||||
expect(toasts()).not.toContain('필터를 저장했습니다.')
|
||||
})
|
||||
|
||||
it('적용하는 동안 다른 필터의 창에서는 칸을 끄고 까닭 — 끝나면 다시 고를 수 있다 · 그동안 화면을 떠나도 이어 한다', async () => {
|
||||
const first = deferred()
|
||||
const { wrapper, router } = await mountPage({ run: () => first.promise })
|
||||
await openRule(wrapper, 'r3')
|
||||
await applyBox(wrapper).setValue(true)
|
||||
await save(wrapper)
|
||||
expect(runs()).toHaveLength(1)
|
||||
|
||||
await openRule(wrapper, 'r2')
|
||||
expect(applyBox(wrapper).attributes('disabled')).toBeDefined()
|
||||
expect(applyHelp(wrapper)).toBe('다른 필터를 받은편지함에 적용하는 중입니다. 끝난 뒤에 고를 수 있습니다.')
|
||||
await click(wrapper.get('#mail-filter-cancel'))
|
||||
|
||||
// 다른 화면으로 가도 이어 한다
|
||||
await router.push('/mail/inbox')
|
||||
wrapper.unmount()
|
||||
first.finish(step(8412, 12, 0, null))
|
||||
await flushPromises()
|
||||
expect(toasts()).toEqual(['‘결재 알림’ 필터를 받은편지함의 메일 12통에 적용했습니다.'])
|
||||
expect(useToastStore().toasts[0]?.action).toBeNull()
|
||||
|
||||
const { wrapper: again } = await mountPage()
|
||||
await openRule(again, 'r2')
|
||||
expect(applyBox(again).attributes('disabled')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('「적용」을 고르고 저장하는 중에 창을 닫거나 화면을 떠나도 — 저장이 끝나면 적용한다', async () => {
|
||||
const reply = deferred()
|
||||
const { wrapper } = await mountPage({ save: () => reply.promise, run: () => step(8412, 37, 0, null) })
|
||||
await openRule(wrapper, 'r2')
|
||||
await applyBox(wrapper).setValue(true)
|
||||
await save(wrapper)
|
||||
await click(wrapper.get('#mail-filter-cancel'))
|
||||
expect(isOpen(wrapper)).toBe(false)
|
||||
expect(runs()).toEqual([])
|
||||
|
||||
reply.finish(echo(requests.find((request) => request.method === 'PUT') as MockRequest))
|
||||
await flushPromises()
|
||||
expect(runs().map((request) => request.cursor)).toEqual([null])
|
||||
expect(toasts()).toEqual(['‘거래처 메일’ 필터를 받은편지함의 메일 37통에 적용했습니다.'])
|
||||
useToastStore().clear()
|
||||
|
||||
const leaving = deferred()
|
||||
const { wrapper: other, router } = await mountPage({ save: () => leaving.promise, run: () => step(8412, 37, 0, null) })
|
||||
await openRule(other, 'r2')
|
||||
await applyBox(other).setValue(true)
|
||||
await save(other)
|
||||
// 저장하는 중이라 묻지 않고 떠난다
|
||||
await router.push('/mail/inbox')
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
|
||||
other.unmount()
|
||||
|
||||
leaving.finish(echo(requests.find((request) => request.method === 'PUT') as MockRequest))
|
||||
await flushPromises()
|
||||
expect(runs().map((request) => request.cursor)).toEqual([null])
|
||||
expect(toasts()).toEqual(['‘거래처 메일’ 필터를 받은편지함의 메일 37통에 적용했습니다.'])
|
||||
})
|
||||
|
||||
it('저장하는 중에 세션이 바뀌면 적용 요청을 보내지 않는다 — 알림도 없다', async () => {
|
||||
const reply = deferred()
|
||||
const { wrapper } = await mountPage({ save: () => reply.promise })
|
||||
await openRule(wrapper, 'r2')
|
||||
await applyBox(wrapper).setValue(true)
|
||||
await save(wrapper)
|
||||
|
||||
useAuthStore().clearAuth()
|
||||
await flushPromises()
|
||||
reply.finish(echo(requests.find((request) => request.method === 'PUT') as MockRequest))
|
||||
await flushPromises()
|
||||
|
||||
expect(runs()).toEqual([])
|
||||
expect(toasts()).toEqual([])
|
||||
})
|
||||
|
||||
it('적용이 끝나면 메일함 개수 · 사용량을 다시 받는다 — 실시간 연결이 없는 탭에서도 옛 개수가 남지 않게', async () => {
|
||||
const { wrapper } = await mountPage({ run: () => step(8412, 37, 0, null) })
|
||||
await openRule(wrapper, 'r2')
|
||||
await applyBox(wrapper).setValue(true)
|
||||
await save(wrapper)
|
||||
|
||||
const afterRun = requests.slice(requests.findIndex((request) => request.url === '/mail-filter-runs') + 1)
|
||||
expect(afterRun.map((request) => `${request.method} ${request.url}`).sort()).toEqual(['GET /mail-quota', 'GET /mailboxes'])
|
||||
})
|
||||
|
||||
it('고르지 않았거나 저장하지 못하면 적용하지 않는다', async () => {
|
||||
let reject = true
|
||||
const { wrapper } = await mountPage({ save: (request) => (reject ? fail(400, 'MAIL_013') : echo(request)) })
|
||||
await openRule(wrapper, 'r2')
|
||||
await applyBox(wrapper).setValue(true)
|
||||
await save(wrapper)
|
||||
expect(runs()).toEqual([])
|
||||
expect(isOpen(wrapper)).toBe(true)
|
||||
|
||||
reject = false
|
||||
await applyBox(wrapper).setValue(false)
|
||||
await save(wrapper)
|
||||
expect(runs()).toEqual([])
|
||||
expect(toasts()).toEqual(['필터를 저장했습니다.'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('고치기 창의 나가기 확인 (7단계 — 6단계 한계를 고친다)', () => {
|
||||
const leaveOpen = (wrapper: VueWrapper) => wrapper.get('#mail-filter-leave-dialog').attributes('open') !== undefined
|
||||
|
||||
/** 창 닫기 · 새로고침 때 브라우저 확인창을 띄우려 하는가 */
|
||||
function blocksUnload(): boolean {
|
||||
const event = new Event('beforeunload', { cancelable: true })
|
||||
window.dispatchEvent(event)
|
||||
return event.defaultPrevented
|
||||
}
|
||||
|
||||
it('바꾼 것이 없으면 [취소] · Esc 는 묻지 않고 닫는다 (바꿨다 되돌린 것도)', async () => {
|
||||
const { wrapper } = await mountPage()
|
||||
|
||||
await openRule(wrapper, 'r2')
|
||||
await click(wrapper.get('#mail-filter-cancel'))
|
||||
expect(isOpen(wrapper)).toBe(false)
|
||||
expect(leaveOpen(wrapper)).toBe(false)
|
||||
|
||||
await openRule(wrapper, 'r2')
|
||||
await wrapper.get('#mail-filter-name').setValue('다른 이름')
|
||||
await wrapper.get('#mail-filter-name').setValue('거래처 메일')
|
||||
expect(blocksUnload()).toBe(false)
|
||||
dialog(wrapper).element.dispatchEvent(new Event('cancel', { cancelable: true }))
|
||||
await flushPromises()
|
||||
expect(isOpen(wrapper)).toBe(false)
|
||||
expect(leaveOpen(wrapper)).toBe(false)
|
||||
expect(document.activeElement).toBe(row(wrapper, 'r2').get('[data-filter-edit]').element)
|
||||
})
|
||||
|
||||
it('바꾸고 [취소]하면 고치기 창 위에 확인 창 — [계속 고치기](처음 초점)면 창 · 쓴 것 · 초점이 그대로, [닫기]면 닫는다', async () => {
|
||||
const { wrapper } = await mountPage()
|
||||
await openRule(wrapper, 'r2')
|
||||
await wrapper.get('#mail-filter-name').setValue('한빛 거래처')
|
||||
expect(blocksUnload()).toBe(true)
|
||||
|
||||
await click(wrapper.get('#mail-filter-cancel'))
|
||||
expect(leaveOpen(wrapper)).toBe(true)
|
||||
expect(isOpen(wrapper)).toBe(true)
|
||||
expect(wrapper.get('#mail-filter-leave-dialog').attributes('role')).toBe('alertdialog')
|
||||
expect(wrapper.get('#mail-filter-leave-title').text()).toBe('저장하지 않은 필터가 있습니다')
|
||||
expect(wrapper.get('#mail-filter-leave-message').text()).toBe('창을 닫으면 고친 내용이 사라집니다.')
|
||||
expect(wrapper.get('#mail-filter-leave-stay').text()).toBe('계속 고치기')
|
||||
expect(wrapper.get('#mail-filter-leave-confirm').text()).toBe('닫기')
|
||||
expect(document.activeElement?.id).toBe('mail-filter-leave-stay')
|
||||
|
||||
await click(wrapper.get('#mail-filter-leave-stay'))
|
||||
expect(leaveOpen(wrapper)).toBe(false)
|
||||
expect(isOpen(wrapper)).toBe(true)
|
||||
expect((wrapper.get('#mail-filter-name').element as HTMLInputElement).value).toBe('한빛 거래처')
|
||||
expect(document.activeElement?.id).toBe('mail-filter-cancel')
|
||||
|
||||
await click(wrapper.get('#mail-filter-cancel'))
|
||||
await click(wrapper.get('#mail-filter-leave-confirm'))
|
||||
expect(leaveOpen(wrapper)).toBe(false)
|
||||
expect(isOpen(wrapper)).toBe(false)
|
||||
expect(puts()).toEqual([])
|
||||
expect(blocksUnload()).toBe(false)
|
||||
expect(document.activeElement).toBe(row(wrapper, 'r2').get('[data-filter-edit]').element)
|
||||
})
|
||||
|
||||
it('Esc 도 같다 — 확인 창의 Esc 는 [계속 고치기] · 「…에도 적용」만 골라도 바꾼 것이다', async () => {
|
||||
const { wrapper } = await mountPage()
|
||||
await openRule(wrapper, 'r2')
|
||||
await wrapper.get('#mail-filter-apply').setValue(true)
|
||||
|
||||
const escape = new Event('cancel', { cancelable: true })
|
||||
dialog(wrapper).element.dispatchEvent(escape)
|
||||
await flushPromises()
|
||||
expect(escape.defaultPrevented).toBe(true)
|
||||
expect(leaveOpen(wrapper)).toBe(true)
|
||||
|
||||
wrapper.get('#mail-filter-leave-dialog').element.dispatchEvent(new Event('cancel', { cancelable: true }))
|
||||
await flushPromises()
|
||||
expect(leaveOpen(wrapper)).toBe(false)
|
||||
expect(isOpen(wrapper)).toBe(true)
|
||||
expect((wrapper.get('#mail-filter-apply').element as HTMLInputElement).checked).toBe(true)
|
||||
})
|
||||
|
||||
it('읽기 화면에서 채워 연 새 필터는 채운 그대로면 묻지 않는다 — 고치면 묻는다', async () => {
|
||||
const { wrapper } = await mountPage({}, '/settings/mail/filters?from=seojun.park%40arasystem.it.kr')
|
||||
await wrapper.get('#mail-filter-read').setValue(true)
|
||||
await click(wrapper.get('#mail-filter-cancel'))
|
||||
expect(leaveOpen(wrapper)).toBe(true)
|
||||
await click(wrapper.get('#mail-filter-leave-stay'))
|
||||
|
||||
await wrapper.get('#mail-filter-read').setValue(false)
|
||||
await click(wrapper.get('#mail-filter-cancel'))
|
||||
expect(leaveOpen(wrapper)).toBe(false)
|
||||
expect(isOpen(wrapper)).toBe(false)
|
||||
})
|
||||
|
||||
it('창을 연 채 다른 화면으로 가면 "이 화면을 나가면 …" [나가기] — [계속 고치기]면 머물고, 바꾼 것이 없으면 묻지 않는다', async () => {
|
||||
const { wrapper, router } = await mountPage()
|
||||
await openRule(wrapper, 'r2')
|
||||
await wrapper.get('#mail-filter-name').setValue('한빛 거래처')
|
||||
|
||||
const stay = router.push('/mail/inbox')
|
||||
await flushPromises()
|
||||
expect(leaveOpen(wrapper)).toBe(true)
|
||||
expect(wrapper.get('#mail-filter-leave-message').text()).toBe('이 화면을 나가면 고친 내용이 사라집니다.')
|
||||
expect(wrapper.get('#mail-filter-leave-confirm').text()).toBe('나가기')
|
||||
await click(wrapper.get('#mail-filter-leave-stay'))
|
||||
await stay
|
||||
expect(router.currentRoute.value.fullPath).toBe('/settings/mail/filters')
|
||||
expect(isOpen(wrapper)).toBe(true)
|
||||
|
||||
const leave = router.push('/mail/inbox')
|
||||
await flushPromises()
|
||||
await click(wrapper.get('#mail-filter-leave-confirm'))
|
||||
await leave
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
|
||||
|
||||
const { wrapper: clean, router: other } = await mountPage()
|
||||
await openRule(clean, 'r2')
|
||||
await other.push('/mail/inbox')
|
||||
await flushPromises()
|
||||
expect(leaveOpen(clean)).toBe(false)
|
||||
expect(other.currentRoute.value.fullPath).toBe('/mail/inbox')
|
||||
})
|
||||
|
||||
it('이동 확인 창의 Esc 는 [계속 고치기] — 그 이동을 취소하고 머문다', async () => {
|
||||
const { wrapper, router } = await mountPage()
|
||||
await openRule(wrapper, 'r2')
|
||||
await wrapper.get('#mail-filter-name').setValue('한빛 거래처')
|
||||
|
||||
const moving = router.push('/mail/inbox')
|
||||
await flushPromises()
|
||||
expect(wrapper.get('#mail-filter-leave-confirm').text()).toBe('나가기')
|
||||
const escape = new Event('cancel', { cancelable: true })
|
||||
wrapper.get('#mail-filter-leave-dialog').element.dispatchEvent(escape)
|
||||
await moving
|
||||
await flushPromises()
|
||||
|
||||
expect(escape.defaultPrevented).toBe(true)
|
||||
expect(router.currentRoute.value.fullPath).toBe('/settings/mail/filters')
|
||||
expect(leaveOpen(wrapper)).toBe(false)
|
||||
expect(isOpen(wrapper)).toBe(true)
|
||||
})
|
||||
|
||||
it('닫기 확인이 떠 있는 동안 다른 화면으로 가려 하면 같은 창이 이동 확인으로 바뀐다', async () => {
|
||||
const { wrapper, router } = await mountPage()
|
||||
await openRule(wrapper, 'r2')
|
||||
await wrapper.get('#mail-filter-name').setValue('한빛 거래처')
|
||||
await click(wrapper.get('#mail-filter-cancel'))
|
||||
expect(wrapper.get('#mail-filter-leave-confirm').text()).toBe('닫기')
|
||||
|
||||
const back = router.push('/mail/inbox')
|
||||
await flushPromises()
|
||||
expect(leaveOpen(wrapper)).toBe(true)
|
||||
expect(wrapper.get('#mail-filter-leave-confirm').text()).toBe('나가기')
|
||||
await click(wrapper.get('#mail-filter-leave-stay'))
|
||||
await back
|
||||
expect(router.currentRoute.value.fullPath).toBe('/settings/mail/filters')
|
||||
expect(leaveOpen(wrapper)).toBe(false)
|
||||
expect(isOpen(wrapper)).toBe(true)
|
||||
})
|
||||
|
||||
it('저장하는 중에는 묻지 않는다 — [취소]로 바로 닫고 결과는 알림으로', async () => {
|
||||
const reply = deferred()
|
||||
const { wrapper } = await mountPage({ save: () => reply.promise })
|
||||
await openRule(wrapper, 'r2')
|
||||
await wrapper.get('#mail-filter-name').setValue('한빛 거래처')
|
||||
await save(wrapper)
|
||||
|
||||
await click(wrapper.get('#mail-filter-cancel'))
|
||||
expect(leaveOpen(wrapper)).toBe(false)
|
||||
expect(isOpen(wrapper)).toBe(false)
|
||||
|
||||
reply.finish(echo(requests[requests.length - 1] as MockRequest))
|
||||
await flushPromises()
|
||||
expect(toasts()).toEqual(['필터를 저장했습니다.'])
|
||||
})
|
||||
|
||||
it('저장하는 중에도 탭 닫기 · 새로고침은 묻는다(beforeunload) — 저장이 끝나 창이 닫히면 그만 묻는다', async () => {
|
||||
const reply = deferred()
|
||||
const { wrapper } = await mountPage({ save: () => reply.promise })
|
||||
await openRule(wrapper, 'r2')
|
||||
await wrapper.get('#mail-filter-name').setValue('한빛 거래처')
|
||||
await save(wrapper)
|
||||
expect(blocksUnload()).toBe(true)
|
||||
|
||||
reply.finish(echo(requests[requests.length - 1] as MockRequest))
|
||||
await flushPromises()
|
||||
expect(isOpen(wrapper)).toBe(false)
|
||||
expect(blocksUnload()).toBe(false)
|
||||
})
|
||||
|
||||
/** 브라우저가 창을 스스로 닫는 것을 흉내 낸다 (Esc 를 거듭 누름) — 초점은 close 이벤트 전에 창 밖으로 나간다 */
|
||||
async function browserCloses(wrapper: VueWrapper) {
|
||||
const element = dialog(wrapper).element
|
||||
element.removeAttribute('open')
|
||||
;(document.activeElement as HTMLElement | null)?.blur()
|
||||
element.dispatchEvent(new Event('close'))
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
it('브라우저가 창을 스스로 닫았는데(Esc 를 거듭 누름) 바꾼 것이 있으면 묻고 — [계속 고치기]면 창을 다시 띄우고 초점을 제자리로', async () => {
|
||||
const { wrapper } = await mountPage()
|
||||
await openRule(wrapper, 'r2')
|
||||
await wrapper.get('#mail-filter-name').setValue('한빛 거래처')
|
||||
;(wrapper.get('#mail-filter-read').element as HTMLElement).focus()
|
||||
|
||||
await browserCloses(wrapper)
|
||||
expect(leaveOpen(wrapper)).toBe(true)
|
||||
|
||||
await click(wrapper.get('#mail-filter-leave-stay'))
|
||||
expect(isOpen(wrapper)).toBe(true)
|
||||
expect((wrapper.get('#mail-filter-name').element as HTMLInputElement).value).toBe('한빛 거래처')
|
||||
expect(document.activeElement?.id).toBe('mail-filter-read')
|
||||
})
|
||||
|
||||
it('스스로 닫힌 창에서 [닫기]를 고르면 닫는다 — 고친 것은 버리고, 다른 필터의 창을 다시 열 수 있다', async () => {
|
||||
const { wrapper } = await mountPage()
|
||||
await openRule(wrapper, 'r2')
|
||||
await wrapper.get('#mail-filter-name').setValue('한빛 거래처')
|
||||
await browserCloses(wrapper)
|
||||
|
||||
await click(wrapper.get('#mail-filter-leave-confirm'))
|
||||
expect(leaveOpen(wrapper)).toBe(false)
|
||||
expect(isOpen(wrapper)).toBe(false)
|
||||
expect(puts()).toEqual([])
|
||||
|
||||
await openRule(wrapper, 'r3')
|
||||
expect(isOpen(wrapper)).toBe(true)
|
||||
expect((wrapper.get('#mail-filter-name').element as HTMLInputElement).value).toBe('결재 알림')
|
||||
})
|
||||
|
||||
it('묻는 중에 세션이 끝나면 확인 창을 닫는다 (세션 만료 대화상자와 겹치지 않는다)', async () => {
|
||||
const { wrapper } = await mountPage()
|
||||
await openRule(wrapper, 'r2')
|
||||
await wrapper.get('#mail-filter-name').setValue('한빛 거래처')
|
||||
await click(wrapper.get('#mail-filter-cancel'))
|
||||
expect(leaveOpen(wrapper)).toBe(true)
|
||||
|
||||
useAuthStore().expireSession()
|
||||
await flushPromises()
|
||||
// 이전 사용자의 필터 화면은 내려간다 — 확인 창이 남지 않는다
|
||||
expect(document.querySelector('#mail-filter-leave-dialog[open]')).toBeNull()
|
||||
expect(blocksUnload()).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
// - 읽기 화면 ⋯ 「이런 메일로 필터 만들기」는 ?from=<보낸 사람 주소> 로 온다 — 받은 뒤 새 필터 창을 그 주소로 채워 열고(50개면 열지 않는다)
|
||||
// 주소에서 지운다(replace — 새로 고침에 또 열리지 않게)
|
||||
// - 누른 단추가 사라져 초점을 잃으면(지운 행 · 사라진 줄 · 빈 상태의 [새 필터]) 가까운 행이나 머리의 [새 필터]로 옮긴다
|
||||
// - 창의 「받은편지함에 이미 있는 메일에도 적용」(7단계)을 고른 채 저장하면 저장한 뒤 받은편지함에 적용한다(useMailFilterActions → useMailFilterRun).
|
||||
// 이 탭에서 적용하는 중이면 창의 그 칸을 끈다(mail-filter-run.store 의 running — 화면을 떠나도 적용은 이어진다)
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
@@ -30,10 +32,12 @@ import { MAIL_FILTER_FROM_QUERY, MAIL_FILTER_TEXT } from '@/shared/constants/mai
|
||||
import type { MailFilterRule } from '@/shared/types/mail'
|
||||
import { formatCount } from '@/shared/utils/format'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useMailFilterRunStore } from '@/stores/mail-filter-run.store'
|
||||
import { useMailFilterStore } from '@/stores/mail-filter.store'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
|
||||
const filterStore = useMailFilterStore()
|
||||
const filterRunStore = useMailFilterRunStore()
|
||||
const mailStore = useMailStore()
|
||||
const authStore = useAuthStore()
|
||||
const route = useRoute()
|
||||
@@ -278,7 +282,8 @@ watch(
|
||||
:busy="filterStore.saving"
|
||||
:error="dialogError"
|
||||
:conflict="dialogConflict"
|
||||
@save="(rule) => settleDialog(saveDialog(rule))"
|
||||
:apply-busy="filterRunStore.running"
|
||||
@save="(rule, apply) => settleDialog(saveDialog(rule, { apply }))"
|
||||
@remove="settleDialog(removeDialog())"
|
||||
@cancel="cancelDialog"
|
||||
@clear-error="clearDialogError"
|
||||
|
||||
@@ -3,7 +3,7 @@ import { mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia, type Pinia } from 'pinia'
|
||||
|
||||
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
|
||||
import type { MailVacation } from '@/shared/types/mail'
|
||||
import type { MailVacation, MailVacationState } from '@/shared/types/mail'
|
||||
import { formatDate } from '@/shared/utils/format'
|
||||
import { endOfDayIso, startOfDayIso } from '@/shared/utils/mail-vacation'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
@@ -35,7 +35,7 @@ const SAVED: MailVacation = {
|
||||
}
|
||||
|
||||
interface Server {
|
||||
vacation?: MailVacation | MockResult
|
||||
vacation?: MailVacation | MailVacationState | MockResult
|
||||
save?: (request: MockRequest) => MockResult | Promise<MockResult>
|
||||
}
|
||||
|
||||
@@ -277,6 +277,56 @@ describe('MailVacationPage', () => {
|
||||
])
|
||||
})
|
||||
|
||||
describe('엔진에서 규칙이 꺼졌을 때 (7단계 — 디자인 원본 MailFilterApplyStates)', () => {
|
||||
const INACTIVE_TEXT =
|
||||
'켜 둔 자동 응답이 지금은 동작하지 않습니다. 메일 서버의 규칙이 다른 곳에서 바뀌었습니다 — 아래 [저장]을 누르면 다시 켜집니다.'
|
||||
|
||||
it('켜 두었는데 isActive: false 면 머리 아래 노란 줄(단추 없음) — 저장하면 isActive 없이 보내고 응답의 값으로 내린다', async () => {
|
||||
const { wrapper } = await mountPage({
|
||||
vacation: { ...SAVED, isActive: false },
|
||||
save: (request) => ok({ ...(request.data as MailVacation), isActive: true }),
|
||||
})
|
||||
const notice = wrapper.get('[data-vacation-inactive]')
|
||||
|
||||
expect(notice.text()).toBe(INACTIVE_TEXT)
|
||||
expect(notice.attributes('role')).toBe('status')
|
||||
expect(notice.find('button').exists()).toBe(false)
|
||||
// 머리 아래 — 칸보다 앞
|
||||
expect(notice.element.compareDocumentPosition(wrapper.get('#vacation-enabled').element)).toBe(
|
||||
Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
)
|
||||
|
||||
// 칸을 끄기만 하면(저장하지 않음) 그대로 — 저장한 값으로 본다
|
||||
await wrapper.get('#vacation-enabled').setValue(false)
|
||||
expect(wrapper.find('[data-vacation-inactive]').exists()).toBe(true)
|
||||
await wrapper.get('#vacation-enabled').setValue(true)
|
||||
|
||||
await save(wrapper)
|
||||
expect(puts()).toEqual([SAVED])
|
||||
expect(wrapper.find('[data-vacation-inactive]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('꺼 두었으면 isActive: false 여도 줄이 없다 · 동작하고 있으면(true) 없다', async () => {
|
||||
const { wrapper: off } = await mountPage({ vacation: { ...SAVED, isEnabled: false, isActive: false } })
|
||||
expect(off.find('[data-vacation-inactive]').exists()).toBe(false)
|
||||
|
||||
const { wrapper: on } = await mountPage({ vacation: { ...SAVED, isActive: true } })
|
||||
expect(on.find('[data-vacation-inactive]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('꺼 두고 저장해도 줄을 내린다 — 서버가 아직 isActive: false 를 돌려줘도 (꺼진 자동 응답은 알릴 것이 없다)', async () => {
|
||||
const { wrapper } = await mountPage({
|
||||
vacation: { ...SAVED, isActive: false },
|
||||
save: (request) => ok({ ...(request.data as MailVacation), isActive: false }),
|
||||
})
|
||||
await wrapper.get('#vacation-enabled').setValue(false)
|
||||
await save(wrapper)
|
||||
|
||||
expect(puts()).toEqual([{ ...SAVED, isEnabled: false }])
|
||||
expect(wrapper.find('[data-vacation-inactive]').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('나가기 확인 (서명 설정과 같다)', () => {
|
||||
const dialogOpen = () => document.getElementById('vacation-leave-dialog')?.hasAttribute('open') ?? false
|
||||
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
// 설정 › 메일 › 부재중 자동 응답 (디자인 원본 SettingsVacation · README "4단계 › 부재중 자동 응답") — /settings/mail/vacation
|
||||
// 들어올 때마다 새로 받는다(다른 창 · 메일 프로그램에서 바꿨을 수 있다). 받는 동안은 자리만, 못 받으면 사전 문구와 "다시 시도".
|
||||
// 저장한 값이 켜짐인데 엔진에서 우리 규칙이 꺼져 있으면(isActive: false — 7단계, 디자인 원본 MailFilterApplyStates) 머리 아래 노란 줄
|
||||
// "켜 둔 자동 응답이 지금은 동작하지 않습니다 …"(필터 화면의 줄과 같은 모양, 단추 없음 — [저장]은 늘 누를 수 있다).
|
||||
// 저장하면 응답의 isActive 로 맞춘다(저장하면 다시 켜진다). 꺼 두었으면 보이지 않는다 — 칸을 고친 값이 아니라 저장한 값으로 본다
|
||||
import MailLoadError from '@/components/mail/MailLoadError.vue'
|
||||
import MailFilterNotice from '@/components/settings/MailFilterNotice.vue'
|
||||
import SettingsPageFrame from '@/components/settings/SettingsPageFrame.vue'
|
||||
import VacationForm from '@/components/settings/VacationForm.vue'
|
||||
import { VACATION_TEXT } from '@/shared/constants/mail-vacation'
|
||||
@@ -29,10 +33,15 @@ reload()
|
||||
leave-label=""
|
||||
@retry="reload"
|
||||
/>
|
||||
<VacationForm
|
||||
v-else-if="vacationStore.status === 'ready' && vacationStore.vacation"
|
||||
:vacation="vacationStore.vacation"
|
||||
/>
|
||||
<template v-else-if="vacationStore.status === 'ready' && vacationStore.vacation">
|
||||
<MailFilterNotice
|
||||
v-if="vacationStore.vacation.isEnabled && !vacationStore.vacation.isActive"
|
||||
icon="alert-circle"
|
||||
:message="VACATION_TEXT.inactive"
|
||||
data-vacation-inactive
|
||||
/>
|
||||
<VacationForm :vacation="vacationStore.vacation" />
|
||||
</template>
|
||||
<div
|
||||
v-else
|
||||
class="vacation-page__placeholder"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// 설정 › 메일 › 필터 상수 — 계약 2절 "메일 필터" · 디자인 README "6단계 — 메일 필터" (디자인 원본 SettingsFilter · MailFilterStates)
|
||||
// 설정 › 메일 › 필터 상수 — 계약 2절 "메일 필터" · "이미 받은 메일에 적용" · 디자인 README "6단계 — 메일 필터" · "7단계 — 이미 받은 메일에 필터 적용"
|
||||
// (디자인 원본 SettingsFilter · MailFilterStates · MailFilterApply · MailFilterApplyStates)
|
||||
// 한도는 서버와 같은 값이다 — 서버가 최종 검증한다(넘으면 400 VAL_001). 길이는 JS 문자열 길이(UTF-16 단위)로 센다.
|
||||
import type { MailFilterField, MailFilterMatch, MailFilterOperator } from '@/shared/types/mail'
|
||||
|
||||
@@ -26,6 +27,11 @@ export const MAIL_FILTER_LABEL_MAX_LENGTH = MAIL_FILTER_NAME_MAX_LENGTH
|
||||
export const MAIL_FILTER_FORBIDDEN_SEQUENCE = '${'
|
||||
/** 읽기 화면 ⋯ 「이런 메일로 필터 만들기」가 보낸 사람 주소를 싣는 쿼리 — /settings/mail/filters?from=<주소> */
|
||||
export const MAIL_FILTER_FROM_QUERY = 'from'
|
||||
/**
|
||||
* 이미 받은 메일에 적용할 때 서버가 한 번의 요청에서 하나도 확인하지 못한 채 cursor 만 돌려준 횟수 — 이만큼 이어지면 멈추고 실패로 알린다.
|
||||
* 서버는 하나도 확인하지 못하면 503 을 주므로 정상이라면 일어나지 않는다 (같은 요청을 끝없이 다시 보내지 않게 하는 안전장치 — 4단계 처리와 같은 값)
|
||||
*/
|
||||
export const MAIL_FILTER_RUN_MAX_STALLS = 3
|
||||
|
||||
/** 조건의 "어디에서" — 고치기 창의 고르기와 목록 요약이 함께 쓴다 */
|
||||
export const MAIL_FILTER_FIELD_LABELS: Readonly<Record<MailFilterField, string>> = {
|
||||
@@ -78,7 +84,7 @@ export const MAIL_FILTER_SUMMARY_TEXT = {
|
||||
export const MAIL_FILTER_TEXT = {
|
||||
title: '필터',
|
||||
description:
|
||||
'받은 메일을 조건에 따라 메일함으로 옮기거나, 읽음 · 중요로 표시하거나, 동료에게 전달합니다. 새로 오는 메일에만 적용됩니다.',
|
||||
'받은 메일을 조건에 따라 메일함으로 옮기거나, 읽음 · 중요로 표시하거나, 동료에게 전달합니다. 새로 오는 메일에 적용하고, 고를 때는 받은편지함에 이미 있는 메일에도 적용합니다.',
|
||||
create: '새 필터',
|
||||
/**
|
||||
* 다 만들었을 때 [새 필터] 옆 · 읽기 화면 ⋯ 「이런 메일로 필터 만들기」 아래 ·
|
||||
@@ -101,12 +107,15 @@ export const MAIL_FILTER_TEXT = {
|
||||
reload: '다시 불러오기',
|
||||
inactive: '지금은 필터와 부재중 자동 응답이 동작하지 않습니다. 메일 서버의 규칙이 다른 곳에서 바뀌었습니다.',
|
||||
reactivate: '다시 켜기',
|
||||
// 아래 안내 상자 — 첫 줄의 "위에서부터 차례로"는 굵게
|
||||
// 아래 안내 상자 — 첫 줄의 "위에서부터 차례로" · 둘째 줄의 칸 이름은 굵게 (7단계 — 디자인 원본 MailFilterApply 의 글)
|
||||
noteOrderStrong: '위에서부터 차례로',
|
||||
noteOrderAfter: ' 적용합니다. 메일함으로 옮기는 필터에 맞으면 그 아래 필터는 적용하지 않습니다 — 순서는 ▲ ▼ 로 바꿉니다.',
|
||||
noteSpam: '스팸으로 판정된 메일에는 필터를 적용하지 않습니다 — 그대로 스팸메일함으로 갑니다.',
|
||||
noteApplyBefore: '이미 받은 메일에도 적용하려면 필터를 저장할 때 ',
|
||||
noteApplyStrong: '「받은편지함에 이미 있는 메일에도 적용」',
|
||||
noteApplyAfter: '을 고릅니다. 받은편지함의 메일만 보고, 그 필터 하나만 적용합니다.',
|
||||
noteSpam: '스팸으로 판정되어 들어오는 메일에는 필터를 적용하지 않습니다 — 그대로 스팸메일함으로 갑니다.',
|
||||
noteForward:
|
||||
'전달은 사내 주소로만 할 수 있고, 메일 한 통은 한 곳으로만 전달됩니다(위의 필터가 먼저). 전달해도 내 메일함에 그대로 남습니다.',
|
||||
'전달은 사내 주소로만, 새로 오는 메일만 합니다. 메일 한 통은 한 곳으로만 전달됩니다(위의 필터가 먼저). 전달해도 내 메일함에 그대로 남습니다.',
|
||||
noteVacation: '부재중 자동 응답은 필터와 함께 동작합니다.',
|
||||
// 알림 — 켜기 · 끄기 · 순서 바꾸기는 목록이 바로 바뀌므로 알리지 않는다 (실패하면 되돌리고 오류 알림)
|
||||
saved: '필터를 저장했습니다.',
|
||||
@@ -143,7 +152,6 @@ export const MAIL_FILTER_DIALOG_TEXT = {
|
||||
/** domain 은 로그인한 직원 주소의 도메인 */
|
||||
forwardPlaceholder: (domain: string) => `이름@${domain}`,
|
||||
forwardHint: '사내 주소로만 전달할 수 있습니다. 전달해도 내 메일함에 그대로 남습니다.',
|
||||
notice: '새로 오는 메일에만 적용됩니다 — 이미 받은 메일은 그대로입니다. 스팸으로 판정된 메일에는 적용하지 않습니다.',
|
||||
remove: '이 필터 지우기',
|
||||
save: '저장',
|
||||
cancel: '취소',
|
||||
@@ -157,3 +165,72 @@ export const MAIL_FILTER_DIALOG_TEXT = {
|
||||
forwardSelf: '내 주소로는 전달할 수 없습니다.',
|
||||
actionsEmpty: '할 일을 하나 이상 골라 주세요.',
|
||||
} as const
|
||||
|
||||
/**
|
||||
* 고치기 창의 「받은편지함에 이미 있는 메일에도 적용」 (7단계 — 디자인 원본 MailFilterApply · MailFilterApplyStates).
|
||||
* 6단계의 안내 띠("새로 오는 메일에만 적용됩니다 …") 자리에 둔다. 고를 수 없으면 설명이 그 까닭이다 —
|
||||
* 이 탭에서 다른 필터를 적용하는 중 → 옮길 메일함이 지워짐 → 받은편지함으로 옮기기만 → 전달만 (mail-filter-run.ts 의 filterApplyBlock 차례)
|
||||
*/
|
||||
export const MAIL_FILTER_APPLY_TEXT = {
|
||||
label: '받은편지함에 이미 있는 메일에도 적용',
|
||||
help: '저장한 뒤 받은편지함에서 조건에 맞는 메일을 찾아 바로 적용합니다.',
|
||||
/** 전달도 하는 필터 — 설명 뒤에 더한다 */
|
||||
helpForward: ' 전달은 새로 오는 메일에만 합니다.',
|
||||
forwardOnly: '전달만 하는 필터는 이미 받은 메일에 할 일이 없습니다 — 전달은 새로 오는 메일에만 합니다.',
|
||||
/** 옮길 메일함이 지금 메일함 목록에 없다 — 읽음 · 중요 없이 옮기기만 (7단계 리뷰로 더한 경우) */
|
||||
missingMailbox: '옮길 메일함이 지워져 이미 받은 메일에 할 일이 없습니다 — 다른 메일함을 고르세요.',
|
||||
/** 받은편지함으로 옮기기만 — 이미 받은편지함에 있는 메일이다 (7단계 리뷰로 더한 경우) */
|
||||
inboxOnly: '받은편지함으로 옮기기만 하는 필터는 이미 받은 메일에 할 일이 없습니다.',
|
||||
busy: '다른 필터를 받은편지함에 적용하는 중입니다. 끝난 뒤에 고를 수 있습니다.',
|
||||
/** 상자 아래 한 줄 */
|
||||
spam: '스팸으로 판정되어 들어오는 메일에는 적용하지 않습니다.',
|
||||
} as const
|
||||
|
||||
/**
|
||||
* 저장한 뒤 받은편지함에 적용하는 동안 · 끝난 뒤의 알림 (7단계 — 디자인 원본 MailFilterApply · MailFilterApplyStates).
|
||||
* quoted 는 따옴표로 묶은 이름(quotedRuleTitle), 수는 formatCount 로 만든 글자. 확인 = 지금까지 확인한 수, 전체 = 확인 + 남은 수(어림)
|
||||
*/
|
||||
export const MAIL_FILTER_RUN_TEXT = {
|
||||
progress: (quoted: string) => `${quoted} 필터를 받은편지함에 적용하는 중`,
|
||||
/** 진행 문구 뒤 — 전체를 아직 모르면(받은편지함 수를 모르고 첫 응답 전) 뺀다 */
|
||||
progressCount: (scanned: string, total: string) => ` · ${scanned} / ${total}통 확인`,
|
||||
done: (quoted: string, matched: string) => `${quoted} 필터를 받은편지함의 메일 ${matched}통에 적용했습니다.`,
|
||||
/** 맞는 메일이 없었다 */
|
||||
none: (quoted: string) => `받은편지함에 ${quoted} 필터에 맞는 메일이 없습니다.`,
|
||||
/** 옮기는 필터의 끝 알림 단추 — 그 메일함으로 */
|
||||
viewMailbox: (mailbox: string) => `${mailbox} 보기`,
|
||||
/** 도중에 멈췄다 — [다시 시도]는 남은 메일부터 이어서 */
|
||||
stopped: (quoted: string, total: string, scanned: string, matched: string) =>
|
||||
`${quoted} 필터 — ${total}통 가운데 ${scanned}통까지 확인하고 멈췄습니다(${matched}통에 적용). 다시 시도해 주세요.`,
|
||||
/** 처음 요청부터 실패했다 — 필터는 저장되어 있다(새로 오는 메일에는 적용된다) */
|
||||
notApplied: '필터는 저장했지만 받은편지함에 적용하지 못했습니다. 다시 시도해 주세요.',
|
||||
/** 429 REQ_001 — 같은 직원의 적용이 다른 창 · PC 에서 진행 중 */
|
||||
otherWindow: '다른 창에서 필터를 받은편지함에 적용하는 중입니다. 끝난 뒤 다시 시도해 주세요.',
|
||||
/**
|
||||
* 이 탭에서 다른 필터를 적용하는 중에 [다시 시도]를 눌렀다 (보드에 없던 경우 — 2026-10-29 구현하며 정함).
|
||||
* 같은 [다시 시도](같은 자리 · 같은 수)를 붙여 앞 적용이 끝난 뒤 다시 누를 수 있게 한다
|
||||
*/
|
||||
busy: '다른 필터를 받은편지함에 적용하는 중입니다. 끝난 뒤 다시 시도해 주세요.',
|
||||
retry: '다시 시도',
|
||||
} as const
|
||||
|
||||
/**
|
||||
* 고치기 창을 저장하지 않고 닫거나 떠날 때 (7단계 — 디자인 원본 MailFilterApply · MailFilterApplyStates "저장하지 않고 닫을 때").
|
||||
* 서명 · 부재중 설정의 나가기 확인과 같은 모양 — [계속 고치기](처음 초점 · Esc) [닫기 · 나가기]
|
||||
*/
|
||||
export const MAIL_FILTER_LEAVE_DIALOG = {
|
||||
/** [취소] · Esc */
|
||||
close: {
|
||||
title: '저장하지 않은 필터가 있습니다',
|
||||
message: '창을 닫으면 고친 내용이 사라집니다.',
|
||||
stay: '계속 고치기',
|
||||
leave: '닫기',
|
||||
},
|
||||
/** 창을 연 채 다른 화면으로 가기 */
|
||||
route: {
|
||||
title: '저장하지 않은 필터가 있습니다',
|
||||
message: '이 화면을 나가면 고친 내용이 사라집니다.',
|
||||
stay: '계속 고치기',
|
||||
leave: '나가기',
|
||||
},
|
||||
} as const
|
||||
|
||||
@@ -48,6 +48,11 @@ export const VACATION_TEXT = {
|
||||
/** 아직 시작하지 않았다 */
|
||||
noticeRange: (from: string, to: string) => `${from} ~ ${to} · 설정`,
|
||||
noticeFrom: (from: string) => `${from}부터 · 설정`,
|
||||
/**
|
||||
* 머리 아래 노란 줄 (7단계 — 디자인 원본 MailFilterApplyStates) — 저장한 값이 켜짐인데 엔진에서 우리 규칙이 꺼져 있을 때(isActive: false).
|
||||
* 단추는 없다 — 저장 단추는 늘 누를 수 있다. 저장하면 필터도 함께 다시 켜진다
|
||||
*/
|
||||
inactive: '켜 둔 자동 응답이 지금은 동작하지 않습니다. 메일 서버의 규칙이 다른 곳에서 바뀌었습니다 — 아래 [저장]을 누르면 다시 켜집니다.',
|
||||
} as const
|
||||
|
||||
/** 바꾸고 저장하지 않은 채 떠날 때 (앱 안 이동은 대화상자, 탭 닫기 · 새로고침은 브라우저 확인창) — 서명 설정과 같은 모양 */
|
||||
|
||||
@@ -110,6 +110,11 @@ export const MAIL_SEND_TIMEOUT_MS = 60 * 1000
|
||||
* (계약 2절 "메일함 관리"). 기본 10초로는 끊기므로 발송과 같은 값을 쓴다
|
||||
*/
|
||||
export const MAIL_MAILBOX_OPERATION_TIMEOUT_MS = 60 * 1000
|
||||
/**
|
||||
* 이미 받은 메일에 필터 적용(POST /mail-filter-runs)의 요청 하나 — 서버가 한 요청을 25초 안에 끝내고 cursor 를 돌려준다
|
||||
* (계약 2절 "이미 받은 메일에 적용" — 4단계 오래 걸리는 처리와 같은 상한). 기본 10초로는 끊기므로 메일함 처리와 같은 값을 쓴다
|
||||
*/
|
||||
export const MAIL_FILTER_RUN_TIMEOUT_MS = 60 * 1000
|
||||
|
||||
// ── 중복 발송 방지 (계약 2절 "발송 본문") ─────────────────────
|
||||
/** 쓰기 화면마다 만든 키(UUID)를 이 헤더로 보낸다 — 서버는 같은 키의 두 번째 발송을 내보내지 않는다 */
|
||||
|
||||
@@ -261,7 +261,7 @@ export interface MailboxBulkResult {
|
||||
// ── 부재중 자동 응답 (계약 2절 "부재중 자동 응답" — 4단계) ──────────────
|
||||
|
||||
/**
|
||||
* GET · PUT /mail-vacation — 저장은 전체 교체. 꺼도 쓴 제목 · 내용은 남긴다.
|
||||
* PUT /mail-vacation 본문 — 저장은 전체 교체. 꺼도 쓴 제목 · 내용은 남긴다.
|
||||
* 제목(1~150자) · 내용(1~600자)은 켤 때만 필요하다 — 꺼져 있으면 빈 값도 된다. 설정한 적 없으면 꺼짐 · 빈 값이 온다
|
||||
*/
|
||||
export interface MailVacation {
|
||||
@@ -275,6 +275,15 @@ export interface MailVacation {
|
||||
textBody: string
|
||||
}
|
||||
|
||||
/** GET · PUT /mail-vacation 의 응답 (7단계) — 저장한 값 + 엔진에서 지금 동작하는지 */
|
||||
export interface MailVacationState extends MailVacation {
|
||||
/**
|
||||
* false = 엔진에서 우리 규칙(필터와 한 스크립트)이 꺼져 있어 켜 둔 자동 응답이 지금은 동작하지 않는다 — 저장하면 다시 켜진다.
|
||||
* 메일 필터의 isActive 와 같은 값. PUT 본문에는 넣지 않는다(넣으면 400 VAL_001 — 모르는 필드)
|
||||
*/
|
||||
isActive: boolean
|
||||
}
|
||||
|
||||
// ── 메일 필터 (계약 2절 "메일 필터" — 6단계) ──────────────────────
|
||||
|
||||
/** 조건이 모두 맞음 · 하나라도 맞음 · 모든 메일(조건 없음) */
|
||||
@@ -334,6 +343,42 @@ export interface MailFilterSetPayload {
|
||||
version: string
|
||||
}
|
||||
|
||||
// ── 이미 받은 메일에 적용 (계약 2절 "이미 받은 메일에 적용" — 7단계) ─────────────
|
||||
|
||||
/** 이미 받은 메일에 할 일 — 하나 이상. 전달은 없다(이미 받은 메일은 전달하지 않는다) */
|
||||
export interface MailFilterRunActions {
|
||||
/** 필터 규칙과 같은 메일함만(보낸편지함 · 임시보관함은 400). 받은편지함이거나 없는 메일함이면 서버가 옮기지 않는다 */
|
||||
moveToMailboxId: string | null
|
||||
markAsRead: boolean
|
||||
flag: boolean
|
||||
}
|
||||
|
||||
/** 적용할 규칙 — 방금 저장한 그 규칙의 조건 · 할 일 (서버는 저장해 둔 필터를 읽지 않고 이것을 쓴다) */
|
||||
export interface MailFilterRunRule {
|
||||
match: MailFilterMatch
|
||||
/** all · any 는 1~5개, always 는 빈 배열 */
|
||||
conditions: MailFilterCondition[]
|
||||
actions: MailFilterRunActions
|
||||
}
|
||||
|
||||
/** POST /mail-filter-runs 본문 */
|
||||
export interface MailFilterRunRequest extends MailFilterRunRule {
|
||||
/** 처음은 null, 이어 할 때는 앞 응답의 cursor 그대로 (해석하지 않는다) */
|
||||
cursor: string | null
|
||||
}
|
||||
|
||||
/** POST /mail-filter-runs 응답 — 한 요청은 25초 안에 끝난다 */
|
||||
export interface MailFilterRun {
|
||||
/** 이번 요청에서 확인한 받은편지함 메일 수 */
|
||||
scanned: number
|
||||
/** 그중 조건에 맞아 할 일을 한 메일 수 (이미 읽음 · 중요였던 메일도 센다) */
|
||||
matched: number
|
||||
/** 아직 확인하지 않은 메일 수 — 어림 (그사이 들어오고 나간 메일이 있다) */
|
||||
remaining: number
|
||||
/** null = 끝. 아니면 같은 본문에 이 cursor 를 넣어 다시 보낸다 */
|
||||
cursor: string | null
|
||||
}
|
||||
|
||||
// ── 서명 (계약 2절 "서명") ─────────────────────────────
|
||||
|
||||
export type SignatureImageType = 'image/png' | 'image/jpeg' | 'image/gif'
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { Mailbox, MailFilterRule } from '@/shared/types/mail'
|
||||
import { newFilterDraft, type MailFilterDraft } from './mail-filter'
|
||||
import {
|
||||
filterApplyBlock,
|
||||
filterRunIdleReason,
|
||||
filterRunRuleOf,
|
||||
filterRunTarget,
|
||||
mailFilterRunCopy,
|
||||
} from './mail-filter-run'
|
||||
|
||||
// 이미 받은 메일에 필터 적용 — 할 일 판정 · 보낼 규칙 · 끝 알림의 메일함 · 알림 문구 (계약 2절 "이미 받은 메일에 적용" · 디자인 원본 MailFilterApplyStates)
|
||||
|
||||
function mailbox(id: string, name: string, role: Mailbox['role'] = null): Mailbox {
|
||||
return { id, name, role, parentId: null, sortOrder: 0, totalCount: 0, unreadCount: 0 }
|
||||
}
|
||||
|
||||
const MAILBOXES = [mailbox('inbox', '받은편지함', 'inbox'), mailbox('vendor', '거래처'), mailbox('trash', '휴지통', 'trash')]
|
||||
|
||||
function rule(patch: Partial<MailFilterRule> = {}): MailFilterRule {
|
||||
return {
|
||||
id: 'r2',
|
||||
name: '거래처 메일',
|
||||
isEnabled: true,
|
||||
match: 'all',
|
||||
conditions: [{ field: 'from', operator: 'contains', value: '@hanbit-partners.co.kr' }],
|
||||
actions: { moveToMailboxId: 'vendor', markAsRead: false, flag: false, forwardTo: null },
|
||||
...patch,
|
||||
}
|
||||
}
|
||||
|
||||
const NO_ACTIONS = { moveToMailboxId: null, markAsRead: false, flag: false, forwardTo: null }
|
||||
|
||||
function draft(patch: Partial<MailFilterDraft> = {}): MailFilterDraft {
|
||||
return { ...newFilterDraft({ moveTarget: 'vendor' }), ...patch }
|
||||
}
|
||||
|
||||
describe('이미 받은 메일에 할 일 — 칸과 시작이 함께 쓰는 판정', () => {
|
||||
it('할 일 = 읽음 · 중요 · 지금 목록에 있는 받은편지함이 아닌 메일함으로 옮기기', () => {
|
||||
expect(filterRunIdleReason({ moveToMailboxId: 'vendor', markAsRead: false, flag: false }, MAILBOXES)).toBeNull()
|
||||
expect(filterRunIdleReason({ moveToMailboxId: 'trash', markAsRead: false, flag: false }, MAILBOXES)).toBeNull()
|
||||
expect(filterRunIdleReason({ moveToMailboxId: null, markAsRead: true, flag: false }, MAILBOXES)).toBeNull()
|
||||
expect(filterRunIdleReason({ moveToMailboxId: null, markAsRead: false, flag: true }, MAILBOXES)).toBeNull()
|
||||
// 옮길 곳이 없어져도 · 받은편지함이어도 표시는 할 일이다
|
||||
expect(filterRunIdleReason({ moveToMailboxId: 'gone', markAsRead: true, flag: false }, MAILBOXES)).toBeNull()
|
||||
expect(filterRunIdleReason({ moveToMailboxId: 'inbox', markAsRead: false, flag: true }, MAILBOXES)).toBeNull()
|
||||
})
|
||||
|
||||
it('할 일이 없는 까닭 — 옮길 메일함이 목록에 없음 → 받은편지함으로 옮기기만 → 그 밖', () => {
|
||||
expect(filterRunIdleReason({ moveToMailboxId: 'gone', markAsRead: false, flag: false }, MAILBOXES)).toBe('missingMailbox')
|
||||
expect(filterRunIdleReason({ moveToMailboxId: 'vendor', markAsRead: false, flag: false }, [])).toBe('missingMailbox')
|
||||
expect(filterRunIdleReason({ moveToMailboxId: 'inbox', markAsRead: false, flag: false }, MAILBOXES)).toBe('inboxOnly')
|
||||
expect(filterRunIdleReason({ moveToMailboxId: null, markAsRead: false, flag: false }, MAILBOXES)).toBe('noAction')
|
||||
})
|
||||
})
|
||||
|
||||
describe('「받은편지함에 이미 있는 메일에도 적용」을 고를 수 없는 까닭 (고치기 창의 칸 값)', () => {
|
||||
it('이 탭에서 적용하는 중이 먼저 — 그다음 지워진 메일함 · 받은편지함으로만 · 전달만', () => {
|
||||
expect(filterApplyBlock(draft({ move: true }), MAILBOXES, true)).toBe('busy')
|
||||
expect(filterApplyBlock(draft({ move: true, moveTarget: 'gone', forward: true }), MAILBOXES, false)).toBe('missingMailbox')
|
||||
expect(filterApplyBlock(draft({ move: true, moveTarget: 'inbox', forward: true }), MAILBOXES, false)).toBe('inboxOnly')
|
||||
expect(filterApplyBlock(draft({ forward: true }), MAILBOXES, false)).toBe('forwardOnly')
|
||||
// 고를 메일함이 하나도 없어 빈 값이면 지워진 메일함처럼
|
||||
expect(filterApplyBlock(draft({ move: true, moveTarget: '' }), MAILBOXES, false)).toBe('missingMailbox')
|
||||
})
|
||||
|
||||
it('고를 수 있음 — 옮기기 · 읽음 · 중요가 하나라도, 끈 할 일의 값은 보지 않는다 · 할 일을 아직 고르지 않았으면 막지 않는다', () => {
|
||||
expect(filterApplyBlock(draft({ move: true }), MAILBOXES, false)).toBeNull()
|
||||
expect(filterApplyBlock(draft({ move: true, moveTarget: 'inbox', markAsRead: true }), MAILBOXES, false)).toBeNull()
|
||||
expect(filterApplyBlock(draft({ moveTarget: 'gone', flag: true }), MAILBOXES, false)).toBeNull()
|
||||
expect(filterApplyBlock(draft({ forward: true, flag: true }), MAILBOXES, false)).toBeNull()
|
||||
expect(filterApplyBlock(draft(), MAILBOXES, false)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('보낼 규칙', () => {
|
||||
it('조건 · 할 일만 — 전달 주소 · id · 이름 · 켜짐은 보내지 않는다', () => {
|
||||
const withForward = rule({
|
||||
actions: { moveToMailboxId: 'vendor', markAsRead: true, flag: false, forwardTo: 'doyun.lee@arasystem.it.kr' },
|
||||
})
|
||||
expect(filterRunRuleOf(withForward, MAILBOXES)).toEqual({
|
||||
match: 'all',
|
||||
conditions: [{ field: 'from', operator: 'contains', value: '@hanbit-partners.co.kr' }],
|
||||
actions: { moveToMailboxId: 'vendor', markAsRead: true, flag: false },
|
||||
})
|
||||
})
|
||||
|
||||
it('모든 메일이면 조건은 빈 배열 · 이미 받은 메일에 할 일이 없으면 null (칸과 같은 판정)', () => {
|
||||
expect(filterRunRuleOf(rule({ match: 'always', conditions: [], actions: { ...NO_ACTIONS, flag: true } }), MAILBOXES)).toEqual({
|
||||
match: 'always',
|
||||
conditions: [],
|
||||
actions: { moveToMailboxId: null, markAsRead: false, flag: true },
|
||||
})
|
||||
expect(filterRunRuleOf(rule({ actions: { ...NO_ACTIONS, forwardTo: 'doyun.lee@arasystem.it.kr' } }), MAILBOXES)).toBeNull()
|
||||
expect(filterRunRuleOf(rule({ actions: { ...NO_ACTIONS, moveToMailboxId: 'inbox' } }), MAILBOXES)).toBeNull()
|
||||
expect(filterRunRuleOf(rule({ actions: { ...NO_ACTIONS, moveToMailboxId: 'gone' } }), MAILBOXES)).toBeNull()
|
||||
// 표시가 있으면 지워진 메일함을 가리켜도 보낸다 — 서버는 옮기지 않고 표시만 한다
|
||||
expect(filterRunRuleOf(rule({ actions: { ...NO_ACTIONS, moveToMailboxId: 'gone', markAsRead: true } }), MAILBOXES)?.actions).toEqual({
|
||||
moveToMailboxId: 'gone',
|
||||
markAsRead: true,
|
||||
flag: false,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('끝 알림의 [{메일함} 보기]', () => {
|
||||
it('옮기는 필터의 그 메일함 — 받은편지함 · 지금 목록에 없는 메일함 · 옮기지 않는 필터는 없다', () => {
|
||||
expect(filterRunTarget(rule(), MAILBOXES)?.name).toBe('거래처')
|
||||
expect(filterRunTarget(rule({ actions: { ...rule().actions, moveToMailboxId: 'trash' } }), MAILBOXES)?.name).toBe('휴지통')
|
||||
expect(filterRunTarget(rule({ actions: { ...rule().actions, moveToMailboxId: 'inbox' } }), MAILBOXES)).toBeNull()
|
||||
expect(filterRunTarget(rule({ actions: { ...rule().actions, moveToMailboxId: 'gone' } }), MAILBOXES)).toBeNull()
|
||||
expect(filterRunTarget(rule({ actions: { ...rule().actions, moveToMailboxId: null, markAsRead: true } }), MAILBOXES)).toBeNull()
|
||||
expect(filterRunTarget(rule(), [])).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('알림 문구 (디자인 원본 MailFilterApplyStates)', () => {
|
||||
const copy = mailFilterRunCopy(rule())
|
||||
|
||||
it('하는 중 — "· 확인 / 전체통 확인", 전체를 모르면 수를 뺀다', () => {
|
||||
expect(copy.progress({ scanned: 4200, total: 8412 })).toBe('‘거래처 메일’ 필터를 받은편지함에 적용하는 중 · 4,200 / 8,412통 확인')
|
||||
expect(copy.progress({ scanned: 0, total: 0 })).toBe('‘거래처 메일’ 필터를 받은편지함에 적용하는 중')
|
||||
})
|
||||
|
||||
it('끝 — N통에 적용, 0통이면 맞는 메일이 없다', () => {
|
||||
expect(copy.done(37)).toBe('‘거래처 메일’ 필터를 받은편지함의 메일 37통에 적용했습니다.')
|
||||
expect(copy.done(1234)).toBe('‘거래처 메일’ 필터를 받은편지함의 메일 1,234통에 적용했습니다.')
|
||||
expect(copy.done(0)).toBe('받은편지함에 ‘거래처 메일’ 필터에 맞는 메일이 없습니다.')
|
||||
})
|
||||
|
||||
it('멈춤 — 전체 가운데 확인한 데까지 · 적용한 수', () => {
|
||||
expect(copy.stopped({ scanned: 4200, matched: 12, total: 8412 })).toBe(
|
||||
'‘거래처 메일’ 필터 — 8,412통 가운데 4,200통까지 확인하고 멈췄습니다(12통에 적용). 다시 시도해 주세요.',
|
||||
)
|
||||
})
|
||||
|
||||
it('이름 없는 필터는 조건 글이 이름 자리에 — 작은따옴표가 겹치지 않게 큰따옴표로 묶는다', () => {
|
||||
expect(mailFilterRunCopy(rule({ name: '' })).done(3)).toBe(
|
||||
'“보낸 사람에 ‘@hanbit-partners.co.kr’ 포함” 필터를 받은편지함의 메일 3통에 적용했습니다.',
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,121 @@
|
||||
// 이미 받은 메일에 필터 적용 — 할 일 판정 · 보낼 규칙 · 끝 알림의 메일함 · 알림 문구 (순수 함수만 둔다)
|
||||
// 계약 2절 "이미 받은 메일에 적용 (/api/mail-filter-runs)" · 디자인 README "7단계" (디자인 원본 MailFilterApply · MailFilterApplyStates)
|
||||
// 서버는 한 요청을 25초 안에 끝내고 cursor 를 돌려준다 — 화면은 cursor 가 null 이 될 때까지 다시 보내며
|
||||
// "‘거래처 메일’ 필터를 받은편지함에 적용하는 중 · 4,200 / 8,412통 확인"을 보인다 (4단계 오래 걸리는 처리와 같은 결 — mailbox-operation.ts)
|
||||
|
||||
import { MAIL_FILTER_RUN_TEXT } from '@/shared/constants/mail-filter'
|
||||
import type { Mailbox, MailFilterActions, MailFilterRule, MailFilterRunRule } from '@/shared/types/mail'
|
||||
import { formatCount } from '@/shared/utils/format'
|
||||
import { hasDraftAction, quotedRuleTitle, type MailFilterActionDraft } from '@/shared/utils/mail-filter'
|
||||
|
||||
/** 지금까지 — 확인한 수 · 할 일을 한 수 · 전체(확인 + 남은 수 — 어림. 첫 응답 전에는 받은편지함의 메일 수, 모르면 0) */
|
||||
export interface MailFilterRunTally {
|
||||
scanned: number
|
||||
matched: number
|
||||
total: number
|
||||
}
|
||||
|
||||
// ── 이미 받은 메일에 할 일 ───────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 이미 받은 메일에 할 일이 없는 까닭 — 고치기 창의 칸(filterApplyBlock)과 적용 시작(filterRunRuleOf)이 함께 쓴다.
|
||||
* missingMailbox: 옮길 메일함이 지금 메일함 목록에 없다(지워짐) · inboxOnly: 받은편지함으로 옮기기만 · noAction: 그 밖(전달만 · 할 일 없음)
|
||||
*/
|
||||
export type MailFilterRunIdle = 'missingMailbox' | 'inboxOnly' | 'noAction'
|
||||
|
||||
/**
|
||||
* 할 일 = 읽음 표시 · 중요 표시 · 옮기기(옮길 메일함이 지금 메일함 목록에 있고 받은편지함이 아님 — 서버는 받은편지함 · 없는 메일함이면 옮기지 않는다).
|
||||
* 할 일이 있으면 null, 없으면 그 까닭을 이 차례로 고른다: 옮길 메일함이 목록에 없음 → 받은편지함으로 옮기기 → 그 밖
|
||||
*/
|
||||
export function filterRunIdleReason(
|
||||
actions: Pick<MailFilterActions, 'moveToMailboxId' | 'markAsRead' | 'flag'>,
|
||||
mailboxes: readonly Mailbox[],
|
||||
): MailFilterRunIdle | null {
|
||||
if (actions.markAsRead || actions.flag) return null
|
||||
if (actions.moveToMailboxId === null) return 'noAction'
|
||||
const target = mailboxes.find((mailbox) => mailbox.id === actions.moveToMailboxId)
|
||||
if (!target) return 'missingMailbox'
|
||||
return target.role === 'inbox' ? 'inboxOnly' : null
|
||||
}
|
||||
|
||||
/**
|
||||
* 「받은편지함에 이미 있는 메일에도 적용」을 고를 수 없는 까닭 (디자인 원본 MailFilterApplyStates — 7단계 리뷰로 넓혔다)
|
||||
* busy: 이 탭에서 다른 필터를 적용하는 중(가장 먼저) · missingMailbox · inboxOnly · forwardOnly: 전달만 하는 필터
|
||||
*/
|
||||
export type MailFilterApplyBlock = 'busy' | 'missingMailbox' | 'inboxOnly' | 'forwardOnly'
|
||||
|
||||
/**
|
||||
* 고치기 창의 칸 값으로 본다 — 고를 수 있으면 null. 할 일을 아직 하나도 고르지 않았으면 막지 않는다
|
||||
* (저장할 때 칸 아래에 "할 일을 하나 이상 골라 주세요."). 옮기기를 골랐으면 고른 메일함으로 본다 — 고를 곳이 없어 빈 값이면 목록에 없는 메일함이다
|
||||
*/
|
||||
export function filterApplyBlock(
|
||||
draft: MailFilterActionDraft,
|
||||
mailboxes: readonly Mailbox[],
|
||||
busy: boolean,
|
||||
): MailFilterApplyBlock | null {
|
||||
if (busy) return 'busy'
|
||||
if (!hasDraftAction(draft)) return null
|
||||
const idle = filterRunIdleReason(
|
||||
{ moveToMailboxId: draft.move ? draft.moveTarget : null, markAsRead: draft.markAsRead, flag: draft.flag },
|
||||
mailboxes,
|
||||
)
|
||||
if (idle === null) return null
|
||||
return idle === 'noAction' ? 'forwardOnly' : idle
|
||||
}
|
||||
|
||||
/**
|
||||
* 저장한 규칙 → 적용 요청의 규칙. 전달은 보내지 않는다(이미 받은 메일은 전달하지 않는다).
|
||||
* 이미 받은 메일에 할 일이 없으면 null — 고치기 창의 칸과 같은 판정(filterRunIdleReason)이라 칸이 꺼진 규칙은 시작하지 않는다
|
||||
*/
|
||||
export function filterRunRuleOf(
|
||||
rule: Pick<MailFilterRule, 'match' | 'conditions' | 'actions'>,
|
||||
mailboxes: readonly Mailbox[],
|
||||
): MailFilterRunRule | null {
|
||||
if (filterRunIdleReason(rule.actions, mailboxes) !== null) return null
|
||||
const { moveToMailboxId, markAsRead, flag } = rule.actions
|
||||
return {
|
||||
match: rule.match,
|
||||
conditions: rule.match === 'always' ? [] : rule.conditions.map((condition) => ({ ...condition })),
|
||||
actions: { moveToMailboxId, markAsRead, flag },
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 끝 알림의 [{메일함} 보기]가 갈 메일함 — 옮기는 필터만. 받은편지함으로 옮기는 필터(옮기지 않는다)와
|
||||
* 지금 메일함 목록에 없는 메일함(지워짐 — 서버도 옮기지 않는다 · 아직 받지 않은 목록)은 null (단추 없음)
|
||||
*/
|
||||
export function filterRunTarget(
|
||||
rule: Pick<MailFilterRule, 'actions'>,
|
||||
mailboxes: readonly Mailbox[],
|
||||
): Mailbox | null {
|
||||
const id = rule.actions.moveToMailboxId
|
||||
if (id === null) return null
|
||||
const target = mailboxes.find((mailbox) => mailbox.id === id)
|
||||
return target && target.role !== 'inbox' ? target : null
|
||||
}
|
||||
|
||||
// ── 알림 문구 ───────────────────────────────────────────────
|
||||
|
||||
export interface MailFilterRunCopy {
|
||||
/** 하는 중 — "‘…’ 필터를 받은편지함에 적용하는 중 · 4,200 / 8,412통 확인" (전체를 모르면 수를 뺀다) */
|
||||
progress: (tally: Pick<MailFilterRunTally, 'scanned' | 'total'>) => string
|
||||
/** 끝남 — 할 일을 한 수. 0 이면 "받은편지함에 ‘…’ 필터에 맞는 메일이 없습니다." */
|
||||
done: (matched: number) => string
|
||||
/** 도중에 멈춤 — 거기까지의 수 */
|
||||
stopped: (tally: MailFilterRunTally) => string
|
||||
}
|
||||
|
||||
/** 그 규칙의 알림 문구 — 이름은 따옴표로 묶는다(이름이 없으면 조건 글 — 길면 자른다, quotedRuleTitle) */
|
||||
export function mailFilterRunCopy(rule: Pick<MailFilterRule, 'name' | 'match' | 'conditions'>): MailFilterRunCopy {
|
||||
const quoted = quotedRuleTitle(rule)
|
||||
return {
|
||||
progress: ({ scanned, total }) =>
|
||||
total > 0
|
||||
? `${MAIL_FILTER_RUN_TEXT.progress(quoted)}${MAIL_FILTER_RUN_TEXT.progressCount(formatCount(scanned), formatCount(total))}`
|
||||
: MAIL_FILTER_RUN_TEXT.progress(quoted),
|
||||
done: (matched) =>
|
||||
matched > 0 ? MAIL_FILTER_RUN_TEXT.done(quoted, formatCount(matched)) : MAIL_FILTER_RUN_TEXT.none(quoted),
|
||||
stopped: ({ scanned, matched, total }) =>
|
||||
MAIL_FILTER_RUN_TEXT.stopped(quoted, formatCount(total), formatCount(scanned), formatCount(matched)),
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import type { Mailbox, MailFilterRule } from '@/shared/types/mail'
|
||||
import {
|
||||
actionsText,
|
||||
conditionsText,
|
||||
copyFilterDraft,
|
||||
defaultMoveTarget,
|
||||
filterDraftOf,
|
||||
filterDraftProblems,
|
||||
@@ -16,6 +17,7 @@ import {
|
||||
quotedRuleTitle,
|
||||
ruleSummary,
|
||||
ruleTitle,
|
||||
sameFilterDraft,
|
||||
withoutRule,
|
||||
withRuleEnabled,
|
||||
withRuleMoved,
|
||||
@@ -211,6 +213,33 @@ describe('고치기 창의 값 ↔ 규칙', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('나가기 확인 — 창을 연 때의 값과 견준다 (7단계)', () => {
|
||||
it('사본은 조건 줄까지 따로다 — 고쳐도 연 때의 값이 바뀌지 않는다', () => {
|
||||
const value = draft()
|
||||
const opened = copyFilterDraft(value)
|
||||
value.conditions[0] = { field: 'subject', operator: 'contains', value: '견적' }
|
||||
|
||||
expect(opened.conditions).toEqual([{ field: 'from', operator: 'contains', value: '' }])
|
||||
expect(sameFilterDraft(value, opened)).toBe(false)
|
||||
})
|
||||
|
||||
it('칸마다 쓴 그대로 견준다 — 지웠다 되돌리면 같고, 앞뒤 공백 · 숨긴 조건 · 끈 할 일의 값도 다르면 다르다', () => {
|
||||
const opened = draft({ conditions: [{ field: 'from', operator: 'contains', value: 'seojun.park@arasystem.it.kr' }] })
|
||||
expect(sameFilterDraft(copyFilterDraft(opened), opened)).toBe(true)
|
||||
|
||||
expect(sameFilterDraft({ ...opened, name: ' ' }, opened)).toBe(false)
|
||||
expect(sameFilterDraft({ ...opened, match: 'any' }, opened)).toBe(false)
|
||||
expect(sameFilterDraft({ ...opened, conditions: [] }, opened)).toBe(false)
|
||||
expect(
|
||||
sameFilterDraft({ ...opened, conditions: [{ field: 'from', operator: 'notContains', value: 'seojun.park@arasystem.it.kr' }] }, opened),
|
||||
).toBe(false)
|
||||
expect(sameFilterDraft({ ...opened, moveTarget: 'notice' }, opened)).toBe(false)
|
||||
expect(sameFilterDraft({ ...opened, forward: true }, opened)).toBe(false)
|
||||
expect(sameFilterDraft({ ...opened, forwardTo: 'x' }, opened)).toBe(false)
|
||||
expect(sameFilterDraft({ ...opened, flag: true }, opened)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('검사 — 서버와 같은 규칙 (계약 2절 "메일 필터"의 "클라이언트")', () => {
|
||||
it('빈 조건 · `${` · 200자 넘음 · 제어 문자 — 줄마다 따로 (모든 메일이면 보지 않는다)', () => {
|
||||
const value = draft({
|
||||
|
||||
@@ -155,7 +155,7 @@ export type MailFilterActionDraft = Pick<
|
||||
'move' | 'moveTarget' | 'markAsRead' | 'flag' | 'forward' | 'forwardTo'
|
||||
>
|
||||
|
||||
/** 할 일을 하나라도 골랐다 (옮기기 · 읽음 · 중요 · 전달) */
|
||||
/** 할 일을 하나라도 골랐다 (옮기기 · 읽음 · 중요 · 전달) — 이미 받은 메일에 할 일은 mail-filter-run.ts 의 filterRunIdleReason */
|
||||
export function hasDraftAction(draft: MailFilterActionDraft): boolean {
|
||||
return draft.move || draft.markAsRead || draft.flag || draft.forward
|
||||
}
|
||||
@@ -199,6 +199,38 @@ export function filterDraftOf(rule: MailFilterRule, moveTarget: string): MailFil
|
||||
}
|
||||
}
|
||||
|
||||
/** 칸 값의 사본 — 창을 연 때의 값을 따로 둔다(나가기 확인이 견준다). 조건 줄도 새로 만든다 */
|
||||
export function copyFilterDraft(draft: MailFilterDraft): MailFilterDraft {
|
||||
return { ...draft, conditions: draft.conditions.map((condition) => ({ ...condition })) }
|
||||
}
|
||||
|
||||
/**
|
||||
* 두 칸 값이 같은가 — 고치기 창을 연 뒤 바꾼 것이 있는지 본다 (7단계 나가기 확인). 쓴 그대로(앞뒤 공백 포함) 견준다.
|
||||
* 모든 메일로 바꾸며 숨긴 조건 줄 · 끈 할 일에 남겨 둔 값도 견준다 — 다시 켜면 그대로 돌아오는 값이라서다
|
||||
*/
|
||||
export function sameFilterDraft(a: MailFilterDraft, b: MailFilterDraft): boolean {
|
||||
return (
|
||||
a.name === b.name &&
|
||||
a.match === b.match &&
|
||||
a.move === b.move &&
|
||||
a.moveTarget === b.moveTarget &&
|
||||
a.markAsRead === b.markAsRead &&
|
||||
a.flag === b.flag &&
|
||||
a.forward === b.forward &&
|
||||
a.forwardTo === b.forwardTo &&
|
||||
a.conditions.length === b.conditions.length &&
|
||||
a.conditions.every((condition, index) => {
|
||||
const other = b.conditions[index]
|
||||
return (
|
||||
other !== undefined &&
|
||||
condition.field === other.field &&
|
||||
condition.operator === other.operator &&
|
||||
condition.value === other.value
|
||||
)
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
/** 새 규칙의 id · 켜짐 — 새로 만든 필터는 켜진 채로 끝에 붙는다 */
|
||||
export function newFilterRuleBase(): Pick<MailFilterRule, 'id' | 'isEnabled'> {
|
||||
return { id: createUuid(), isEnabled: true }
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { nextTick } from 'vue'
|
||||
|
||||
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
|
||||
import { MAIL_FILTER_RUN_TIMEOUT_MS } from '@/shared/constants/mail'
|
||||
import type { MailFilterRun, MailFilterRunRequest, MailFilterRunRule } from '@/shared/types/mail'
|
||||
import type { MailFilterRunTally } from '@/shared/utils/mail-filter-run'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useToastStore } from '@/stores/toast.store'
|
||||
import {
|
||||
fail,
|
||||
flushPromises,
|
||||
installMockApi,
|
||||
ok,
|
||||
restoreApiAdapter,
|
||||
type MockRequest,
|
||||
type MockResult,
|
||||
} from '@/test-utils/mock-api'
|
||||
import { useMailFilterRunStore, type MailFilterRunStart } from './mail-filter-run.store'
|
||||
|
||||
// 이미 받은 메일에 필터 적용 — cursor 가 null 이 될 때까지 · 수를 이어 센다 · 한 탭에 하나 · 세션이 바뀌면 멈춘다 (계약 2절 "이미 받은 메일에 적용")
|
||||
|
||||
const USER = { email: 'ara.kim@arasystem.it.kr', name: '김아라' }
|
||||
|
||||
const RULE: MailFilterRunRule = {
|
||||
match: 'all',
|
||||
conditions: [{ field: 'from', operator: 'contains', value: '@hanbit-partners.co.kr' }],
|
||||
actions: { moveToMailboxId: 'vendor', markAsRead: false, flag: false },
|
||||
}
|
||||
|
||||
const FROM_START: MailFilterRunStart = { rule: RULE, cursor: null, scanned: 0, matched: 0, total: 8412 }
|
||||
|
||||
function step(scanned: number, matched: number, remaining: number, cursor: string | null): MockResult {
|
||||
const run: MailFilterRun = { scanned, matched, remaining, cursor }
|
||||
return ok(run)
|
||||
}
|
||||
|
||||
/** 응답을 손으로 끝내는 요청 — 그 사이에 세션을 바꿔 본다 */
|
||||
function deferred() {
|
||||
let finish: (result: MockResult) => void = () => undefined
|
||||
const promise = new Promise<MockResult>((resolve) => {
|
||||
finish = resolve
|
||||
})
|
||||
return { promise, finish }
|
||||
}
|
||||
|
||||
describe('mail-filter-run.store', () => {
|
||||
let requests: MockRequest[]
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
useAuthStore().startSession(USER)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
useToastStore().clear()
|
||||
restoreApiAdapter()
|
||||
})
|
||||
|
||||
/** 차례로 돌려줄 응답 — 다 쓰면 마지막 것을 되풀이한다 */
|
||||
function replies(...results: (MockResult | Promise<MockResult>)[]) {
|
||||
let next = 0
|
||||
requests = installMockApi(() => {
|
||||
const result = results[Math.min(next, results.length - 1)] as MockResult | Promise<MockResult>
|
||||
next += 1
|
||||
return result
|
||||
})
|
||||
}
|
||||
|
||||
const bodies = () => requests.map((request) => request.data as MailFilterRunRequest)
|
||||
const toasts = () => useToastStore().toasts.map((toast) => toast.message)
|
||||
|
||||
it('cursor 가 null 이 될 때까지 같은 규칙에 cursor 를 넣어 다시 보내고, 확인 · 적용 수를 이어 센다 (전체 = 확인 + 남은 수)', async () => {
|
||||
replies(step(4500, 30, 3912, 'c1'), step(3000, 5, 912, 'c2'), step(912, 2, 0, null))
|
||||
const store = useMailFilterRunStore()
|
||||
const progress: MailFilterRunTally[] = []
|
||||
|
||||
const running = store.run(FROM_START, (tally) => progress.push(tally))
|
||||
expect(store.running).toBe(true)
|
||||
const outcome = await running
|
||||
|
||||
expect(outcome).toEqual({ status: 'done', scanned: 8412, matched: 37, total: 8412 })
|
||||
expect(progress).toEqual([
|
||||
{ scanned: 4500, matched: 30, total: 8412 },
|
||||
{ scanned: 7500, matched: 35, total: 8412 },
|
||||
])
|
||||
expect(bodies()).toEqual([
|
||||
{ ...RULE, cursor: null },
|
||||
{ ...RULE, cursor: 'c1' },
|
||||
{ ...RULE, cursor: 'c2' },
|
||||
])
|
||||
expect(requests.map((request) => `${request.method} ${request.url}`)).toEqual(Array(3).fill('POST /mail-filter-runs'))
|
||||
expect(requests[0]?.config.timeout).toBe(MAIL_FILTER_RUN_TIMEOUT_MS)
|
||||
expect(store.running).toBe(false)
|
||||
})
|
||||
|
||||
it('받은편지함이 비었으면 한 번에 끝난다 — 0통', async () => {
|
||||
replies(step(0, 0, 0, null))
|
||||
const outcome = await useMailFilterRunStore().run({ ...FROM_START, total: 0 }, () => undefined)
|
||||
|
||||
expect(outcome).toEqual({ status: 'done', scanned: 0, matched: 0, total: 0 })
|
||||
expect(requests).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('이 탭에서 적용하는 중에는 두 번째를 보내지 않는다 (busy)', async () => {
|
||||
const first = deferred()
|
||||
replies(first.promise)
|
||||
const store = useMailFilterRunStore()
|
||||
|
||||
const running = store.run(FROM_START, () => undefined)
|
||||
await expect(store.run(FROM_START, () => undefined)).resolves.toEqual({ status: 'busy' })
|
||||
expect(requests).toHaveLength(1)
|
||||
|
||||
first.finish(step(10, 1, 0, null))
|
||||
await running
|
||||
expect(store.running).toBe(false)
|
||||
})
|
||||
|
||||
it('도중에 실패하면 거기까지의 수와 이어 할 자리(마지막 cursor)를 돌려준다 — 까닭은 사전 문구로 알린다', async () => {
|
||||
replies(step(4200, 12, 4212, 'c1'), fail(503, 'MAIL_001'))
|
||||
const outcome = await useMailFilterRunStore().run(FROM_START, () => undefined)
|
||||
|
||||
expect(outcome).toMatchObject({ status: 'failed', cursor: 'c1', scanned: 4200, matched: 12, total: 8412 })
|
||||
expect(outcome.status === 'failed' && outcome.error.code).toBe('MAIL_001')
|
||||
expect(toasts()).toEqual([ERROR_CODE_MESSAGES.MAIL_001])
|
||||
})
|
||||
|
||||
it('처음 요청부터 실패하면 이어 할 자리가 없다 (cursor null) · 429 REQ_001 은 알리지 않는다 (화면이 "다른 창에서 …"로 알린다)', async () => {
|
||||
replies(fail(429, 'REQ_001'))
|
||||
const outcome = await useMailFilterRunStore().run(FROM_START, () => undefined)
|
||||
|
||||
expect(outcome).toMatchObject({ status: 'failed', cursor: null, scanned: 0, matched: 0, total: 8412 })
|
||||
expect(outcome.status === 'failed' && outcome.error.code).toBe('REQ_001')
|
||||
expect(toasts()).toEqual([])
|
||||
// 요청은 알림을 끄고 보낸다 — 알릴지는 응답이 온 때의 세션으로 정한다
|
||||
expect(requests[0]?.config.silent).toBe(true)
|
||||
})
|
||||
|
||||
it('이어 하기 — 앞 실패의 cursor · 수에서 시작해 이어 센다', async () => {
|
||||
replies(step(4212, 25, 0, null))
|
||||
const outcome = await useMailFilterRunStore().run(
|
||||
{ rule: RULE, cursor: 'c1', scanned: 4200, matched: 12, total: 8412 },
|
||||
() => undefined,
|
||||
)
|
||||
|
||||
expect(bodies()).toEqual([{ ...RULE, cursor: 'c1' }])
|
||||
expect(outcome).toEqual({ status: 'done', scanned: 8412, matched: 37, total: 8412 })
|
||||
})
|
||||
|
||||
it('나아가지 못한 응답(확인 0 인데 cursor 가 있다)이 3번 이어지면 멈추고 BIZ_001 — 알리지 않았다', async () => {
|
||||
replies(step(100, 1, 300, 'c1'), step(0, 0, 300, 'c2'), step(0, 0, 300, 'c3'), step(0, 0, 300, 'c4'))
|
||||
const outcome = await useMailFilterRunStore().run(FROM_START, () => undefined)
|
||||
|
||||
expect(requests).toHaveLength(4)
|
||||
expect(outcome).toMatchObject({ status: 'failed', cursor: 'c4', scanned: 100, matched: 1, total: 400 })
|
||||
expect(outcome.status === 'failed' && outcome.error.code).toBe('BIZ_001')
|
||||
expect(toasts()).toEqual([])
|
||||
})
|
||||
|
||||
it('세션이 바뀌면 다음 요청을 보내지 않는다 — 늦게 온 응답도 쓰지 않고, 새 세션은 바로 시작할 수 있다', async () => {
|
||||
const stale = deferred()
|
||||
const fresh = deferred()
|
||||
replies(stale.promise, fresh.promise)
|
||||
const store = useMailFilterRunStore()
|
||||
const authStore = useAuthStore()
|
||||
const progress: MailFilterRunTally[] = []
|
||||
|
||||
const old = store.run(FROM_START, (tally) => progress.push(tally))
|
||||
authStore.clearAuth()
|
||||
await nextTick()
|
||||
expect(store.running).toBe(false)
|
||||
authStore.startSession(USER)
|
||||
await nextTick()
|
||||
|
||||
const next = store.run(FROM_START, () => undefined)
|
||||
expect(store.running).toBe(true)
|
||||
stale.finish(step(4500, 30, 3912, 'c1'))
|
||||
await expect(old).resolves.toEqual({ status: 'canceled' })
|
||||
await flushPromises()
|
||||
expect(progress).toEqual([])
|
||||
expect(requests).toHaveLength(2)
|
||||
// 옛 적용이 끝나도 새 적용의 표시는 그대로다
|
||||
expect(store.running).toBe(true)
|
||||
|
||||
fresh.finish(step(10, 0, 0, null))
|
||||
await next
|
||||
expect(store.running).toBe(false)
|
||||
})
|
||||
|
||||
it('세션이 바뀐 뒤 늦게 온 실패 — 알리지 않고 실행 중 표시도 건드리지 않는다', async () => {
|
||||
const stale = deferred()
|
||||
replies(stale.promise)
|
||||
const store = useMailFilterRunStore()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const old = store.run(FROM_START, () => undefined)
|
||||
authStore.clearAuth()
|
||||
await nextTick()
|
||||
authStore.startSession(USER)
|
||||
await nextTick()
|
||||
|
||||
// 같은 코드의 알림은 3초 안에 다시 띄우지 않는다(useApi) — 앞 테스트의 MAIL_001 과 섞이지 않게 이 파일에서 처음 쓰는 코드로
|
||||
stale.finish(fail(500, 'SYS_001'))
|
||||
await expect(old).resolves.toEqual({ status: 'canceled' })
|
||||
await flushPromises()
|
||||
expect(toasts()).toEqual([])
|
||||
expect(store.running).toBe(false)
|
||||
expect(requests).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,104 @@
|
||||
import { ref, watch } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
import { getErrorMessage, notifyApiError, toApiError, type ApiError } from '@/composables/useApi'
|
||||
import { useMail } from '@/composables/useMail'
|
||||
import type { ErrorCode } from '@/shared/constants/error-codes.generated'
|
||||
import { MAIL_FILTER_RUN_MAX_STALLS } from '@/shared/constants/mail-filter'
|
||||
import type { MailFilterRun, MailFilterRunRule } from '@/shared/types/mail'
|
||||
import type { MailFilterRunTally } from '@/shared/utils/mail-filter-run'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
|
||||
// 이미 받은 메일에 필터 적용 — 계약 2절 "이미 받은 메일에 적용 (/api/mail-filter-runs)" (7단계)
|
||||
// - 서버가 한 요청을 25초 안에 끝내고 cursor 를 돌려준다 — null 이 될 때까지 같은 규칙에 그 cursor 를 넣어 다시 보낸다.
|
||||
// 다시 보낼 때마다 onProgress (확인 = scanned 합, 전체 = 확인 + remaining). 이어 하기([다시 시도])는 앞 실패의 cursor · 수에서 시작한다
|
||||
// - 한 탭에서 한 번에 하나(running — 고치기 창이 그동안 「…에도 적용」 칸을 끈다). 다른 탭 · PC 의 적용은 서버가 429 REQ_001 로 막는다 —
|
||||
// 그 알림은 화면이 "다른 창에서 …"로 한다. 그 밖의 실패 까닭은 여기서 사전 문구로 알린다(4단계 오래 걸리는 처리의 인터셉터 알림과 같은 알림 —
|
||||
// 요청은 알림을 끄고 보내고, 응답이 온 때 세션이 그대로일 때만 알린다: 세션이 바뀐 뒤 늦게 온 실패는 알리지 않는다)
|
||||
// - 한 번도 나아가지 못한 응답(확인 0 인데 cursor 가 있다)이 이어지면 멈추고 BIZ_001 — 같은 요청을 끝없이 보내지 않게 (서버는 그럴 때 503 을 준다)
|
||||
// - 세션이 바뀌면(로그아웃 · 세션 만료 · 다른 계정 — authStore.generation) 다음 요청을 보내지 않고 멈춘다(canceled). 늦게 온 응답도 쓰지 않는다
|
||||
// - 화면과 상관없이 앱이 떠 있는 동안 이어 한다(다른 화면으로 가도 된다). 탭을 닫으면 멈춘다 — 이어 하지 않는다
|
||||
|
||||
/** 처음부터(cursor null · 수 0 · 전체는 받은편지함의 메일 수) 또는 앞 실패의 자리에서 */
|
||||
export interface MailFilterRunStart extends MailFilterRunTally {
|
||||
rule: MailFilterRunRule
|
||||
cursor: string | null
|
||||
}
|
||||
|
||||
export type MailFilterRunOutcome =
|
||||
| ({ status: 'done' } & MailFilterRunTally)
|
||||
/** 도중에 멈췄다 — cursor 는 이어 할 자리(마지막으로 받은 cursor — 처음 요청부터 실패했으면 null), 수는 거기까지 */
|
||||
| ({ status: 'failed'; cursor: string | null; error: ApiError } & MailFilterRunTally)
|
||||
/** 이 탭에서 다른 적용이 돌고 있다 — 보내지 않았다 */
|
||||
| { status: 'busy' }
|
||||
/** 세션이 바뀌었다 — 아무것도 알리지 않는다 */
|
||||
| { status: 'canceled' }
|
||||
|
||||
/** 화면이 알리는 거절 — 같은 직원의 적용이 다른 곳에서 진행 중 (그 밖의 실패는 여기서 알린다) */
|
||||
const SCREEN_NOTIFIED: readonly ErrorCode[] = ['REQ_001']
|
||||
const CANCELED = { status: 'canceled' } as const
|
||||
|
||||
/** 서버가 준 수 — 음수는 0 으로 */
|
||||
function countOf(value: number): number {
|
||||
return Math.max(0, value)
|
||||
}
|
||||
|
||||
export const useMailFilterRunStore = defineStore('mail-filter-run', () => {
|
||||
const mailApi = useMail()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
/** 이 탭에서 적용하는 중 — 한 번에 하나 */
|
||||
const running = ref<boolean>(false)
|
||||
|
||||
/** 끝날 때까지(cursor 가 null) 보낸다 — 세션이 바뀌면 멈춘다 */
|
||||
async function run(
|
||||
start: MailFilterRunStart,
|
||||
onProgress: (tally: MailFilterRunTally) => void,
|
||||
): Promise<MailFilterRunOutcome> {
|
||||
if (running.value) return { status: 'busy' }
|
||||
const session = authStore.generation
|
||||
running.value = true
|
||||
let { cursor, scanned, matched, total } = start
|
||||
let stalls = 0
|
||||
try {
|
||||
for (;;) {
|
||||
let step: MailFilterRun
|
||||
try {
|
||||
// 알림을 끄고 보낸다 — 실패를 알릴지는 응답이 온 때의 세션으로 정한다 (세션 만료 대화상자는 그대로 뜬다)
|
||||
step = await mailApi.runFilter({ ...start.rule, cursor }, { silent: true })
|
||||
} catch (caught) {
|
||||
if (authStore.generation !== session) return CANCELED
|
||||
const error = toApiError(caught)
|
||||
if (!SCREEN_NOTIFIED.includes(error.code)) notifyApiError(error)
|
||||
return { status: 'failed', cursor, scanned, matched, total, error }
|
||||
}
|
||||
if (authStore.generation !== session) return CANCELED
|
||||
const stepScanned = countOf(step.scanned)
|
||||
scanned += stepScanned
|
||||
matched += countOf(step.matched)
|
||||
total = scanned + countOf(step.remaining)
|
||||
if (step.cursor === null) return { status: 'done', scanned, matched, total }
|
||||
cursor = step.cursor
|
||||
stalls = stepScanned > 0 ? 0 : stalls + 1
|
||||
if (stalls >= MAIL_FILTER_RUN_MAX_STALLS) {
|
||||
const error: ApiError = { code: 'BIZ_001', message: getErrorMessage('BIZ_001') }
|
||||
return { status: 'failed', cursor, scanned, matched, total, error }
|
||||
}
|
||||
onProgress({ scanned, matched, total })
|
||||
}
|
||||
} finally {
|
||||
// 세션이 바뀐 뒤 끝난 옛 적용은 새 세션의 표시를 건드리지 않는다 (아래 watch 가 이미 내렸다)
|
||||
if (authStore.generation === session) running.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 로그아웃 · 세션 만료 · 다른 계정 로그인 — 하던 적용은 다음 요청을 보내지 않는다(run 이 멈춘다). 새 세션은 바로 시작할 수 있다
|
||||
watch(
|
||||
() => authStore.generation,
|
||||
() => {
|
||||
running.value = false
|
||||
},
|
||||
)
|
||||
|
||||
return { running, run }
|
||||
})
|
||||
@@ -2,24 +2,27 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { nextTick } from 'vue'
|
||||
|
||||
import type { MailVacation } from '@/shared/types/mail'
|
||||
import type { MailVacation, MailVacationState } from '@/shared/types/mail'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useToastStore } from '@/stores/toast.store'
|
||||
import { installMockApi, ok, restoreApiAdapter, type MockRequest, type MockResult } from '@/test-utils/mock-api'
|
||||
import { useMailVacationStore } from './mail-vacation.store'
|
||||
|
||||
// 부재중 자동 응답 스토어 — 세션이 바뀐 뒤 늦게 온 응답은 쓰지 않는다 (계약 2절 "부재중 자동 응답")
|
||||
// 부재중 자동 응답 스토어 — 세션이 바뀐 뒤 늦게 온 응답은 쓰지 않는다 · 엔진에서 동작하는지(isActive — 7단계) (계약 2절 "부재중 자동 응답")
|
||||
|
||||
const USER = { email: 'ara.kim@arasystem.it.kr', name: '김아라' }
|
||||
const OTHER = { email: 'minsu.park@arasystem.it.kr', name: '박민수' }
|
||||
|
||||
const ON: MailVacation = {
|
||||
/** PUT 본문 — 다섯 칸 */
|
||||
const BODY: MailVacation = {
|
||||
isEnabled: true,
|
||||
fromDate: null,
|
||||
toDate: '2026-11-03T15:00:00.000Z',
|
||||
subject: '부재중입니다',
|
||||
textBody: '11월 3일까지 자리를 비웁니다.',
|
||||
}
|
||||
/** GET · PUT 응답 — 본문 + 엔진에서 동작하는지 (7단계) */
|
||||
const ON: MailVacationState = { ...BODY, isActive: true }
|
||||
|
||||
/** 응답을 손으로 끝내는 요청 — 그 사이에 세션을 바꿔 본다 */
|
||||
function deferred() {
|
||||
@@ -82,7 +85,7 @@ describe('mail-vacation.store — 늦게 온 응답', () => {
|
||||
|
||||
it('같은 세션이면 저장한 값이 곧 안내 값이다 — 그 전에 시작한 불러오기가 늦게 와도 덮지 않는다', async () => {
|
||||
const stale = deferred()
|
||||
const saved: MailVacation = { ...ON, subject: '휴가 중입니다' }
|
||||
const saved: MailVacationState = { ...ON, subject: '휴가 중입니다' }
|
||||
requests = installMockApi((request) => (request.method === 'PUT' ? ok(saved) : stale.promise))
|
||||
const store = useMailVacationStore()
|
||||
|
||||
@@ -95,4 +98,30 @@ describe('mail-vacation.store — 늦게 온 응답', () => {
|
||||
expect(store.vacation).toEqual(saved)
|
||||
expect(store.status).toBe('ready')
|
||||
})
|
||||
|
||||
it('엔진에서 규칙이 꺼져 있으면(isActive: false) 그대로 두고, isActive 가 없는 옛 서버 응답은 동작하는 것으로 본다', async () => {
|
||||
let reply: MailVacationState | MailVacation = { ...ON, isActive: false }
|
||||
requests = installMockApi(() => ok(reply))
|
||||
const store = useMailVacationStore()
|
||||
|
||||
await store.load()
|
||||
expect(store.vacation?.isActive).toBe(false)
|
||||
|
||||
// 7단계 앞의 서버 — isActive 가 없다
|
||||
reply = BODY
|
||||
await store.load()
|
||||
expect(store.vacation).toEqual({ ...BODY, isActive: true })
|
||||
})
|
||||
|
||||
it('저장 본문에는 isActive 를 넣지 않는다 — 받은 값(isActive: false)을 그대로 넘겨도 다섯 칸만, 응답의 isActive 로 맞춘다', async () => {
|
||||
requests = installMockApi((request) => (request.method === 'PUT' ? ok({ ...ON, isActive: true }) : ok({ ...ON, isActive: false })))
|
||||
const store = useMailVacationStore()
|
||||
const loaded = await store.load()
|
||||
expect(loaded?.isActive).toBe(false)
|
||||
|
||||
await store.save(loaded as MailVacationState)
|
||||
|
||||
expect(requests[1]?.data).toEqual(BODY)
|
||||
expect(store.vacation?.isActive).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,28 +3,30 @@ import { defineStore } from 'pinia'
|
||||
|
||||
import { toApiError, type ApiError } from '@/composables/useApi'
|
||||
import { useMail, type MailRequestOptions } from '@/composables/useMail'
|
||||
import type { MailVacation } from '@/shared/types/mail'
|
||||
import type { MailVacation, MailVacationState } from '@/shared/types/mail'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import type { LoadStatus } from '@/stores/mail.store'
|
||||
|
||||
// 부재중 자동 응답 스토어 — 계약 2절 "부재중 자동 응답" (사용자마다 하나, 엔진의 VacationResponse)
|
||||
// 부재중 자동 응답 스토어 — 계약 2절 "부재중 자동 응답" (사용자마다 하나 — 6단계부터 메일 필터와 같은 엔진 규칙(Sieve 스크립트)에 둔다)
|
||||
// - 메일 화면은 들어올 때 한 번 받는다(load) — 켜져 있으면 메일함 패널 아래(사용량 위)에 안내를 보인다. 못 받으면 알리지 않고 지난 값을 둔다
|
||||
// - 설정 화면은 들어올 때마다 새로 받는다(load) — 다른 창 · 메일 프로그램에서 바꿨을 수 있다. 오류는 화면이 직접 그린다
|
||||
// - 저장(save)한 값이 곧 패널 안내의 값이다
|
||||
// - 저장(save)한 값이 곧 패널 안내의 값이다. 응답의 isActive(7단계 — 엔진에서 지금 동작하는지)도 함께 둔다 — 설정 화면의
|
||||
// "켜 둔 자동 응답이 지금은 동작하지 않습니다" 줄은 저장한 값이 켜짐이고 isActive 가 false 일 때다(저장하면 응답의 값으로 내린다).
|
||||
// PUT 본문에는 넣지 않는다 (useMail.saveVacation 이 다섯 칸만 보낸다)
|
||||
// - 세션이 바뀐 뒤(로그아웃 · 다른 계정 — reset) 늦게 온 응답은 쓰지 않는다: 불러오기는 순번(seq — reset 이 올린다),
|
||||
// 저장은 세션 세대(authStore.generation — 그사이 불러오기가 있어도 저장한 값이 더 새롭다)로 가린다
|
||||
export const useMailVacationStore = defineStore('mail-vacation', () => {
|
||||
const mailApi = useMail()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const vacation = ref<MailVacation | null>(null)
|
||||
const vacation = ref<MailVacationState | null>(null)
|
||||
const status = ref<LoadStatus>('idle')
|
||||
const error = ref<ApiError | null>(null)
|
||||
|
||||
let seq = 0
|
||||
|
||||
/** 새로 받는다 — 실패하면 null (알림 없이 상태에 담는다 — 지난 값은 그대로 둔다) */
|
||||
async function load(): Promise<MailVacation | null> {
|
||||
async function load(): Promise<MailVacationState | null> {
|
||||
seq += 1
|
||||
const current = seq
|
||||
status.value = 'loading'
|
||||
@@ -47,7 +49,7 @@ export const useMailVacationStore = defineStore('mail-vacation', () => {
|
||||
* 저장 — 실패하면 예외가 그대로 올라간다 (칸 안 안내는 화면이 한다).
|
||||
* 기다리는 사이 세션이 바뀌었으면 결과를 돌려주기만 하고 스토어에는 쓰지 않는다 (다음 사용자의 안내가 되지 않게)
|
||||
*/
|
||||
async function save(payload: MailVacation, options?: MailRequestOptions): Promise<MailVacation> {
|
||||
async function save(payload: MailVacation, options?: MailRequestOptions): Promise<MailVacationState> {
|
||||
const session = authStore.generation
|
||||
const result = await mailApi.saveVacation(payload, options)
|
||||
if (authStore.generation !== session) return result
|
||||
|
||||
@@ -189,6 +189,35 @@ describe('toast.store', () => {
|
||||
expect(messages()).toEqual([])
|
||||
})
|
||||
|
||||
it('결과에 단추 · 링크를 붙일 수 있다 — 단추가 있는 알림처럼 10초 · 단추는 띄운 세션에서만 움직인다 (필터 적용의 [거래처 보기] · [다시 시도])', async () => {
|
||||
const USER = { email: 'ara.kim@arasystem.test', name: '김아라' }
|
||||
useAuthStore().startSession(USER)
|
||||
const toast = useToast()
|
||||
const store = useToastStore()
|
||||
const run = vi.fn()
|
||||
|
||||
toast.progress('적용하는 중').succeed('37통에 적용했습니다.', { label: '거래처 보기', to: '/mail/vendor' })
|
||||
toast.progress('적용하는 중').fail('멈췄습니다.', { label: '다시 시도', run })
|
||||
expect(store.toasts.map((item) => [item.kind, item.message, item.action?.label])).toEqual([
|
||||
['success', '37통에 적용했습니다.', '거래처 보기'],
|
||||
['error', '멈췄습니다.', '다시 시도'],
|
||||
])
|
||||
|
||||
vi.advanceTimersByTime(TOAST_DURATION_MS.error)
|
||||
expect(messages()).toEqual(['37통에 적용했습니다.', '멈췄습니다.'])
|
||||
vi.advanceTimersByTime(TOAST_ACTION_DURATION_MS - TOAST_DURATION_MS.error)
|
||||
expect(messages()).toEqual([])
|
||||
|
||||
toast.progress('적용하는 중').fail('멈췄습니다.', { label: '다시 시도', run })
|
||||
const action = store.toasts[0]?.action
|
||||
if (!action || !('run' in action)) throw new Error('단추가 없다')
|
||||
useAuthStore().clearAuth()
|
||||
useAuthStore().startSession(USER)
|
||||
await nextTick()
|
||||
action.run()
|
||||
expect(run).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('useToast 의 진행 알림 — 끝나면 성공 · 실패로 바꾸고, 그사이 닫혔으면 결과를 새로 띄운다', () => {
|
||||
const toast = useToast()
|
||||
const store = useToastStore()
|
||||
|
||||
Reference in New Issue
Block a user