|
| 1 | +import { createPinia, setActivePinia } from 'pinia' |
| 2 | +import { beforeEach, describe, expect, it, vi } from 'vitest' |
| 3 | + |
| 4 | +import type * as VueUse from '@vueuse/core' |
| 5 | + |
| 6 | +import type { AssetExportWsMessage } from '@/schemas/apiSchema' |
| 7 | +import { api } from '@/scripts/api' |
| 8 | +import type { TaskId } from '@/platform/tasks/services/taskService' |
| 9 | +import { useAssetExportStore } from '@/stores/assetExportStore' |
| 10 | + |
| 11 | +const { getExportDownloadUrl, getTask, toastAdd, intervalState } = vi.hoisted( |
| 12 | + () => ({ |
| 13 | + getExportDownloadUrl: vi.fn(), |
| 14 | + getTask: vi.fn(), |
| 15 | + toastAdd: vi.fn(), |
| 16 | + intervalState: { cb: null as null | (() => void) } |
| 17 | + }) |
| 18 | +) |
| 19 | + |
| 20 | +vi.mock('@vueuse/core', async (importOriginal) => ({ |
| 21 | + ...(await importOriginal<typeof VueUse>()), |
| 22 | + useIntervalFn: (cb: () => void) => { |
| 23 | + intervalState.cb = cb |
| 24 | + return { pause: vi.fn(), resume: vi.fn() } |
| 25 | + } |
| 26 | +})) |
| 27 | + |
| 28 | +vi.mock('@/scripts/api', () => ({ |
| 29 | + api: { addEventListener: vi.fn() } |
| 30 | +})) |
| 31 | + |
| 32 | +vi.mock('@/platform/assets/services/assetService', () => ({ |
| 33 | + assetService: { getExportDownloadUrl } |
| 34 | +})) |
| 35 | + |
| 36 | +vi.mock('@/platform/tasks/services/taskService', () => ({ |
| 37 | + taskService: { getTask } |
| 38 | +})) |
| 39 | + |
| 40 | +vi.mock('@/platform/updates/common/toastStore', () => ({ |
| 41 | + useToastStore: () => ({ add: toastAdd }) |
| 42 | +})) |
| 43 | + |
| 44 | +vi.mock('@/i18n', () => ({ |
| 45 | + t: (key: string) => key |
| 46 | +})) |
| 47 | + |
| 48 | +function wsMessage( |
| 49 | + over: Partial<AssetExportWsMessage> = {} |
| 50 | +): AssetExportWsMessage { |
| 51 | + return { |
| 52 | + task_id: 'task-1', |
| 53 | + export_name: 'export.zip', |
| 54 | + assets_total: 10, |
| 55 | + assets_attempted: 5, |
| 56 | + assets_failed: 0, |
| 57 | + bytes_total: 1000, |
| 58 | + bytes_processed: 500, |
| 59 | + progress: 0.5, |
| 60 | + status: 'running', |
| 61 | + ...over |
| 62 | + } |
| 63 | +} |
| 64 | + |
| 65 | +const taskId = (id: string) => id as TaskId |
| 66 | + |
| 67 | +/** |
| 68 | + * Build a store and an `emit` bound to the real `asset_export` listener the |
| 69 | + * store registers on `api`, so tests drive the state machine through its |
| 70 | + * actual entry point rather than a private method. |
| 71 | + */ |
| 72 | +function setup() { |
| 73 | + const store = useAssetExportStore() |
| 74 | + const entry = vi |
| 75 | + .mocked(api.addEventListener) |
| 76 | + .mock.calls.find((c) => c[0] === 'asset_export') |
| 77 | + const handler = entry![1] as (e: { detail: AssetExportWsMessage }) => void |
| 78 | + const emit = (msg: AssetExportWsMessage) => handler({ detail: msg }) |
| 79 | + // Run the polling tick that `useIntervalFn` would normally fire, and let its |
| 80 | + // async work settle. |
| 81 | + const runPoll = async () => { |
| 82 | + intervalState.cb?.() |
| 83 | + await new Promise((resolve) => setTimeout(resolve, 0)) |
| 84 | + } |
| 85 | + return { store, emit, runPoll } |
| 86 | +} |
| 87 | + |
| 88 | +const STALE_AGO_MS = 20_000 |
| 89 | + |
| 90 | +beforeEach(() => { |
| 91 | + setActivePinia(createPinia()) |
| 92 | + vi.mocked(api.addEventListener).mockClear() |
| 93 | + getExportDownloadUrl |
| 94 | + .mockReset() |
| 95 | + .mockResolvedValue({ url: 'https://example.com/export.zip' }) |
| 96 | + getTask.mockReset() |
| 97 | + toastAdd.mockReset() |
| 98 | +}) |
| 99 | + |
| 100 | +describe('assetExportStore', () => { |
| 101 | + it('tracks a new export as created and is idempotent', () => { |
| 102 | + const { store } = setup() |
| 103 | + |
| 104 | + store.trackExport(taskId('t1')) |
| 105 | + store.trackExport(taskId('t1')) |
| 106 | + |
| 107 | + expect(store.exportList).toHaveLength(1) |
| 108 | + expect(store.exportList[0].status).toBe('created') |
| 109 | + expect(store.hasExports).toBe(true) |
| 110 | + expect(store.hasActiveExports).toBe(true) |
| 111 | + }) |
| 112 | + |
| 113 | + it('separates active from finished exports by status', () => { |
| 114 | + const { store, emit } = setup() |
| 115 | + |
| 116 | + emit(wsMessage({ task_id: 'running', status: 'running' })) |
| 117 | + emit( |
| 118 | + wsMessage({ task_id: 'failed', status: 'failed', export_name: 'f.zip' }) |
| 119 | + ) |
| 120 | + |
| 121 | + expect(store.activeExports.map((e) => e.taskId)).toEqual(['running']) |
| 122 | + expect(store.finishedExports.map((e) => e.taskId)).toEqual(['failed']) |
| 123 | + }) |
| 124 | + |
| 125 | + it('updates an export from successive websocket messages', () => { |
| 126 | + const { store, emit } = setup() |
| 127 | + |
| 128 | + emit(wsMessage({ progress: 0.5, status: 'running' })) |
| 129 | + emit(wsMessage({ progress: 0.9, status: 'running' })) |
| 130 | + |
| 131 | + expect(store.exportList).toHaveLength(1) |
| 132 | + expect(store.exportList[0].progress).toBe(0.9) |
| 133 | + }) |
| 134 | + |
| 135 | + it('ignores updates for an export already completed and downloaded', async () => { |
| 136 | + const { store, emit } = setup() |
| 137 | + |
| 138 | + emit(wsMessage({ status: 'completed' })) |
| 139 | + await Promise.resolve() |
| 140 | + const triggeredCalls = getExportDownloadUrl.mock.calls.length |
| 141 | + |
| 142 | + // A late 'running' message must not revive a completed+downloaded export |
| 143 | + emit(wsMessage({ status: 'running', progress: 0.1 })) |
| 144 | + |
| 145 | + expect(store.exportList[0].status).toBe('completed') |
| 146 | + expect(getExportDownloadUrl).toHaveBeenCalledTimes(triggeredCalls) |
| 147 | + }) |
| 148 | + |
| 149 | + it('falls back to the prior export name when a message omits it', async () => { |
| 150 | + const { store, emit } = setup() |
| 151 | + |
| 152 | + emit(wsMessage({ status: 'running', progress: 0.4 })) |
| 153 | + emit( |
| 154 | + wsMessage({ status: 'running', export_name: undefined, progress: 0.6 }) |
| 155 | + ) |
| 156 | + |
| 157 | + expect(store.exportList[0].exportName).toBe('export.zip') |
| 158 | + }) |
| 159 | + |
| 160 | + it('falls back to a blank export name when no message has named it', () => { |
| 161 | + const { store, emit } = setup() |
| 162 | + |
| 163 | + emit(wsMessage({ export_name: undefined, status: 'running' })) |
| 164 | + |
| 165 | + expect(store.exportList[0].exportName).toBe('') |
| 166 | + }) |
| 167 | + |
| 168 | + it('triggers a download for a named export and clears prior errors', async () => { |
| 169 | + const { store, emit } = setup() |
| 170 | + emit(wsMessage({ status: 'running' })) |
| 171 | + const [exp] = store.exportList |
| 172 | + |
| 173 | + await store.triggerDownload(exp) |
| 174 | + |
| 175 | + expect(getExportDownloadUrl).toHaveBeenCalledWith('export.zip') |
| 176 | + expect(exp.downloadTriggered).toBe(true) |
| 177 | + expect(exp.downloadError).toBeUndefined() |
| 178 | + }) |
| 179 | + |
| 180 | + it('does not re-trigger a download unless forced', async () => { |
| 181 | + const { store, emit } = setup() |
| 182 | + emit(wsMessage({ status: 'running' })) |
| 183 | + const [exp] = store.exportList |
| 184 | + exp.downloadTriggered = true |
| 185 | + |
| 186 | + await store.triggerDownload(exp) |
| 187 | + expect(getExportDownloadUrl).not.toHaveBeenCalled() |
| 188 | + |
| 189 | + await store.triggerDownload(exp, true) |
| 190 | + expect(getExportDownloadUrl).toHaveBeenCalledTimes(1) |
| 191 | + }) |
| 192 | + |
| 193 | + it('records a download error and surfaces a toast on failure', async () => { |
| 194 | + getExportDownloadUrl.mockRejectedValueOnce(new Error('network down')) |
| 195 | + const { store, emit } = setup() |
| 196 | + emit(wsMessage({ status: 'running' })) |
| 197 | + const [exp] = store.exportList |
| 198 | + |
| 199 | + await store.triggerDownload(exp) |
| 200 | + |
| 201 | + expect(exp.downloadError).toBe('network down') |
| 202 | + expect(exp.downloadTriggered).toBe(false) |
| 203 | + expect(toastAdd).toHaveBeenCalledWith( |
| 204 | + expect.objectContaining({ severity: 'error' }) |
| 205 | + ) |
| 206 | + }) |
| 207 | + |
| 208 | + it('records a string download error', async () => { |
| 209 | + getExportDownloadUrl.mockRejectedValueOnce('offline') |
| 210 | + const { store, emit } = setup() |
| 211 | + emit(wsMessage({ status: 'running' })) |
| 212 | + const [exp] = store.exportList |
| 213 | + |
| 214 | + await store.triggerDownload(exp) |
| 215 | + |
| 216 | + expect(exp.downloadError).toBe('offline') |
| 217 | + }) |
| 218 | + |
| 219 | + it('clears finished exports while keeping active ones', () => { |
| 220 | + const { store, emit } = setup() |
| 221 | + emit(wsMessage({ task_id: 'a', status: 'running' })) |
| 222 | + emit(wsMessage({ task_id: 'b', status: 'failed', export_name: 'b.zip' })) |
| 223 | + |
| 224 | + store.clearFinishedExports() |
| 225 | + |
| 226 | + expect(store.exportList.map((e) => e.taskId)).toEqual(['a']) |
| 227 | + }) |
| 228 | + |
| 229 | + it('does not poll when no active export is stale', async () => { |
| 230 | + const { emit, runPoll } = setup() |
| 231 | + emit(wsMessage({ status: 'running' })) |
| 232 | + |
| 233 | + await runPoll() |
| 234 | + |
| 235 | + expect(getTask).not.toHaveBeenCalled() |
| 236 | + }) |
| 237 | + |
| 238 | + it('reconciles a stale export from the task service result', async () => { |
| 239 | + const { store, emit, runPoll } = setup() |
| 240 | + emit(wsMessage({ status: 'running' })) |
| 241 | + store.exportList[0].lastUpdate = Date.now() - STALE_AGO_MS |
| 242 | + getTask.mockResolvedValue({ |
| 243 | + status: 'completed', |
| 244 | + result: { export_name: 'reconciled.zip', assets_total: 10 } |
| 245 | + }) |
| 246 | + |
| 247 | + await runPoll() |
| 248 | + |
| 249 | + expect(getTask).toHaveBeenCalledWith('task-1') |
| 250 | + expect(store.exportList[0].status).toBe('completed') |
| 251 | + expect(store.exportList[0].exportName).toBe('reconciled.zip') |
| 252 | + }) |
| 253 | + |
| 254 | + it('leaves a stale export active when the task is still running', async () => { |
| 255 | + const { store, emit, runPoll } = setup() |
| 256 | + emit(wsMessage({ status: 'running' })) |
| 257 | + store.exportList[0].lastUpdate = Date.now() - STALE_AGO_MS |
| 258 | + getTask.mockResolvedValue({ status: 'running' }) |
| 259 | + |
| 260 | + await runPoll() |
| 261 | + |
| 262 | + expect(store.exportList[0].status).toBe('running') |
| 263 | + }) |
| 264 | + |
| 265 | + it('reconciles a stale failed export using existing counters', async () => { |
| 266 | + const { store, emit, runPoll } = setup() |
| 267 | + emit( |
| 268 | + wsMessage({ |
| 269 | + assets_attempted: 4, |
| 270 | + assets_failed: 1, |
| 271 | + status: 'running' |
| 272 | + }) |
| 273 | + ) |
| 274 | + store.exportList[0].lastUpdate = Date.now() - STALE_AGO_MS |
| 275 | + getTask.mockResolvedValue({ |
| 276 | + status: 'failed', |
| 277 | + result: { error: 'failed in result' } |
| 278 | + }) |
| 279 | + |
| 280 | + await runPoll() |
| 281 | + |
| 282 | + expect(store.exportList[0]).toMatchObject({ |
| 283 | + assetsAttempted: 4, |
| 284 | + assetsFailed: 1, |
| 285 | + error: 'failed in result', |
| 286 | + status: 'failed' |
| 287 | + }) |
| 288 | + }) |
| 289 | + |
| 290 | + it('leaves a stale export untouched when the task lookup fails', async () => { |
| 291 | + const { store, emit, runPoll } = setup() |
| 292 | + emit(wsMessage({ status: 'running' })) |
| 293 | + store.exportList[0].lastUpdate = Date.now() - STALE_AGO_MS |
| 294 | + getTask.mockRejectedValue(new Error('task not found')) |
| 295 | + |
| 296 | + await runPoll() |
| 297 | + |
| 298 | + expect(store.exportList[0].status).toBe('running') |
| 299 | + }) |
| 300 | +}) |
0 commit comments