Files
accounted/i18n/__tests__/message-keys.test.ts
T
Jakob Wennberg 902b3ee986 fix(invoices): resolve ROT/RUT strings and hide the payout action when it does not apply (#1429)
The ROT/RUT payout feature shipped in #1380 with all 44 of its strings
written into the invoice_editor namespace, while RotRutPayoutDialog and
the invoices page both read useTranslations('invoices'). next-intl falls
back to rendering the key path, so production showed literal
"invoices.rot_rut_payout_title" text where every label should be, and the
header button read "invoices.rot_rut_payout_action".

Move the keys to the invoices namespace, textually rather than through a
JSON round trip: the message files contain duplicate keys that
JSON.parse/stringify would silently drop.

Three other dialogs had the same namespace mismatch and are fixed the
same way, by adding the strings they reference to the namespace they read
from (TemplateBookDialog reads bookkeeping, Correction/StrikeLines read
journal_detail).

Add i18n/__tests__/message-keys.test.ts, which resolves every literal t()
key in app/, components/ and extensions/ against both locales. next-intl
has no build-time check and degrades by rendering the key path, so this
class of bug reaches users silently otherwise.

Also gate the payout action. Begäran om utbetalning (Lag 2009:194 8 §)
only concerns companies selling ROT/RUT-eligible work to consumers, so
the header button now appears only when the company has invoiced a
deduction or has opted into ROT/RUT in tax settings. The flag comes from
the company_settings row the page already fetches for ore_rounding, so
there is no extra request. ?rot-rut=1 still opens the dialog.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 11:13:25 +02:00

103 lines
3.5 KiB
TypeScript

import { describe, it, expect } from 'vitest'
import fs from 'fs'
import path from 'path'
/**
* Guard against the failure mode that shipped the ROT/RUT payout dialog to
* production rendering raw key paths: the strings existed in messages/*.json,
* but under a different namespace than the component read from. next-intl has
* no build-time check for that, it silently renders "namespace.key", so every
* label in the feature turned into debug output for real users.
*
* This resolves every literal translation key against both locales, so a
* misplaced namespace fails the suite instead of the UI.
*/
const ROOT = path.resolve(__dirname, '../..')
const SCAN_DIRS = ['app', 'components', 'extensions']
const SKIP_DIRS = new Set(['node_modules', '.next', '.git', '.claude'])
function collectSourceFiles(dir: string, out: string[] = []): string[] {
if (!fs.existsSync(dir)) return out
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
if (SKIP_DIRS.has(entry.name)) continue
const full = path.join(dir, entry.name)
if (entry.isDirectory()) collectSourceFiles(full, out)
else if (/\.tsx?$/.test(entry.name) && !/\.test\.tsx?$/.test(entry.name)) out.push(full)
}
return out
}
/**
* Comments hold example code (`t('none_title')` in a JSDoc usage block) that
* never runs. Blank them out rather than reporting keys nobody renders.
*/
function stripComments(src: string): string {
return src.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '')
}
function resolveKey(messages: unknown, dottedPath: string): unknown {
return dottedPath
.split('.')
.reduce<unknown>(
(node, part) =>
node && typeof node === 'object' ? (node as Record<string, unknown>)[part] : undefined,
messages,
)
}
interface Reference {
file: string
namespace: string
key: string
}
function collectReferences(): Reference[] {
const refs: Reference[] = []
for (const dir of SCAN_DIRS) {
for (const file of collectSourceFiles(path.join(ROOT, dir))) {
const src = stripComments(fs.readFileSync(file, 'utf8'))
// `const t = useTranslations('invoices')` / `= await getTranslations('x')`
const namespaces: Record<string, string> = {}
const declaration =
/(?:const|let)\s+(\w+)\s*=\s*(?:await\s+)?(?:useTranslations|getTranslations)\(\s*['"]([^'"]+)['"]\s*\)/g
for (const match of src.matchAll(declaration)) namespaces[match[1]] = match[2]
for (const [variable, namespace] of Object.entries(namespaces)) {
// Literal calls only: t(dynamicKey) cannot be checked statically.
const call = new RegExp(
`\\b${variable}(?:\\.rich|\\.markup|\\.raw)?\\(\\s*['"]([A-Za-z0-9_.]+)['"]`,
'g',
)
for (const match of src.matchAll(call)) {
refs.push({ file: path.relative(ROOT, file), namespace, key: match[1] })
}
}
}
}
return refs
}
describe('message keys', () => {
const references = collectReferences()
it('finds translation calls to check', () => {
expect(references.length).toBeGreaterThan(500)
})
for (const locale of ['sv', 'en'] as const) {
it(`resolves every referenced key in messages/${locale}.json`, () => {
const messages = JSON.parse(
fs.readFileSync(path.join(ROOT, 'messages', `${locale}.json`), 'utf8'),
)
const missing = references
.filter(({ namespace, key }) => resolveKey(messages, `${namespace}.${key}`) === undefined)
.map(({ file, namespace, key }) => `${file}: ${namespace}.${key}`)
expect([...new Set(missing)]).toEqual([])
})
}
})