Files
ims/tests/scripts/generate-qr.test.ts
T
adminandClaude Fable 5 81282fbd8a 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
2026-07-12 10:40:38 +08:00

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')
})
})