d3ro-voice/apps/desktop/tests/unit/error-recovery-redteam-r3-21.test.ts
Yun Chan ba9ef9741e fix: red-team round 3 hardening across desktop, mobile, core and server
Batch of red-team r3 fixes that were in the working tree before the
2026-09-28 design overhaul, committed as one unit with their tests.

- desktop main: STT timeouts and sidecar, voice recording store, sync
  (credentials, audio, knowledge reindex, push gates), runtime
  provisioner, update policy, AltGr keybindings, voice-command policy,
  dictionary file codec/limits, meeting transcript condensing and a
  local recording ledger so interrupted-session recovery only closes
  meetings this device recorded (a phone's live meeting is left alone).
- mobile: login CSRF via implicit token callbacks rejected, account
  deletion/retention, durable queue retention, knowledge realtime
  without unfiltered DELETE, meeting re-record failure paths, cloud STT
  client, preferences store/resync.
- core: text chunking splits long unbroken transcripts to fit, template
  field policy, dictionary limits, meeting markdown inline handling.
- server: payple webhook policy and cancellation order scope, meeting
  document generation quota, team RPC null-role guard, unified LLM
  quota in-flight accounting, knowledge chunk vector index, meeting
  re-record failure paths (migrations 20260929*).
- ci: portable/runtime feed gates, update-policy schema, Forgejo file
  delete and alias planning.

Four older tests are updated to the new contracts rather than the old
behavior: token-pair auth callbacks are rejected, knowledge realtime no
longer subscribes to DELETE, long transcript lines are split, and
meeting recovery requires the local recording ledger for empty rows.
2026-09-28 20:45:52 +09:00

145 lines
6.7 KiB
TypeScript

import { describe, expect, it } from 'vitest'
import { ErrorCode } from '@d3ro/core/errors'
import {
normalizeErrorCode,
offersForceRestart,
presentError,
settingsTabOf,
} from '../../src/renderer/navigation/error-recovery'
import { buildMeetingErrorInfo, forceRestartLabel } from '../../src/renderer/pages/meeting/meeting-error'
import { canDismissOnboarding } from '../../src/renderer/components/onboarding-dismiss'
// 회귀:
// 1) voice:error 101(STT 모델 없음)이 닫을 수 없는 온보딩 모달을 열어 메인 창을 가뒀다.
// 2) 회의 후처리 실패(882)가 '회의를 시작할 수 없습니다'로 보이고, 주 버튼이 새 녹음을 강제로 시작했다.
// 3) 회의 오류 분류가 ErrorCode 와 맞지 않았다(1400=충돌, 200-220=마이크).
describe('presentError — voice', () => {
it('101 은 온보딩이 아니라 설정 → STT 탭 딥링크로 복구한다', () => {
const p = presentError({ context: 'voice', code: ErrorCode.STTModelNotFound })
expect(p.category).toBe('sttModelMissing')
expect(p.messageKey).toBe('voice.error.modelMissing')
expect(p.recoveries).toEqual([{ kind: 'openSettings', tab: 'stt' }])
expect(settingsTabOf(p)).toBe('stt')
})
it.each([103, 130, 131, 132])('%i 은 엔진 오류 문구를 유지하고 복구 동작은 없다', (code) => {
const p = presentError({ context: 'voice', code })
expect(p.category).toBe('sttEngine')
expect(p.messageKey).toBe('voice.error.engine')
expect(p.recoveries).toEqual([])
})
it('300 경고는 llmSkipped 문구, 300 오류는 원문 그대로', () => {
expect(presentError({ context: 'voice', code: 300, severity: 'warning' }).messageKey).toBe('voice.warning.llmSkipped')
const asError = presentError({ context: 'voice', code: 300, severity: 'error' })
expect(asError.category).toBe('general')
expect(asError.messageKey).toBeNull()
})
it('모르는 코드는 원문을 쓰고 설정 딥링크가 없다', () => {
const p = presentError({ context: 'voice', code: 999 })
expect(p.messageKey).toBeNull()
expect(settingsTabOf(p)).toBeNull()
})
})
describe('presentError — meetingProcessing', () => {
it('882 는 후처리 실패이고 강제 재시작(새 녹음)을 절대 제시하지 않는다', () => {
const p = presentError({ context: 'meetingProcessing', code: ErrorCode.MeetingProcessingFailed, message: '후처리 실패: SQLITE_FULL' })
expect(p.category).toBe('processing')
expect(offersForceRestart(p)).toBe(false)
})
it('후처리 문구에 model/already 같은 키워드가 있어도 processing 이다', () => {
for (const message of ['후처리 실패: model busy', '후처리 실패: already locked', '후처리 실패: audio']) {
const p = presentError({ context: 'meetingProcessing', code: 882, message })
expect(p.category).toBe('processing')
expect(offersForceRestart(p)).toBe(false)
}
})
})
describe('presentError — meetingStart', () => {
it.each([ErrorCode.MeetingAlreadyRecording, ErrorCode.CaptionAlreadyActive])('%i 는 충돌 → 강제 재시작', (code) => {
const p = presentError({ context: 'meetingStart', code, message: 'x' })
expect(p.category).toBe('collision')
expect(p.recoveries).toEqual([{ kind: 'forceRestart' }])
})
it('1400 은 ErrorCode 가 아니므로 충돌로 보지 않는다', () => {
expect(presentError({ context: 'meetingStart', code: 1400, message: 'x' }).category).toBe('general')
})
it.each([101, 103, 110, 130, 132, 140])('STT 코드 %i 는 STT 설정 + 재시도', (code) => {
const p = presentError({ context: 'meetingStart', code, message: 'x' })
expect(p.category).toBe('stt')
expect(p.recoveries).toEqual([{ kind: 'openSettings', tab: 'stt' }, { kind: 'forceRestart' }])
})
it.each([400, 401, 410, 420, 431])('오디오 코드 %i 는 오디오 설정 + 재시도', (code) => {
const p = presentError({ context: 'meetingStart', code, message: 'x' })
expect(p.category).toBe('audio')
expect(settingsTabOf(p)).toBe('audio')
})
it.each([200, 210, 220])('TTS 코드 %i 는 마이크 오류가 아니다', (code) => {
const p = presentError({ context: 'meetingStart', code, message: 'x' })
expect(p.category).toBe('general')
expect(settingsTabOf(p)).toBeNull()
})
it('시작 경로에 882 가 와도 강제 재시작을 주지 않는다', () => {
expect(offersForceRestart(presentError({ context: 'meetingStart', code: 882 }))).toBe(false)
})
it('코드가 없는 catch 경로는 문구 키워드로 폴백한다', () => {
expect(presentError({ context: 'meetingStart', message: 'Caption already active' }).category).toBe('collision')
expect(presentError({ context: 'meetingStart', message: 'whisper sidecar died' }).category).toBe('stt')
expect(presentError({ context: 'meetingStart', message: 'mic not found' }).category).toBe('audio')
expect(presentError({ context: 'meetingStart', message: 'ipc timeout' }).category).toBe('general')
})
it('숫자 문자열 코드도 정규화한다', () => {
expect(normalizeErrorCode('880')).toBe(880)
expect(normalizeErrorCode('STT_UNAVAILABLE')).toBeNull()
expect(normalizeErrorCode(undefined)).toBeNull()
expect(presentError({ context: 'meetingStart', code: '880' }).category).toBe('collision')
})
})
describe('buildMeetingErrorInfo', () => {
it('후처리 실패는 시작 실패 제목이 아니고 복구 버튼이 없다', () => {
const info = buildMeetingErrorInfo('meetingProcessing', '후처리 실패: disk full', 882)
expect(info.title).not.toBe('회의를 시작할 수 없습니다')
expect(info.category).toBe('processing')
expect(info.recoveries).toEqual([])
expect(info.code).toBe(882)
expect(info.message).toBe('후처리 실패: disk full')
})
it('충돌 라벨과 일반 라벨이 다르다', () => {
expect(forceRestartLabel('collision')).not.toBe(forceRestartLabel('general'))
})
it('코드·문구가 없으면 카테고리 기본값을 쓴다', () => {
const info = buildMeetingErrorInfo('meetingStart')
expect(info.category).toBe('general')
expect(info.code).toBe('MEETING_START_ERROR')
expect(info.message.length).toBeGreaterThan(0)
})
})
describe('canDismissOnboarding', () => {
it('첫 실행(미완료)은 success 전까지 닫을 수 없다', () => {
expect(canDismissOnboarding('select_mode', false)).toBe(false)
expect(canDismissOnboarding('local_ollama_setup', false)).toBe(false)
expect(canDismissOnboarding('success', false)).toBe(true)
})
it('이미 온보딩을 마친 사용자는 어느 단계에서든 닫을 수 있다', () => {
for (const phase of ['select_mode', 'local_ollama_setup', 'online_auth', 'failed'] as const) {
expect(canDismissOnboarding(phase, true)).toBe(true)
}
})
})