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:
+50
-13
@@ -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() {
|
||||||
|
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<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)
|
console.error(err)
|
||||||
process.exit(1)
|
process.exit(1)
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user