|
| 1 | +import { mount } from "@vue/test-utils"; |
| 2 | +import { createPinia, setActivePinia } from "pinia"; |
| 3 | +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; |
| 4 | +import { defineComponent, reactive } from "vue"; |
| 5 | +import type { ScanStats } from "@/__generated__"; |
| 6 | +import taskApi from "@/services/api/task"; |
| 7 | +import storeScanning from "@/stores/scanning"; |
| 8 | +import { installScanLifecycle } from "./index"; |
| 9 | + |
| 10 | +// Minimal socket stand-in: records handlers so tests can fire events, and |
| 11 | +// stays "connected" so `useSocketEvent` never tries to dial out. |
| 12 | +const handlers = new Map<string, (payload: unknown) => void>(); |
| 13 | +vi.mock("@/services/socket", () => ({ |
| 14 | + default: { |
| 15 | + connected: true, |
| 16 | + connect: vi.fn(), |
| 17 | + on: (event: string, handler: (payload: unknown) => void) => { |
| 18 | + handlers.set(event, handler); |
| 19 | + }, |
| 20 | + off: vi.fn(), |
| 21 | + }, |
| 22 | +})); |
| 23 | + |
| 24 | +vi.mock("@/services/api/task", () => ({ |
| 25 | + default: { getTaskStatus: vi.fn() }, |
| 26 | +})); |
| 27 | + |
| 28 | +// Platform lookups are incidental here: `getPlatform` for platforms the |
| 29 | +// scanning store doesn't know yet, `getPlatforms` for the post-scan reconcile. |
| 30 | +vi.mock("@/services/api/platform", () => ({ |
| 31 | + default: { |
| 32 | + getPlatform: vi.fn(() => Promise.resolve({ data: { id: 1 } })), |
| 33 | + getPlatforms: vi.fn(() => Promise.resolve({ data: [] })), |
| 34 | + }, |
| 35 | +})); |
| 36 | + |
| 37 | +// Reactive so the composable's `watch` on `authStore.user` fires when the |
| 38 | +// user arrives after install, which is the real flow: AppLayout installs |
| 39 | +// while /users/me is still in flight. |
| 40 | +const authState = reactive({ |
| 41 | + user: { id: 1, oauth_scopes: ["tasks.run"] } as { |
| 42 | + id: number; |
| 43 | + oauth_scopes: string[]; |
| 44 | + } | null, |
| 45 | +}); |
| 46 | +vi.mock("@/stores/auth", () => ({ |
| 47 | + default: () => authState, |
| 48 | +})); |
| 49 | + |
| 50 | +const getTaskStatus = vi.mocked(taskApi.getTaskStatus); |
| 51 | + |
| 52 | +/** Drain pending microtasks so the reconcile's promise chain has settled. */ |
| 53 | +const flushPromises = () => new Promise((resolve) => setTimeout(resolve, 0)); |
| 54 | + |
| 55 | +function makeStats(overrides: Partial<ScanStats> = {}): ScanStats { |
| 56 | + return { |
| 57 | + total_platforms: 0, |
| 58 | + total_roms: 0, |
| 59 | + scanned_platforms: 0, |
| 60 | + new_platforms: 0, |
| 61 | + identified_platforms: 0, |
| 62 | + scanned_roms: 0, |
| 63 | + new_roms: 0, |
| 64 | + identified_roms: 0, |
| 65 | + scanned_firmware: 0, |
| 66 | + new_firmware: 0, |
| 67 | + ...overrides, |
| 68 | + }; |
| 69 | +} |
| 70 | + |
| 71 | +function runningScanTask(stats: ScanStats | null) { |
| 72 | + return { |
| 73 | + task_name: "scan_platforms", |
| 74 | + task_id: "job-1", |
| 75 | + status: "started", |
| 76 | + task_type: "scan", |
| 77 | + created_at: null, |
| 78 | + enqueued_at: null, |
| 79 | + started_at: null, |
| 80 | + ended_at: null, |
| 81 | + meta: { scan_stats: stats }, |
| 82 | + }; |
| 83 | +} |
| 84 | + |
| 85 | +// The lifecycle uses `inject` and `onScopeDispose`, so it needs a host |
| 86 | +// component instance. Tracked so `afterEach` can unmount it: the auth state |
| 87 | +// is reactive and shared, so a leaked host would keep watching it and |
| 88 | +// reconcile again during later tests. |
| 89 | +let host: ReturnType<typeof mount> | null = null; |
| 90 | + |
| 91 | +function install() { |
| 92 | + host = mount( |
| 93 | + defineComponent({ |
| 94 | + setup() { |
| 95 | + installScanLifecycle(); |
| 96 | + return () => null; |
| 97 | + }, |
| 98 | + }), |
| 99 | + ); |
| 100 | +} |
| 101 | + |
| 102 | +function fire(event: string, payload: unknown) { |
| 103 | + handlers.get(event)?.(payload); |
| 104 | +} |
| 105 | + |
| 106 | +describe("installScanLifecycle", () => { |
| 107 | + beforeEach(() => { |
| 108 | + setActivePinia(createPinia()); |
| 109 | + handlers.clear(); |
| 110 | + getTaskStatus.mockReset(); |
| 111 | + getTaskStatus.mockResolvedValue({ data: [] } as never); |
| 112 | + authState.user = { id: 1, oauth_scopes: ["tasks.run"] }; |
| 113 | + }); |
| 114 | + |
| 115 | + afterEach(() => { |
| 116 | + host?.unmount(); |
| 117 | + host = null; |
| 118 | + }); |
| 119 | + |
| 120 | + it("treats a stats event as proof a scan is running", () => { |
| 121 | + install(); |
| 122 | + const scanning = storeScanning(); |
| 123 | + expect(scanning.scanning).toBe(false); |
| 124 | + |
| 125 | + fire("scan:update_stats", makeStats({ scanned_roms: 12 })); |
| 126 | + |
| 127 | + expect(scanning.scanning).toBe(true); |
| 128 | + expect(scanning.scanStats.scanned_roms).toBe(12); |
| 129 | + }); |
| 130 | + |
| 131 | + it("reconciles with a running scan job on install", async () => { |
| 132 | + getTaskStatus.mockResolvedValue({ |
| 133 | + data: [runningScanTask(makeStats({ scanned_roms: 40, total_roms: 100 }))], |
| 134 | + } as never); |
| 135 | + |
| 136 | + install(); |
| 137 | + await flushPromises(); |
| 138 | + |
| 139 | + const scanning = storeScanning(); |
| 140 | + expect(scanning.scanning).toBe(true); |
| 141 | + expect(scanning.scanStats.scanned_roms).toBe(40); |
| 142 | + expect(scanning.scanStats.total_roms).toBe(100); |
| 143 | + }); |
| 144 | + |
| 145 | + it("reconciles once the user arrives after install", async () => { |
| 146 | + // The real flow: AppLayout installs while /users/me is still in flight, |
| 147 | + // so `user` is null at install and the watch has to catch the arrival. |
| 148 | + authState.user = null; |
| 149 | + getTaskStatus.mockResolvedValue({ |
| 150 | + data: [runningScanTask(makeStats({ scanned_roms: 7 }))], |
| 151 | + } as never); |
| 152 | + |
| 153 | + install(); |
| 154 | + await flushPromises(); |
| 155 | + expect(getTaskStatus).not.toHaveBeenCalled(); |
| 156 | + |
| 157 | + authState.user = { id: 1, oauth_scopes: ["tasks.run"] }; |
| 158 | + await flushPromises(); |
| 159 | + |
| 160 | + expect(getTaskStatus).toHaveBeenCalledTimes(1); |
| 161 | + const scanning = storeScanning(); |
| 162 | + expect(scanning.scanning).toBe(true); |
| 163 | + expect(scanning.scanStats.scanned_roms).toBe(7); |
| 164 | + }); |
| 165 | + |
| 166 | + it("stays idle when no scan job is running", async () => { |
| 167 | + getTaskStatus.mockResolvedValue({ |
| 168 | + data: [{ ...runningScanTask(makeStats()), status: "finished" }], |
| 169 | + } as never); |
| 170 | + |
| 171 | + install(); |
| 172 | + await flushPromises(); |
| 173 | + |
| 174 | + expect(storeScanning().scanning).toBe(false); |
| 175 | + }); |
| 176 | + |
| 177 | + it("skips the reconcile without the tasks.run scope", async () => { |
| 178 | + authState.user = { id: 1, oauth_scopes: ["platforms.write"] }; |
| 179 | + |
| 180 | + install(); |
| 181 | + await flushPromises(); |
| 182 | + |
| 183 | + expect(getTaskStatus).not.toHaveBeenCalled(); |
| 184 | + expect(storeScanning().scanning).toBe(false); |
| 185 | + }); |
| 186 | + |
| 187 | + it("does not resurrect a scan that ended while the reconcile was in flight", async () => { |
| 188 | + let resolveStatus!: (value: unknown) => void; |
| 189 | + getTaskStatus.mockReturnValue( |
| 190 | + new Promise((resolve) => { |
| 191 | + resolveStatus = resolve; |
| 192 | + }) as never, |
| 193 | + ); |
| 194 | + |
| 195 | + install(); |
| 196 | + fire("scan:done", makeStats({ scanned_roms: 100 })); |
| 197 | + resolveStatus({ data: [runningScanTask(makeStats({ scanned_roms: 40 }))] }); |
| 198 | + await flushPromises(); |
| 199 | + |
| 200 | + const scanning = storeScanning(); |
| 201 | + expect(scanning.scanning).toBe(false); |
| 202 | + expect(scanning.scanStats.scanned_roms).toBe(100); |
| 203 | + }); |
| 204 | + |
| 205 | + it("lets live stats win over the job's snapshot", async () => { |
| 206 | + let resolveStatus!: (value: unknown) => void; |
| 207 | + getTaskStatus.mockReturnValue( |
| 208 | + new Promise((resolve) => { |
| 209 | + resolveStatus = resolve; |
| 210 | + }) as never, |
| 211 | + ); |
| 212 | + |
| 213 | + install(); |
| 214 | + fire("scan:update_stats", makeStats({ scanned_roms: 90 })); |
| 215 | + resolveStatus({ data: [runningScanTask(makeStats({ scanned_roms: 40 }))] }); |
| 216 | + await flushPromises(); |
| 217 | + |
| 218 | + expect(storeScanning().scanStats.scanned_roms).toBe(90); |
| 219 | + }); |
| 220 | +}); |
0 commit comments