refactor: make generate-qr testable with injectable deps + entry guard

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CPf5Rc8QPx6V8KLEEgfKEQ
This commit is contained in:
2026-07-12 10:40:38 +08:00
co-authored by Claude Fable 5
parent 646c94be0c
commit 81282fbd8a
2 changed files with 248 additions and 15 deletions
+52 -15
View File
@@ -2,37 +2,74 @@
import QRCode from 'qrcode' import QRCode from 'qrcode'
import { mkdirSync } from 'fs' import { mkdirSync } from 'fs'
import path from 'path' 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 // 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-dock-a-qr-2026', name: 'SCW1 — Dock A' },
{ token: 'scw1-cold-storage-qr-2026', name: 'SCW1 — Cold Storage' }, { token: 'scw1-cold-storage-qr-2026', name: 'SCW1 — Cold Storage' },
{ token: 'scw1-loading-bay-qr-2026', name: 'SCW1 — Loading Bay' }, { token: 'scw1-loading-bay-qr-2026', name: 'SCW1 — Loading Bay' },
] ]
async function main() { export interface Zone {
const outDir = path.join(process.cwd(), 'public', 'qr') token: string
mkdirSync(outDir, { recursive: true }) name: string
}
for (const zone of ZONES) { /** Pure helper: builds the URL encoded in each QR code. */
const url = `${BASE_URL}/report?zone=${zone.token}` export function buildQrUrl(baseUrl: string, token: string): string {
return `${baseUrl}/report?zone=${token}`
}
export interface GenerateDeps {
toFile: (outputPath: string, url: string, opts: object) => Promise<void>
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<void> {
deps.mkdirSync(outDir, { recursive: true })
for (const zone of zones) {
const url = buildQrUrl(baseUrl, zone.token)
const outputPath = path.join(outDir, `${zone.token}.png`) const outputPath = path.join(outDir, `${zone.token}.png`)
await QRCode.toFile(outputPath, url, { await deps.toFile(outputPath, url, {
width: 400, width: 400,
margin: 2, margin: 2,
color: { dark: '#000000', light: '#FFFFFF' }, color: { dark: '#000000', light: '#FFFFFF' },
}) })
console.log(`${zone.name}`) deps.log(`${zone.name}`)
console.log(`${url}`) deps.log(`${url}`)
console.log(`${outputPath}`) deps.log(`${outputPath}`)
} }
} }
main().catch((err) => { async function main() {
console.error(err) const baseUrl = process.env.NEXT_PUBLIC_APP_URL ?? DEFAULT_BASE_URL
process.exit(1) const outDir = path.join(process.cwd(), 'public', 'qr')
})
await generateZoneQrs(ZONES, outDir, baseUrl, {
toFile: (outputPath, url, opts) => QRCode.toFile(outputPath, url, opts as Parameters<typeof QRCode.toFile>[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)
})
}
+196
View File
@@ -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<GenerateDeps['toFile']>().mockResolvedValue(undefined),
mkdirSync: vi.fn<GenerateDeps['mkdirSync']>(),
log: vi.fn<GenerateDeps['log']>(),
}
}
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<typeof makeDeps>
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 <outDir>/<token>.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')
})
})