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
197 lines
6.5 KiB
TypeScript
197 lines
6.5 KiB
TypeScript
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')
|
|
})
|
|
})
|