From 9d4b69a075b8a1fc4325853ded98ab296361fccb Mon Sep 17 00:00:00 2001 From: _david Date: Thu, 1 Oct 2026 01:31:30 +0700 Subject: [PATCH] test(auth): add regression coverage for i18n across the auth flow (#159) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit i18n infra (utils/i18n.ts's t()/tErrorType(), locales/{vi,en}.ts, language.middleware.ts) and the full auth-flow migration (register/ login/logout/refresh/forgot-password/reset-password/verify-email, all 21 message sites) were already implemented and live. Adds the missing regression tests: - src/__tests__/utils/i18n.test.ts (new): unit tests for t()/tErrorType() — per-language resolution, default-lang fallback, key-as-fallback, flat vs dot-path lookup. - src/__tests__/middlewares/language.test.ts (new): unit tests for languageMiddleware's Accept-Language resolution (plain/regional/ multi-tag/case-insensitive/unsupported) and req.t() wiring. - src/__tests__/auth/auth.controller.test.ts (extended): 5 new English-language cases proving req.lang drives real (unmocked) t() output at the controller boundary, alongside the pre-existing vi-default cases. 0 production code changed. npm test: 31 suites / 181 tests passed. npm run build: clean. Sealed node: feat-i18n-api-messages-auth Evidence: agent-hub/evidence/implementer/2026-10-01/feat-i18n-api-messages-auth-plan.md, agent-hub/evidence/verifier/2026-10-01/feat-i18n-api-messages-auth-seal.md Co-Authored-By: Claude Sonnet 5 --- .../feat-i18n-api-messages-auth-plan.md | 122 +++++++++++++++++ .../feat-i18n-api-messages-auth-seal.md | 129 ++++++++++++++++++ .../haven/diagrams/dev-loop.prime-mermaid.md | 2 +- src/__tests__/auth/auth.controller.test.ts | 117 ++++++++++++++++ src/__tests__/middlewares/language.test.ts | 79 +++++++++++ src/__tests__/utils/i18n.test.ts | 54 ++++++++ 6 files changed, 502 insertions(+), 1 deletion(-) create mode 100644 agent-hub/evidence/implementer/2026-10-01/feat-i18n-api-messages-auth-plan.md create mode 100644 agent-hub/evidence/verifier/2026-10-01/feat-i18n-api-messages-auth-seal.md create mode 100644 src/__tests__/middlewares/language.test.ts create mode 100644 src/__tests__/utils/i18n.test.ts diff --git a/agent-hub/evidence/implementer/2026-10-01/feat-i18n-api-messages-auth-plan.md b/agent-hub/evidence/implementer/2026-10-01/feat-i18n-api-messages-auth-plan.md new file mode 100644 index 0000000..d4ebd53 --- /dev/null +++ b/agent-hub/evidence/implementer/2026-10-01/feat-i18n-api-messages-auth-plan.md @@ -0,0 +1,122 @@ +# 2026-10-01 — feat-i18n-api-messages-auth (implementer note) + +- Worker: implementer (main session) +- Node: `feat-i18n-api-messages-auth` +- GitHub issue: #159 — "i18n for API messages — auth flow (phase 1/2)" +- Branch: `159-i18n-for-api` (from `staging`) + +## Finding: already fully implemented, same backfill pattern as #158/#161 + +Read the full acceptance criteria and every file the scope names before +writing anything: + +- `src/utils/i18n.ts` — `t(key, lang)` (dot-path lookup, falls back to + `DEFAULT_LANG` then to the raw key) and `tErrorType(type, lang)` (flat + one-level lookup for Joi/Mongoose error-type codes) both already exist. +- `src/locales/{vi,en}.ts` — both locale tables exist (TS modules, not + `.json`, but functionally the "hand-rolled t(key,lang)" the issue + describes) with a full `auth.*` namespace (19 keys) covering every + register/login/logout/refresh/forgot-password/reset-password/ + verify-email message in both languages. +- `src/middlewares/language.middleware.ts` — resolves `Accept-Language` + (first weighted tag, primary subtag, case-insensitive, defaults to + `vi` for missing/unsupported), attaches `req.lang` + `req.t(key)`. +- `src/auth/auth.controller.ts` / `src/auth/auth.service.ts` — read in + full. Every one of the 21 `message:` sites in both files resolves + through `t(key, lang)`; every handler (`handlerRegister`, + `handlerLogin`, `handlerForgotPassword`, `handlerResetPassword`, + `handlerVerifyEmail`) takes `lang: string = DEFAULT_LANG`; every + controller action reads `(req as any).lang` and threads it through to + the service call, the literal `t()` calls, and `handleError(err, next, + lang)`. No hardcoded string found anywhere in either file — confirmed + by grepping every `message:` line in both files. + +Real gap: **zero test coverage** existed for any of this — no +`i18n.test.ts`, no `language.middleware` test, and the existing +`auth.controller.test.ts`/`auth.service.test.ts` only ever exercised the +default `vi` path (mock requests never set `req.lang`, so `(req as +any).lang` was `undefined` and `t()` fell through to `DEFAULT_LANG`). +Nothing proved the `en` path actually worked end-to-end. + +## What changed (tests only, 0 production code) + +1. **`src/__tests__/utils/i18n.test.ts`** (new) — unit tests for `t()` + (resolves per requested lang, defaults to vi, falls back to + `DEFAULT_LANG` for an unsupported lang code, falls back to the raw key + when missing everywhere) and `tErrorType()` (flat lookup vs `t()`'s + dot-path walk, default-lang fallback, `undefined` for an unknown + type). Satisfies acceptance criterion 1 ("`t(key,lang)` utility exists + and is unit-tested"). + +2. **`src/__tests__/middlewares/language.test.ts`** (new) — unit tests + for `languageMiddleware`: defaults to `vi` with no header, plain tag, + regional tag (`en-US` → `en`), multi-tag weighted header (first tag + wins), case-insensitivity, unsupported-language fallback, and that + `req.t(key)` genuinely delegates to the real `t()` table (asserts the + real English/Vietnamese strings, not a mock). + +3. **`src/__tests__/auth/auth.controller.test.ts`** (extended in place, + +117 lines, existing tests untouched) — added one `en`-language test + per controller action group, real (unmocked) `t()`, asserting the + actual English string comes back when `req.lang = 'en'`: + - `authRegister` — fallback success message (`message || + t('auth.registerSuccess', lang)`) + - `authLogin` — fallback failure message (`t('auth.loginFailed')`) + - `authRefreshToken` — no-refresh-token + revoked-token messages + (both direct `t()` calls, not fallbacks) + - `authLogout` — no-token + success messages + - `authLogoutAll` — success message + + Combined with the pre-existing tests (which all run with `req.lang` + unset → `vi`), this now proves both languages for every branch + touched, satisfying acceptance criterion 2 ("verified in both vi and + en via Accept-Language") at the controller boundary — the layer that + actually reads `Accept-Language` via `req.lang`. + +`handlerLogin`/`handlerRegister`'s own service-level messages were +already implicitly exercised in both directions by `auth.service.test.ts` +(default `vi` calls) — no service-level `en` test was added since the +service functions take a raw `lang` string param (already proven +language-agnostic by the `t()` unit tests) rather than reading +`Accept-Language` themselves; the controller is the integration point +worth testing end-to-end. + +## Explicitly not touched + +- Joi validation messages (`utils/valid.ts`) and Mongoose `required` + messages (`utils/helper.ts`'s `handleError`) — out of scope per the + issue body, tracked as phase 2 (#160 / node + `feat-i18n-full-coverage`). +- Candidate/CV section messages — same, phase 2. +- `api/v2/auth` (register/login WIP mirror) — issue #159 scopes only the + v1 auth flow; v2 has its own separate node/issue history + (`consolidate-v1-v2-auth`). + +## Verification run + +``` +npm test +``` +``` +Test Suites: 31 passed, 31 total +Tests: 181 passed, 181 total +Snapshots: 0 total +Time: 7.474 s, estimated 8 s +``` +(baseline before this change: 29 suites / 160 tests — +2 suites / +21 +tests: 8 in `i18n.test.ts`, 8 in `language.test.ts`, 5 new `en`-language +cases in `auth.controller.test.ts`) + +``` +npm run build +``` +``` +tsc && npm run copy +``` +Clean, no typecheck errors. + +## Hub bytes before +(measured by verifier, same 5-category `/hub-tokens` formula) + +## Status +`sealed_pending_verifier` diff --git a/agent-hub/evidence/verifier/2026-10-01/feat-i18n-api-messages-auth-seal.md b/agent-hub/evidence/verifier/2026-10-01/feat-i18n-api-messages-auth-seal.md new file mode 100644 index 0000000..3765104 --- /dev/null +++ b/agent-hub/evidence/verifier/2026-10-01/feat-i18n-api-messages-auth-seal.md @@ -0,0 +1,129 @@ +# 2026-10-01 — feat-i18n-api-messages-auth (verifier verdict) + +- Worker: verifier (subagent, dispatched via Agent tool) +- Node: `feat-i18n-api-messages-auth` +- New PM status: SEALED + +## Isolation proof +Dispatched fresh via the Agent tool with the task string: "You are being +spawned as an independent verifier subagent... Run `verify_seal` for +evidence note: `agent-hub/evidence/implementer/2026-10-01/feat-i18n-api-messages-auth-plan.md`. +Node: `feat-i18n-api-messages-auth`..." — no memory of any implementation +session. Everything below was re-derived independently: reading real +files (`i18n.ts`, `language.middleware.ts`, `auth.controller.ts`, +`auth.service.ts`, `locales/en.ts`, both new test files, the test diff), +running `git status`/`git diff staging` myself, re-running `npm test`/ +`npm run build` myself, and fetching GitHub issue #159 directly via `gh +issue view` rather than trusting the note's paraphrase. + +## Reasoning + +1. **`t(key,lang)`/`tErrorType` read in full** (`src/utils/i18n.ts`) — + matches the note's description exactly: dot-path `getNested` walker, + falls back to `DEFAULT_LANG` then the raw key; `tErrorType` does a flat + one-level `joiErrors[type]` lookup specifically because a Joi type + string like `any.required` already contains a dot. + +2. **`language.middleware.ts` read in full** — `resolveLang` takes the + first comma-separated tag, strips `-XX` regional suffix, lowercases, + falls back to `vi` if unsupported; attaches `req.lang` + `req.t`. + Matches the note. + +3. **Zero hardcoded strings confirmed by my own grep**, not the note's + claim: `grep -n "message:" src/auth/auth.controller.ts + src/auth/auth.service.ts` → exactly 21 lines, every single one calls + `t('auth.xxx', lang)` or `t('auth.xxx', (req as any).lang)`. Cross- + checked against `src/locales/en.ts:6-25` — 19 `auth.*` keys present, + covering every key referenced by the 21 call sites. + +4. **Diff matches the note's claim, genuinely minimal.** `git status + --short` on `159-i18n-for-api`: `M + src/__tests__/auth/auth.controller.test.ts` plus 2 new untracked test + files (`i18n.test.ts`, `language.test.ts`) and the evidence-note + directory — no production file touched. `git diff --stat staging -- + src/`: 1 file, `+117/-0` on `auth.controller.test.ts` — confirmed via + full diff read that every hunk is a pure addition (new `it(...)` + blocks appended after existing tests), the pre-existing tests are + byte-for-byte untouched. + +5. **New test files read in full.** `i18n.test.ts` — 8 real assertions: + per-lang resolution (`loginSuccess` en/vi), default-vi-with-no-lang, + fallback-to-default-lang for an unsupported code, fallback-to-raw-key + when missing everywhere, `tErrorType`'s flat lookup (`any.required` en/ + vi), `tErrorType` default-lang fallback, `tErrorType` returns + `undefined` for an unknown type. `language.test.ts` — 8 real + assertions matching the note's description (default/plain/regional/ + multi-tag-weighted/case-insensitive/unsupported-fallback, plus `req.t` + proven to delegate to the real table for both en and vi, not a mock). + `auth.controller.test.ts`'s 5 new tests each set `req.lang = 'en'` and + assert the literal English string from `locales/en.ts` comes back + through `formatReturn` — genuinely exercises the previously-untested + `en` branch, not a re-assertion of the `vi` default. + +6. **GitHub issue #159 fetched directly** (`gh issue view 159`), not + trusted from the note's paraphrase. Its 3 acceptance criteria: + - "`t(key, lang)` utility exists and is unit-tested" — met, `i18n.test.ts`. + - "Every auth-flow response/error message resolves through `t()`, + verified in both `vi` and `en` via `Accept-Language`" — met: the + 21-site grep (step 3) proves the "resolves through `t()`" half; + the 5 new controller `en` tests + pre-existing `vi`-default tests + prove the "verified in both languages" half at the layer that + actually reads `Accept-Language`. + - "No regression in existing auth tests" — met: `git diff` on + `auth.controller.test.ts` shows pure additions; `auth.service. + test.ts` untouched; full suite green (see below). + Issue explicitly scopes out Joi validation messages and candidate/CV + messages as a separate phase — matches the note's "explicitly not + touched" section and the node's own PENDING description before this + SEAL. + +7. **`npm test` independently re-run.** My own verbatim tail: + ``` + Test Suites: 31 passed, 31 total + Tests: 181 passed, 181 total + Snapshots: 0 total + Time: 7.946 s + Ran all test suites. + ``` + Matches the note's claimed `31 passed, 31 total` / `181 passed, 181 + total` exactly (baseline 29/160 + 2 new suites + 21 new tests, also + checks out arithmetically: 8 + 8 + 5 = 21). + +8. **`npm run build` independently re-run.** `tsc && npm run copy` → + clean, no typecheck errors, `cp -R ./src/views ./src/public ./dist/` + ran without complaint. + +**Proportion**: 3 test files, 0 production code — appropriately minimal +given the feature was already fully live; does not exceed the node's +scope (SmallestDiff) and does not fall short of any of #159's 3 +acceptance criteria. + +**Forbidden states scan**: `ADHOC_WORK` — no, real diagram node existed +(PENDING) and this ran through the verify_seal recipe. `NO_EVIDENCE` — +no, implementer note + this verdict note both exist. `EDIT_UNVERIFIED` — +no, `npm test`/`npm run build` independently re-run and read back +verbatim above. `CODE_IN_HAVEN` — no `.ts`/`.js` files added to `haven/`. +`DIAGRAM_DRIFT` — corrected by this SEAL (PM status now matches the +confirmed-live code + newly-added test coverage). + +## Re-run +`full` — re-ran `npm test` (31/31 suites, 181/181 tests, matched exactly) +and `npm run build` (clean) myself from the repo root; independently read +every file the note cited (`i18n.ts`, `language.middleware.ts`, +`auth.controller.ts`, `auth.service.ts`, `locales/en.ts`, both new test +files, the full `auth.controller.test.ts` diff) rather than auditing the +note's prose alone; ran `git status`/`git diff staging --stat` myself to +confirm the minimal-diff claim; fetched GitHub issue #159 directly via +`gh issue view 159 --json body` rather than trusting the note's +paraphrase of acceptance criteria. Justification: same class as +`add-candidate-self-delete`/`fix-idor-broken-access-control` — an +unusual "0 production diff, already fully live" claim, and the +dispatching task explicitly asked me to judge whether that warrants a +re-run under the recipe's criteria; it does. + +## Hub bytes before +n/a — not measured (not trivial to isolate cheaply from this dispatch context). + +## Hub bytes after +n/a — same reason; the diagram-row edit is the only hub-byte-affecting +write made this pass. diff --git a/agent-hub/haven/diagrams/dev-loop.prime-mermaid.md b/agent-hub/haven/diagrams/dev-loop.prime-mermaid.md index 5544d14..0f7746d 100644 --- a/agent-hub/haven/diagrams/dev-loop.prime-mermaid.md +++ b/agent-hub/haven/diagrams/dev-loop.prime-mermaid.md @@ -65,7 +65,7 @@ flowchart TD | `fix-create-response-null-id` | SEALED | Minor. `BaseService.ts` `handlerCreate`'s `hookAfterSave` reassigns the local destructured `data` variable, never actually updating what `baseCreateDocument` returns → every `POST .../create` response has `data._id: null` instead of the real new ID. Backfill, same pattern as `fix-idor-broken-access-control`: the fix itself was already live since commit `f355e2f` (2026-08-21, `src/services/index.ts` lines ~300-302, `const replacement = await props.hookAfterSave(...); if (replacement !== undefined) _data = replacement;`) — independently confirmed via `git show f355e2f -- src/services/index.ts` and the current file, both match. This node closed the real gap: no regression test existed. 2 new test files added (`src/__tests__/services/baseCreateDocument.test.ts` — root-cause unit coverage; `src/__tests__/candidate_profile/BaseService.test.ts` — unmocked `createCrudService`/`BaseService.ts`/`services/index.ts` spot-check, only the Mongoose model faked), 0 production changes — proportionate. `npm test`: 140/140 passed. Live HTTP end-to-end was not performed (no local Mongo/Redis/Docker in the implementer's sandbox, disclosed honestly, not hidden) — accepted the unmocked code-path spot-check as a reasonable substitute given the fix has been live and stable for 5+ weeks. SEALED 2026-09-28 after independent verifier pass (audit-only, no re-run — note's `npm test` output was verbatim, not truncated, command matched `doctrine/MEMORY.md`; confirmed via `git status --short` on `157-post-create-responses` that nothing was committed/pushed). Evidence: `evidence/verifier/2026-09-28/fix-create-response-null-id-seal.md`. | | `add-candidate-self-delete` | SEALED | GitHub issue #158. Backfill, same pattern as `fix-idor-broken-access-control`/`fix-candidate-password-leak`/`fix-create-response-null-id`: `DELETE /api/v1/candidate` was already fully implemented and live, introduced by commit `32953ed` ("feat: add DELETE /api/v1/candidate for self-service account deletion"), predating this hub's own history (before `f355e2f`'s bug-fix batch, 2026-08-21) — sat PENDING on the diagram purely for lack of a backfilled node/evidence. `fnDelete` (`candidate.controller.ts:157-168`) reads no id from `req.body`/`req.params`/`req.query`, only `(req as any).user?._id`; `router.use('/candidate', verifyToken, routeCandidate)` (`routers/api/v1/index.ts:27`) confirms the whole router, including `router.delete('/', fnDelete)` (`candidate.route.ts:261`), sits behind `verifyToken` — structurally IDOR-safe, independently re-confirmed by reading both files, not just the note's citation. `handlerDelete` (`candidate.service.ts:118-148`) checks existence first (`MODEL.findById`, returns `success:false` with zero deletes if not found), cascades `deleteMany({candidateId})` across all 9 `CV_SECTION_MODELS` (generalInformation/Experience/Education/Reference/Project/Certificate/Award/Application/Profile — independently read and counted) then `Candidate.deleteOne`, and cleans up on-disk files: the uploaded CV PDF and every project/certificate/award image (`IMAGE_SECTION_MODELS`), images collected via `.find({candidateId},{images:1})` BEFORE their documents are deleted, each removal gated by `fs.existsSync`. The real gap this node closed: zero regression tests existed for `handlerDelete`. Diff: 1 file, `src/__tests__/candidate/candidate.service.test.ts` — 5 new tests (not-found short-circuit, full 9-model cascade, CV-file-exists-unlink, CV-file-missing-no-unlink, project/certificate/award image cleanup), independently read in full via `git diff staging`; shared `jest.mock('@/models', ...)` factory extended (not replaced) with `deleteMany`/`deleteOne`/`find`, pre-existing #152 password-exclusion tests confirmed still present and unmodified. Self-reported footgun independently confirmed real: a first attempt with `jest.mock('fs')` (hoisted above imports) broke bcrypt's native-binding resolution at import time; the file as committed uses `jest.spyOn(fs, 'existsSync'/'unlinkSync')` in `beforeEach` + `jest.restoreAllMocks()` in `afterEach` — confirmed via `grep -n "jest.mock('fs')"` returning only the explanatory code comment, never an actual call. 0 production code changed — confirmed via `git diff staging --stat -- src/` showing only the one test file. No controller-level test added for `fnDelete` — reasonable given its 12 lines/single try-catch/zero branching, and matches `candidate.controller.test.ts`'s own file-header precedent ("thin wiring already covered indirectly elsewhere, same precedent as every other controller in this codebase"), independently read and confirmed verbatim. SEALED 2026-09-29 after independent verifier full re-run (self-selected given the unusual "0 production diff" claim): `git status --short`/`git diff staging` on `158-candidate-self-delete-allow` confirmed only the test file + new evidence note changed; `git log --oneline -- src/candidate/candidate.service.ts` confirmed `32953ed` predates `f355e2f`; `npm test` independently reproduced 29/29 suites, 160/160 tests exactly as claimed; `npm run build` independently reproduced clean, no typecheck errors. Evidence: `evidence/implementer/2026-09-29/add-candidate-self-delete-plan.md`, `evidence/verifier/2026-09-29/add-candidate-self-delete-seal.md`. No commit/push has happened yet (`/todo` invoked without `--ship`). | | `fix-candidate-me-candidateid-not-string` | SEALED | **Critical, found by accident while testing #79.** GitHub issue #153. `candidate_me/index.ts` `handlerGetAboutMe` — `_id` from the raw Mongoose document is an ObjectId instance, passed straight into `idQuerySafe.safeQuery({}, { candidateId: _id })` — `QuerySafe.safeQuery` only accepts `typeof value === 'string'`, so an ObjectId silently fails that check and `candidateId` gets dropped from the filter → `model.find({})` returns CV data (education/experience/award/certificate/project/generalInformation) for **every candidate mixed together**, on every `GET /api/me/:email` request (public, no auth) and `/download-pdf`. Live-tested confirmed: a brand-new candidate profile returned real data belonging to `votan.it@gmail.com`. Backfill, same pattern as `fix-create-response-null-id`/`fix-candidate-password-leak`: the fix itself (`.toString()` on `_id`) was already live since the #135 pass (2026-09-19) — independently confirmed via `src/candidate_me/index.ts` lines 122-129, matching the note's citation exactly; also confirmed `fnExportPDF` (`/download-pdf`) calls `handlerGetAboutMe` internally rather than re-implementing its own filter, so no separate call site needed a fix, and that the only other `candidateId`-bearing query in the file (`Profile.findOne` at line 100) bypasses `QuerySafe` entirely and was never exposed to this bug class. This node's real gap: no regression test exercised an ObjectId-typed `_id` (existing tests used a plain string, which already passes the string-only `typeof` check even with the old buggy code). 1 new describe block added to `src/__tests__/candidate_me/index.test.ts` (ObjectId-shaped `_id` via `{ toString() }`, asserts stringified `candidateId` reaches every section's `.find()`, never the raw object, never an unfiltered `{}`), 0 production changes — proportionate. `npm test`: 141/141 passed, independently re-run by the verifier (both the targeted file and the full suite) given the Critical severity, numbers matched exactly. Live HTTP end-to-end against 2+ real accounts (issue's 2nd acceptance criterion) was not performed (no local Mongo/Redis/Docker in the implementer's sandbox, disclosed honestly) — accepted the unmocked code-path regression test as a reasonable substitute, same pattern already accepted for `fix-create-response-null-id`/`fix-candidate-password-leak`, given the fix has been live and stable for 9+ days and the issue's own diagnostic section already recorded the original live-test finding against production data. SEALED 2026-09-28 after independent verifier pass (partial re-run; confirmed via `git status --short` on `153-critical-candidate-me` that nothing was committed/pushed). Evidence: `evidence/verifier/2026-09-28/fix-candidate-me-candidateid-not-string-seal.md`. | -| `feat-i18n-api-messages-auth` | PENDING | Feature, GitHub issue #78 (phase 1 of several). i18n infrastructure (hand-rolled `t(key, lang)`, reads `locales/vi.json`/`en.json`, middleware detects `Accept-Language`, defaults `vi`) + fully migrates the auth flow (register/login/logout/refresh). Does NOT migrate Joi validation messages (different architecture — Joi schemas are built once at module load with no request context; needs error TYPE → i18n key mapping, left as a follow-up). Does NOT touch candidate/CV section messages (separate follow-up). | +| `feat-i18n-api-messages-auth` | SEALED | GitHub issue #159 (phase 1/2 of the original #78 i18n effort). Backfill, same pattern as `add-candidate-self-delete`/`fix-idor-broken-access-control`: independently re-reading the code shows the i18n infrastructure and the full auth-flow migration were already live, not just planned. `src/utils/i18n.ts` — `t(key, lang)` (dot-path lookup, falls back to `DEFAULT_LANG` then the raw key) and `tErrorType(type, lang)` (flat one-level lookup for Joi/Mongoose error-type codes, needed because a type string like `any.required` already contains a dot and would otherwise be mis-parsed by `t()`'s walker) both confirmed present and correct. `src/locales/{vi,en}.ts` (TS modules, not `.json` as the issue's phrasing suggested, but the same hand-rolled `t(key,lang)` shape) — `auth` namespace independently counted at 19 keys in `en.ts:6-25`, covering register/login/logout/logout-all/refresh/forgot-password/reset-password/verify-email in both languages. `src/middlewares/language.middleware.ts` — `resolveLang` takes the first weighted `Accept-Language` tag, strips the regional suffix, lowercases, defaults to `vi` for anything unsupported; attaches `req.lang` + `req.t`. `grep -n "message:" src/auth/auth.controller.ts src/auth/auth.service.ts` independently run: exactly 21 sites, every one routes through `t(key, (req as any).lang)` / `t(key, lang)` — zero hardcoded strings found in either file. The real gap this node closed: zero test coverage existed for any of it — the pre-existing `auth.controller.test.ts`/`auth.service.test.ts` never set `req.lang`, so only the default `vi` path was ever exercised; nothing proved `en` worked end-to-end. Diff (independently confirmed via `git status --short` / `git diff staging --stat -- src/` on `159-i18n-for-api`): 3 test files, 0 production changes — `src/__tests__/utils/i18n.test.ts` (new, 8 tests: per-lang resolution, default-lang fallback, missing-key-returns-raw-key, `tErrorType`'s flat vs dot-path behavior), `src/__tests__/middlewares/language.test.ts` (new, 8 tests: no-header default, plain tag, regional tag, multi-tag weighted header takes the first, case-insensitivity, unsupported-language fallback, `req.t` delegates to the real table for both en and vi), `src/__tests__/auth/auth.controller.test.ts` (+117/-0, existing tests byte-for-byte untouched — confirmed via `git diff staging` showing pure additions — 5 new `en`-language tests, one per controller action group, using the real unmocked `t()` and asserting actual English strings: `authRegister`'s fallback success message, `authLogin`'s fallback failure message, `authRefreshToken`'s no-token/revoked-token messages, `authLogout`'s no-token/success messages, `authLogoutAll`'s success message). Combined with the pre-existing `vi`-default tests this proves both languages at the controller boundary — the layer that actually reads `Accept-Language`. Service-level `en` coverage deliberately not added: `handlerLogin`/`handlerRegister` take a raw `lang` string param already proven language-agnostic by the `i18n.test.ts` unit tests, so the controller integration point was the one worth testing. GitHub issue #159's 3 acceptance criteria all met: (1) `t(key,lang)` exists and is unit-tested — `i18n.test.ts`; (2) every auth-flow message resolves through `t()`, verified in both `vi` and `en` via `Accept-Language` — the 21-site grep + the 5 new controller-level `en` tests + the pre-existing `vi`-default tests; (3) no regression in existing auth tests — confirmed via `git diff` showing pure additions to `auth.controller.test.ts` and `auth.service.test.ts` completely untouched. Joi validation messages, Mongoose `required` messages, and candidate/CV-section messages explicitly out of scope, tracked as `feat-i18n-full-coverage` (issue #160/#78 phase 2) — correctly not attempted here (SmallestDiff). `npm test` independently re-run by the verifier: 31/31 suites, 181/181 tests, matching the note's claimed 31/181 exactly (baseline 29/160 + 2 new suites + 21 new tests). `npm run build` independently re-run: clean, no typecheck errors. SEALED 2026-10-01 after independent verifier full re-run (self-selected given the "0 production diff, already fully live" claim, same class as `add-candidate-self-delete`/`fix-idor-broken-access-control`). No commit/push has happened yet. Evidence: `evidence/implementer/2026-10-01/feat-i18n-api-messages-auth-plan.md`, `evidence/verifier/2026-10-01/feat-i18n-api-messages-auth-seal.md`. | | `feat-i18n-full-coverage` | PENDING | Feature, GitHub issue #78 (phase 2/2 — complete). Joi validation messages: a generic system translating by `detail.type` + `fieldLabels` (`utils/valid.ts`), no longer relying on hardcoded `.messages()` per schema. Mongoose `required` messages: same approach in `handleError` (`utils/helper.ts`). Every candidate/CV section success/error message (`services/index.ts`, `BaseController.ts`, `BaseService.ts`, `candidate.service.ts`, `generalInformation.*`) cascades across all 7 CV sections. Bug found during implementation: `t()`'s dot-path walker misparsed Joi type strings containing a dot (`any.required` was read as 3 nested levels) — caught via a real live test (curl in 2 languages), not code review. Fix: a dedicated `tErrorType()` function, flat lookup with no dot-path walking. | | `add-visit-tracking` | SEALED | 2026-09-01 — archived, see `haven/diagrams/dev-loop-archive.md`. Evidence: `evidence/implementer/2026-09-01/add-visit-tracking-diff.md`. | | `add-open-to-work-status` | SEALED | 2026-08-29 — archived, see `haven/diagrams/dev-loop-archive.md`. Evidence: `evidence/implementer/2026-08-29/add-open-to-work-status-plan.md`. | diff --git a/src/__tests__/auth/auth.controller.test.ts b/src/__tests__/auth/auth.controller.test.ts index e4208cc..bdce9b7 100644 --- a/src/__tests__/auth/auth.controller.test.ts +++ b/src/__tests__/auth/auth.controller.test.ts @@ -95,6 +95,27 @@ describe('auth.controller', () => { errors: ['Invalid email'], }); }); + + // issue #159 — the fallback message (`message || t('auth.registerSuccess', lang)`) + // resolves through the real, unmocked `t()` and must respect req.lang. + it('localizes the fallback success message to English when req.lang is en', async () => { + const req: any = mockRequest({ email: 'test@example.com', password: 'pass123', repassword: 'pass123' }); + req.lang = 'en'; + const res = mockResponse(); + + (validateSchema.validateSchema as jest.Mock).mockReturnValue({ + isValidated: true, + value: { email: 'test@example.com', password: 'pass123' }, + }); + (handlerRegister as jest.Mock).mockResolvedValue({ success: true, message: undefined }); + + await authRegister(req, res, mockNext); + + expect(formatReturn.formatReturn).toHaveBeenCalledWith( + res, + expect.objectContaining({ message: 'Registration successful' }), + ); + }); }); describe('authLogin', () => { @@ -141,6 +162,27 @@ describe('auth.controller', () => { }), ); }); + + // issue #159 — same fallback-through-t() proof as authRegister, for the + // `_result?.message || t('auth.loginFailed', lang)` branch. + it('localizes the fallback failure message to English when req.lang is en', async () => { + const req: any = mockRequest({ email: 'test@example.com', password: 'wrong' }); + req.lang = 'en'; + const res = mockResponse(); + + (validateSchema.validateSchema as jest.Mock).mockReturnValue({ + isValidated: true, + value: { email: 'test@example.com', password: 'wrong' }, + }); + (handlerLogin as jest.Mock).mockResolvedValue({ success: false, message: undefined }); + + await authLogin(req, res, mockNext); + + expect(formatReturn.formatReturn).toHaveBeenCalledWith( + res, + expect.objectContaining({ success: false, message: 'Login failed' }), + ); + }); }); describe('authRefreshToken', () => { @@ -201,6 +243,35 @@ describe('auth.controller', () => { }), ); }); + + // issue #159 — both error branches call t() directly (not via a + // `|| t(...)` fallback), so this proves req.lang drives them too. + it('localizes the no-refresh-token and revoked-token messages to English when req.lang is en', async () => { + const req: any = mockRequest({}, {}); + req.lang = 'en'; + const res = mockResponse(); + + (helperAuth.extractTokenFromRequest as jest.Mock).mockReturnValue(null); + + await authRefreshToken(req, res, mockNext); + + expect(formatReturn.formatReturn).toHaveBeenCalledWith( + res, + expect.objectContaining({ message: 'No refresh token provided' }), + ); + + jest.clearAllMocks(); + (sessionRevocation.getSessionsInvalidatedAt as jest.Mock).mockResolvedValue(null); + (helperAuth.extractTokenFromRequest as jest.Mock).mockReturnValue('blacklisted_token'); + (tokenBlacklist.isBlacklisted as jest.Mock).mockResolvedValue(true); + + await authRefreshToken(req, res, mockNext); + + expect(formatReturn.formatReturn).toHaveBeenCalledWith( + res, + expect.objectContaining({ message: 'Refresh token revoked' }), + ); + }); }); describe('authLogout', () => { @@ -241,6 +312,35 @@ describe('auth.controller', () => { }), ); }); + + // issue #159 — proves both the error and success paths localize. + it('localizes the no-token and success messages to English when req.lang is en', async () => { + const req: any = mockRequest({}, { authorization: 'Bearer access_token' }); + req.lang = 'en'; + const res = mockResponse(); + + (helperAuth.extractTokenFromRequest as jest.Mock).mockReturnValue('access_token'); + (tokenBlacklist.addToBlacklist as jest.Mock).mockResolvedValue(undefined); + + await authLogout(req, res, mockNext); + + expect(formatReturn.formatReturn).toHaveBeenCalledWith( + res, + expect.objectContaining({ success: true, message: 'Logged out successfully' }), + ); + + jest.clearAllMocks(); + const reqNoToken: any = mockRequest(); + reqNoToken.lang = 'en'; + (helperAuth.extractTokenFromRequest as jest.Mock).mockReturnValue(null); + + await authLogout(reqNoToken, res, mockNext); + + expect(formatReturn.formatReturn).toHaveBeenCalledWith( + res, + expect.objectContaining({ message: 'No token provided to logout' }), + ); + }); }); describe('authLogoutAll', () => { @@ -265,6 +365,23 @@ describe('auth.controller', () => { ); }); + // issue #159 + it('localizes the success message to English when req.lang is en', async () => { + const req: any = mockRequest(); + req.lang = 'en'; + req.user = { _id: 'user_id' }; + const res = mockResponse(); + + (sessionRevocation.invalidateAllSessions as jest.Mock).mockResolvedValue(true); + + await authLogoutAll(req, res, mockNext); + + expect(formatReturn.formatReturn).toHaveBeenCalledWith( + res, + expect.objectContaining({ success: true, message: 'Logged out of all devices successfully' }), + ); + }); + it('fails when there is no authenticated user on the request', async () => { const req: any = mockRequest(); const res = mockResponse(); diff --git a/src/__tests__/middlewares/language.test.ts b/src/__tests__/middlewares/language.test.ts new file mode 100644 index 0000000..b794604 --- /dev/null +++ b/src/__tests__/middlewares/language.test.ts @@ -0,0 +1,79 @@ +/** + * Regression coverage for issue #159 — language.middleware.ts (Accept-Language + * → req.lang / req.t) had no unit tests despite gating i18n for every route. + */ + +import { Request, Response, NextFunction } from 'express'; +import { languageMiddleware } from '@/middlewares/language.middleware'; + +const mockRequest = (acceptLanguage?: string) => + ({ + headers: acceptLanguage === undefined ? {} : { 'accept-language': acceptLanguage }, + }) as Request; + +const mockResponse = () => ({}) as Response; + +describe('middlewares/language.middleware', () => { + const next = jest.fn() as NextFunction; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('defaults to vi when Accept-Language is absent', () => { + const req: any = mockRequest(); + languageMiddleware(req, mockResponse(), next); + + expect(req.lang).toBe('vi'); + expect(next).toHaveBeenCalled(); + }); + + it('resolves a plain supported tag', () => { + const req: any = mockRequest('en'); + languageMiddleware(req, mockResponse(), next); + + expect(req.lang).toBe('en'); + }); + + it('takes the primary subtag of a regional tag (en-US -> en)', () => { + const req: any = mockRequest('en-US'); + languageMiddleware(req, mockResponse(), next); + + expect(req.lang).toBe('en'); + }); + + it('takes the first weighted tag in a multi-tag header', () => { + const req: any = mockRequest('en-US,en;q=0.9,vi;q=0.8'); + languageMiddleware(req, mockResponse(), next); + + expect(req.lang).toBe('en'); + }); + + it('is case-insensitive', () => { + const req: any = mockRequest('EN'); + languageMiddleware(req, mockResponse(), next); + + expect(req.lang).toBe('en'); + }); + + it('falls back to vi for an unsupported language', () => { + const req: any = mockRequest('fr-FR'); + languageMiddleware(req, mockResponse(), next); + + expect(req.lang).toBe('vi'); + }); + + it('attaches req.t bound to the resolved language, delegating to the real i18n table', () => { + const req: any = mockRequest('en'); + languageMiddleware(req, mockResponse(), next); + + expect(req.t('auth.loginSuccess')).toBe('Login successful'); + }); + + it('req.t falls back to vi when resolved as vi', () => { + const req: any = mockRequest(); + languageMiddleware(req, mockResponse(), next); + + expect(req.t('auth.loginSuccess')).toBe('Đăng nhập thành công'); + }); +}); diff --git a/src/__tests__/utils/i18n.test.ts b/src/__tests__/utils/i18n.test.ts new file mode 100644 index 0000000..2e69d5b --- /dev/null +++ b/src/__tests__/utils/i18n.test.ts @@ -0,0 +1,54 @@ +/** + * Regression coverage for issue #159 — i18n utility (`t`/`tErrorType`) had + * no unit tests despite being live in the auth flow and Joi error mapping. + */ + +import { t, tErrorType, SUPPORTED_LANGS, DEFAULT_LANG } from '@/utils/i18n'; + +describe('utils/i18n', () => { + describe('SUPPORTED_LANGS / DEFAULT_LANG', () => { + it('supports exactly vi and en, defaulting to vi', () => { + expect(SUPPORTED_LANGS).toEqual(['vi', 'en']); + expect(DEFAULT_LANG).toBe('vi'); + }); + }); + + describe('t', () => { + it('resolves a dot-path key for the requested language', () => { + expect(t('auth.loginSuccess', 'en')).toBe('Login successful'); + expect(t('auth.loginSuccess', 'vi')).toBe('Đăng nhập thành công'); + }); + + it('defaults to vi when no lang is given', () => { + expect(t('auth.loginSuccess')).toBe('Đăng nhập thành công'); + }); + + it('falls back to the default language when the key is missing under the requested language', () => { + // 'xx' isn't a supported lang, so `locales['xx']` is undefined — + // getNested returns undefined, and t() falls back to DEFAULT_LANG. + expect(t('auth.loginSuccess', 'xx')).toBe('Đăng nhập thành công'); + }); + + it('falls back to the key itself when the translation exists in no language', () => { + expect(t('auth.doesNotExist', 'en')).toBe('auth.doesNotExist'); + }); + }); + + describe('tErrorType', () => { + it('does a flat lookup that treats the whole type string as one key', () => { + // A dot-path walk of "any.required" would look for + // locales.en.any.required (3 levels) and find nothing; tErrorType + // instead reads locales.en.joiErrors['any.required'] directly. + expect(tErrorType('any.required', 'en')).toBe('{{label}} is required'); + expect(tErrorType('any.required', 'vi')).toBe('{{label}} là bắt buộc'); + }); + + it('falls back to the default language when missing under the requested language', () => { + expect(tErrorType('any.required', 'xx' as any)).toBe('{{label}} là bắt buộc'); + }); + + it('returns undefined (not the key) when no template exists for the type in any language', () => { + expect(tErrorType('totally.unknown.type', 'en')).toBeUndefined(); + }); + }); +});