d3ro-voice/apps/desktop/tests/unit/stt-model-download-redteam-r3-24.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

129 lines
5 KiB
TypeScript

// tests/unit/stt-model-download-redteam-r3-24.test.ts
// STT 탭 모델 다운로드가 실패/취소로 끝나도 '다운로드 중 (x%)' 에 멈추지 않는지 회귀 검증.
import { describe, it, expect } from 'vitest'
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { ErrorCode, ipcError, ipcSuccess, type IPCResult } from '@d3ro/core/errors'
import {
classifyModelDownloadResult,
releaseIfCurrent,
runModelDownload,
type ModelDownloadView,
} from '../../src/renderer/components/stt/modelDownload'
/** STTTab 의 downloadingModelId / downloadError 상태를 흉내 내는 가짜 view */
function fakeView(): ModelDownloadView & {
downloadingModelId: string | null
error: { modelId: string; message: string } | null
events: string[]
} {
const state = {
downloadingModelId: null as string | null,
error: null as { modelId: string; message: string } | null,
events: [] as string[],
}
return Object.assign(state, {
started: (id: string) => {
state.events.push(`started:${id}`)
state.error = null
state.downloadingModelId = id
},
succeeded: (id: string) => {
state.events.push(`succeeded:${id}`)
state.downloadingModelId = releaseIfCurrent(state.downloadingModelId, id)
},
cancelled: (id: string) => {
state.events.push(`cancelled:${id}`)
state.downloadingModelId = releaseIfCurrent(state.downloadingModelId, id)
},
failed: (id: string, message: string) => {
state.events.push(`failed:${id}`)
state.downloadingModelId = releaseIfCurrent(state.downloadingModelId, id)
state.error = { modelId: id, message }
},
})
}
describe('runModelDownload', () => {
it('ipcError(사이드카 기동 실패 등)면 진행 표시를 풀고 오류 메시지를 노출한다', async () => {
const view = fakeView()
const outcome = await runModelDownload(
'large-v3-turbo',
{ download: async () => ipcError<void>(ErrorCode.STTModelDownloadFailed, '다운로드 시작 실패 (HTTP 500): boom') },
view,
)
expect(outcome).toEqual({ kind: 'failed', message: '다운로드 시작 실패 (HTTP 500): boom' })
expect(view.downloadingModelId).toBeNull()
expect(view.error).toEqual({ modelId: 'large-v3-turbo', message: '다운로드 시작 실패 (HTTP 500): boom' })
})
it('취소 결과는 오류 없이 진행 표시만 푼다', async () => {
const view = fakeView()
const outcome = await runModelDownload(
'small',
{ download: async () => ipcError<void>(ErrorCode.STTModelDownloadCancelled, '모델 다운로드 취소: small') },
view,
)
expect(outcome.kind).toBe('cancelled')
expect(view.downloadingModelId).toBeNull()
expect(view.error).toBeNull()
})
it('invoke 자체가 reject 되어도 진행 표시를 푼다', async () => {
const view = fakeView()
const outcome = await runModelDownload(
'small',
{ download: async () => { throw new Error('ipc channel closed') } },
view,
)
expect(outcome).toEqual({ kind: 'failed', message: 'ipc channel closed' })
expect(view.downloadingModelId).toBeNull()
})
it('성공하면 succeeded 로 끝나고 오류가 없다', async () => {
const view = fakeView()
const outcome = await runModelDownload('small', { download: async () => ipcSuccess(undefined) }, view)
expect(outcome.kind).toBe('succeeded')
expect(view.events).toEqual(['started:small', 'succeeded:small'])
expect(view.downloadingModelId).toBeNull()
expect(view.error).toBeNull()
})
it('늦게 도착한 A 실패가 그 사이 시작한 B 의 진행 표시를 지우지 않는다', async () => {
const view = fakeView()
let failA: (r: IPCResult<void>) => void = () => undefined
const pendingA = runModelDownload(
'A',
{ download: () => new Promise<IPCResult<void>>((resolve) => { failA = resolve }) },
view,
)
view.started('B')
failA(ipcError<void>(ErrorCode.STTModelDownloadFailed, 'A failed'))
await pendingA
expect(view.downloadingModelId).toBe('B')
})
})
describe('classifyModelDownloadResult', () => {
it('실패 코드별 결말', () => {
expect(classifyModelDownloadResult(ipcSuccess(undefined))).toEqual({ kind: 'succeeded' })
expect(classifyModelDownloadResult(ipcError(ErrorCode.STTModelDownloadCancelled, 'x'))).toEqual({ kind: 'cancelled' })
expect(classifyModelDownloadResult(ipcError(ErrorCode.STTModelDownloadFailed, 'y'))).toEqual({
kind: 'failed',
message: 'y',
})
expect(classifyModelDownloadResult({ thrown: 'raw' })).toEqual({ kind: 'failed', message: 'raw' })
})
})
describe('STTTab wiring', () => {
it('handleDownloadModel 이 IPC 결과를 버리지 않고 runModelDownload 로 처리한다', () => {
const src = readFileSync(
join(__dirname, '../../src/renderer/components/STTTab.tsx'),
'utf8',
)
expect(src).toContain('await runModelDownload(')
expect(src).not.toMatch(/await window\.electronAPI\.stt\.downloadModel\(\{ modelId \}\)\s*\n\s*\}, \[\]\)/)
})
})