From 81282fbd8a3b798d87a2fb46b20ced822dee28d7 Mon Sep 17 00:00:00 2001 From: weeihan Date: Sun, 12 Jul 2026 10:40:38 +0800 Subject: [PATCH] refactor: make generate-qr testable with injectable deps + entry guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extracts buildQrUrl and generateZoneQrs with injectable I/O deps and adds tests/scripts/generate-qr.test.ts (20 tests). Refactor + tests originated from a concurrent working session; this commit adds on top: - import.meta entry guard — the unconditional top-level main() executed on test import and wrote (sometimes truncated) PNGs into public/qr - typed vi.fn generics in makeDeps so strict tsc passes Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01CPf5Rc8QPx6V8KLEEgfKEQ --- scripts/generate-qr.ts | 67 +++++++--- tests/scripts/generate-qr.test.ts | 196 ++++++++++++++++++++++++++++++ 2 files changed, 248 insertions(+), 15 deletions(-) create mode 100644 tests/scripts/generate-qr.test.ts diff --git a/scripts/generate-qr.ts b/scripts/generate-qr.ts index 558778a..98ad509 100644 --- a/scripts/generate-qr.ts +++ b/scripts/generate-qr.ts @@ -2,37 +2,74 @@ import QRCode from 'qrcode' import { mkdirSync } from 'fs' import path from 'path' +import { pathToFileURL } from 'url' -const BASE_URL = process.env.NEXT_PUBLIC_APP_URL ?? 'http://localhost:3000' +export const DEFAULT_BASE_URL = 'http://localhost:3000' // Matches seeded zone qr_code_token values in 20260709000008_seed.sql -const ZONES = [ +export const ZONES = [ { token: 'scw1-dock-a-qr-2026', name: 'SCW1 — Dock A' }, { token: 'scw1-cold-storage-qr-2026', name: 'SCW1 — Cold Storage' }, { token: 'scw1-loading-bay-qr-2026', name: 'SCW1 — Loading Bay' }, ] -async function main() { - const outDir = path.join(process.cwd(), 'public', 'qr') - mkdirSync(outDir, { recursive: true }) +export interface Zone { + token: string + name: string +} - for (const zone of ZONES) { - const url = `${BASE_URL}/report?zone=${zone.token}` +/** Pure helper: builds the URL encoded in each QR code. */ +export function buildQrUrl(baseUrl: string, token: string): string { + return `${baseUrl}/report?zone=${token}` +} + +export interface GenerateDeps { + toFile: (outputPath: string, url: string, opts: object) => Promise + mkdirSync: (dir: string, opts: { recursive: boolean }) => void + log: (...args: unknown[]) => void +} + +/** Core logic, with injectable deps so tests never touch real I/O. */ +export async function generateZoneQrs( + zones: Zone[], + outDir: string, + baseUrl: string, + deps: GenerateDeps, +): Promise { + deps.mkdirSync(outDir, { recursive: true }) + + for (const zone of zones) { + const url = buildQrUrl(baseUrl, zone.token) const outputPath = path.join(outDir, `${zone.token}.png`) - await QRCode.toFile(outputPath, url, { + await deps.toFile(outputPath, url, { width: 400, margin: 2, color: { dark: '#000000', light: '#FFFFFF' }, }) - console.log(`✓ ${zone.name}`) - console.log(` → ${url}`) - console.log(` → ${outputPath}`) + deps.log(`✓ ${zone.name}`) + deps.log(` → ${url}`) + deps.log(` → ${outputPath}`) } } -main().catch((err) => { - console.error(err) - process.exit(1) -}) +async function main() { + const baseUrl = process.env.NEXT_PUBLIC_APP_URL ?? DEFAULT_BASE_URL + const outDir = path.join(process.cwd(), 'public', 'qr') + + await generateZoneQrs(ZONES, outDir, baseUrl, { + toFile: (outputPath, url, opts) => QRCode.toFile(outputPath, url, opts as Parameters[2]), + mkdirSync, + log: console.log, + }) +} + +// Only run when executed directly (npm run generate-qr) — importing this +// module (e.g. from tests) must not touch the filesystem. +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main().catch((err) => { + console.error(err) + process.exit(1) + }) +} diff --git a/tests/scripts/generate-qr.test.ts b/tests/scripts/generate-qr.test.ts new file mode 100644 index 0000000..8f78b73 --- /dev/null +++ b/tests/scripts/generate-qr.test.ts @@ -0,0 +1,196 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import path from 'path' +import { + buildQrUrl, + generateZoneQrs, + ZONES, + DEFAULT_BASE_URL, + type Zone, + type GenerateDeps, +} from '@/scripts/generate-qr' + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeDeps() { + return { + toFile: vi.fn().mockResolvedValue(undefined), + mkdirSync: vi.fn(), + log: vi.fn(), + } +} + +const OUT_DIR = '/fake/public/qr' + +// --------------------------------------------------------------------------- +// buildQrUrl — pure function +// --------------------------------------------------------------------------- + +describe('buildQrUrl', () => { + it('combines baseUrl and token into a /report?zone= URL', () => { + expect(buildQrUrl('https://example.com', 'scw1-dock-a-qr-2026')).toBe( + 'https://example.com/report?zone=scw1-dock-a-qr-2026', + ) + }) + + it('works with localhost base URL', () => { + expect(buildQrUrl('http://localhost:3000', 'my-token')).toBe( + 'http://localhost:3000/report?zone=my-token', + ) + }) + + it('does not add a double slash between baseUrl and /report', () => { + const url = buildQrUrl('https://ims.example.com', 'token-xyz') + // Strip the protocol so the only // would be an accidental duplicate slash + expect(url.replace('https://', '')).not.toContain('//') + }) + + it('handles token with hyphens and digits', () => { + const token = 'scw1-cold-storage-qr-2026' + expect(buildQrUrl('https://example.com', token)).toContain(token) + }) +}) + +// --------------------------------------------------------------------------- +// ZONES constant — shape invariants +// --------------------------------------------------------------------------- + +describe('ZONES', () => { + it('contains exactly 3 zones', () => { + expect(ZONES).toHaveLength(3) + }) + + it('every zone has a non-empty token and name', () => { + for (const zone of ZONES) { + expect(zone.token).toBeTruthy() + expect(zone.name).toBeTruthy() + } + }) + + it('tokens match the seeded qr_code_token values from the migration', () => { + const tokens = ZONES.map((z) => z.token) + expect(tokens).toContain('scw1-dock-a-qr-2026') + expect(tokens).toContain('scw1-cold-storage-qr-2026') + expect(tokens).toContain('scw1-loading-bay-qr-2026') + }) + + it('all tokens are unique', () => { + const tokens = ZONES.map((z) => z.token) + expect(new Set(tokens).size).toBe(tokens.length) + }) +}) + +// --------------------------------------------------------------------------- +// DEFAULT_BASE_URL +// --------------------------------------------------------------------------- + +describe('DEFAULT_BASE_URL', () => { + it('falls back to localhost:3000', () => { + expect(DEFAULT_BASE_URL).toBe('http://localhost:3000') + }) +}) + +// --------------------------------------------------------------------------- +// generateZoneQrs — I/O behaviour +// --------------------------------------------------------------------------- + +describe('generateZoneQrs', () => { + let deps: ReturnType + + beforeEach(() => { + deps = makeDeps() + }) + + it('creates the output directory with recursive: true', async () => { + await generateZoneQrs(ZONES, OUT_DIR, DEFAULT_BASE_URL, deps) + expect(deps.mkdirSync).toHaveBeenCalledWith(OUT_DIR, { recursive: true }) + }) + + it('calls toFile once per zone', async () => { + await generateZoneQrs(ZONES, OUT_DIR, DEFAULT_BASE_URL, deps) + expect(deps.toFile).toHaveBeenCalledTimes(ZONES.length) + }) + + it('passes the correct URL to toFile for each zone', async () => { + await generateZoneQrs(ZONES, OUT_DIR, DEFAULT_BASE_URL, deps) + for (const zone of ZONES) { + const expectedUrl = buildQrUrl(DEFAULT_BASE_URL, zone.token) + expect(deps.toFile).toHaveBeenCalledWith( + expect.any(String), + expectedUrl, + expect.any(Object), + ) + } + }) + + it('writes each PNG to /.png', async () => { + await generateZoneQrs(ZONES, OUT_DIR, DEFAULT_BASE_URL, deps) + for (const zone of ZONES) { + const expectedPath = path.join(OUT_DIR, `${zone.token}.png`) + expect(deps.toFile).toHaveBeenCalledWith( + expectedPath, + expect.any(String), + expect.any(Object), + ) + } + }) + + it('passes the expected QRCode options (width, margin, color)', async () => { + await generateZoneQrs(ZONES, OUT_DIR, DEFAULT_BASE_URL, deps) + const [, , opts] = deps.toFile.mock.calls[0] + expect(opts).toMatchObject({ + width: 400, + margin: 2, + color: { dark: '#000000', light: '#FFFFFF' }, + }) + }) + + it('uses the supplied baseUrl instead of the default', async () => { + const customBase = 'https://ims.setiacorp.com' + await generateZoneQrs(ZONES, OUT_DIR, customBase, deps) + const firstUrl: string = deps.toFile.mock.calls[0][1] + expect(firstUrl.startsWith(customBase)).toBe(true) + }) + + it('logs three lines per zone (check-mark, url, path)', async () => { + await generateZoneQrs(ZONES, OUT_DIR, DEFAULT_BASE_URL, deps) + expect(deps.log).toHaveBeenCalledTimes(ZONES.length * 3) + }) + + it('handles a single-zone list correctly', async () => { + const single: Zone[] = [{ token: 'test-zone-token', name: 'Test Zone' }] + await generateZoneQrs(single, OUT_DIR, DEFAULT_BASE_URL, deps) + expect(deps.toFile).toHaveBeenCalledTimes(1) + expect(deps.toFile).toHaveBeenCalledWith( + path.join(OUT_DIR, 'test-zone-token.png'), + `${DEFAULT_BASE_URL}/report?zone=test-zone-token`, + expect.any(Object), + ) + }) + + it('handles an empty zone list without errors', async () => { + await generateZoneQrs([], OUT_DIR, DEFAULT_BASE_URL, deps) + expect(deps.toFile).not.toHaveBeenCalled() + expect(deps.log).not.toHaveBeenCalled() + // mkdir is still called to ensure the directory exists + expect(deps.mkdirSync).toHaveBeenCalledOnce() + }) + + it('propagates errors thrown by toFile', async () => { + const boom = new Error('disk full') + deps.toFile.mockRejectedValueOnce(boom) + await expect( + generateZoneQrs(ZONES, OUT_DIR, DEFAULT_BASE_URL, deps), + ).rejects.toThrow('disk full') + }) + + it('propagates errors thrown by mkdirSync', async () => { + deps.mkdirSync.mockImplementationOnce(() => { + throw new Error('permission denied') + }) + await expect( + generateZoneQrs(ZONES, OUT_DIR, DEFAULT_BASE_URL, deps), + ).rejects.toThrow('permission denied') + }) +})