merge: G1 i18n EN fallback deep-merge in request.ts (gap #1 closed)

This commit is contained in:
diegosouzapw
2026-05-28 12:15:18 -03:00
2 changed files with 277 additions and 4 deletions

View File

@@ -3,23 +3,57 @@ import { cookies, headers } from "next/headers";
import { LOCALES, DEFAULT_LOCALE, LOCALE_COOKIE } from "./config";
import type { Locale } from "./config";
const FALLBACK_LOCALE = "en";
/**
* Deep merge that mutates `target` with values from `source`.
* If both have an object at the same key, recurse.
* Otherwise prefer the existing value in `target` (locale-specific wins).
*/
export function deepMergeFallback(
target: Record<string, unknown>,
source: Record<string, unknown>
): Record<string, unknown> {
for (const [key, sourceValue] of Object.entries(source)) {
const targetValue = target[key];
if (
sourceValue !== null &&
typeof sourceValue === "object" &&
!Array.isArray(sourceValue) &&
targetValue !== null &&
typeof targetValue === "object" &&
!Array.isArray(targetValue)
) {
deepMergeFallback(targetValue as Record<string, unknown>, sourceValue as Record<string, unknown>);
} else if (targetValue === undefined) {
target[key] = sourceValue;
}
}
return target;
}
export default getRequestConfig(async () => {
// 1. Try cookie
const cookieStore = await cookies();
let locale: string = cookieStore.get(LOCALE_COOKIE)?.value || "";
// 2. Try custom header (set by middleware)
if (!locale) {
const headerStore = await headers();
locale = headerStore.get("x-locale") || "";
}
// 3. Validate & fallback
if (!LOCALES.includes(locale as Locale)) {
locale = DEFAULT_LOCALE;
}
const messages = (await import(`./messages/${locale}.json`)).default;
const localeMessages = (await import(`./messages/${locale}.json`)).default;
// G1: fall back to EN for any missing key. EN is loaded only once per request
// and only when the active locale is not EN itself (no-op).
let messages = localeMessages as Record<string, unknown>;
if (locale !== FALLBACK_LOCALE) {
const fallbackMessages = (await import(`./messages/${FALLBACK_LOCALE}.json`)).default as Record<string, unknown>;
messages = deepMergeFallback({ ...localeMessages }, fallbackMessages);
}
return {
locale,

View File

@@ -0,0 +1,239 @@
/**
* Tests for G1 — i18n deep-merge EN fallback (src/i18n/request.ts).
*
* Strategy (A): test `deepMergeFallback` directly via its named export.
* This avoids mocking next/headers, next-intl, and dynamic imports while
* achieving ≥90% line coverage of the merge function itself.
*/
import { test } from "node:test";
import assert from "node:assert/strict";
import { deepMergeFallback } from "../../src/i18n/request.ts";
// ---------------------------------------------------------------------------
// 1. deepMergeFallback — locale-specific key wins (target wins)
// ---------------------------------------------------------------------------
test("deepMergeFallback: locale-specific key is preserved when source has the same key", () => {
const target: Record<string, unknown> = { greeting: "Hola" };
const source: Record<string, unknown> = { greeting: "Hello" };
const result = deepMergeFallback(target, source);
assert.equal(result.greeting, "Hola", "target value must survive when both target and source have the key");
});
test("deepMergeFallback: returns the same target reference (mutates in-place)", () => {
const target: Record<string, unknown> = { a: 1 };
const source: Record<string, unknown> = { b: 2 };
const result = deepMergeFallback(target, source);
assert.equal(result, target, "must return the same object reference");
});
// ---------------------------------------------------------------------------
// 2. deepMergeFallback — missing keys are added from source (fallback wins)
// ---------------------------------------------------------------------------
test("deepMergeFallback: missing key in target is filled from source", () => {
const target: Record<string, unknown> = { localeKey: "Hola" };
const source: Record<string, unknown> = { localeKey: "Hello", fallbackKey: "Fallback EN" };
const result = deepMergeFallback(target, source);
assert.equal(result.localeKey, "Hola", "locale key must win");
assert.equal(result.fallbackKey, "Fallback EN", "fallback key must be added");
});
test("deepMergeFallback: entire namespace missing in target is filled from source", () => {
const target: Record<string, unknown> = { namespace1: { localeKey: "Hola" } };
const source: Record<string, unknown> = {
namespace1: { localeKey: "Hello", fallbackKey: "Fallback EN" },
namespace2: { onlyEn: "Only EN" },
};
const result = deepMergeFallback(target, source);
// locale key wins inside existing namespace
assert.equal((result.namespace1 as Record<string, unknown>).localeKey, "Hola");
// fallback key added inside existing namespace
assert.equal((result.namespace1 as Record<string, unknown>).fallbackKey, "Fallback EN");
// entire namespace from source added to target
assert.deepEqual(result.namespace2, { onlyEn: "Only EN" });
});
// ---------------------------------------------------------------------------
// 3. deepMergeFallback — deep merge on nested objects
// ---------------------------------------------------------------------------
test("deepMergeFallback: deep merge recurses into nested objects", () => {
const target: Record<string, unknown> = {
a: {
b: {
locale: "es value",
},
},
};
const source: Record<string, unknown> = {
a: {
b: {
locale: "en value",
fallback: "en fallback",
},
c: "only in en",
},
};
const result = deepMergeFallback(target, source);
const a = result.a as Record<string, unknown>;
const b = a.b as Record<string, unknown>;
assert.equal(b.locale, "es value", "deeply nested locale key must win");
assert.equal(b.fallback, "en fallback", "deeply nested missing key must be filled from fallback");
assert.equal(a.c, "only in en", "sibling key missing in target must be filled from source");
});
test("deepMergeFallback: three levels deep — target wins at all levels", () => {
const target: Record<string, unknown> = {
l1: { l2: { l3: { key: "locale" } } },
};
const source: Record<string, unknown> = {
l1: { l2: { l3: { key: "fallback", extra: "extra-en" }, l2extra: "l2extra-en" } },
};
const result = deepMergeFallback(target, source);
const l3 = (((result.l1 as Record<string, unknown>).l2 as Record<string, unknown>).l3 as Record<string, unknown>);
assert.equal(l3.key, "locale");
assert.equal(l3.extra, "extra-en");
const l2 = ((result.l1 as Record<string, unknown>).l2 as Record<string, unknown>);
assert.equal(l2.l2extra, "l2extra-en");
});
// ---------------------------------------------------------------------------
// 4. deepMergeFallback — arrays are NOT deep-merged (scalar replacement)
// ---------------------------------------------------------------------------
test("deepMergeFallback: arrays in target are preserved as-is (not merged with source)", () => {
const target: Record<string, unknown> = { items: ["es-a", "es-b"] };
const source: Record<string, unknown> = { items: ["en-a", "en-b", "en-c"] };
const result = deepMergeFallback(target, source);
// Target already has the key, so it wins — array from source is ignored.
assert.deepEqual(result.items, ["es-a", "es-b"]);
});
test("deepMergeFallback: array missing in target is filled from source (not merged)", () => {
const target: Record<string, unknown> = {};
const source: Record<string, unknown> = { tags: ["en-tag-1", "en-tag-2"] };
const result = deepMergeFallback(target, source);
assert.deepEqual(result.tags, ["en-tag-1", "en-tag-2"]);
});
test("deepMergeFallback: source array does NOT overwrite existing object in target", () => {
// Source has an array where target has an object — target wins (existing value kept).
const target: Record<string, unknown> = { data: { nested: "locale" } };
const source: Record<string, unknown> = { data: ["en-1", "en-2"] };
const result = deepMergeFallback(target, source);
// target has "data" defined (as object), so source's array is ignored
assert.deepEqual(result.data, { nested: "locale" });
});
test("deepMergeFallback: source object does NOT overwrite existing array in target", () => {
// Source has an object where target has an array — target array wins (existing value kept).
const target: Record<string, unknown> = { list: ["es-item"] };
const source: Record<string, unknown> = { list: { key: "en-val" } };
const result = deepMergeFallback(target, source);
// target has "list" defined (as array), source is an object — since target[key] !== undefined
// the else-if branch is skipped, so target.list remains the array.
assert.deepEqual(result.list, ["es-item"]);
});
// ---------------------------------------------------------------------------
// 5. deepMergeFallback — null values in source / target
// ---------------------------------------------------------------------------
test("deepMergeFallback: null in source is treated as scalar (fills missing target key)", () => {
const target: Record<string, unknown> = {};
const source: Record<string, unknown> = { nullable: null };
const result = deepMergeFallback(target, source);
assert.equal(result.nullable, null);
});
test("deepMergeFallback: null in target preserves null (source object does not recurse into null)", () => {
const target: Record<string, unknown> = { section: null };
const source: Record<string, unknown> = { section: { key: "en-val" } };
const result = deepMergeFallback(target, source);
// target has "section" defined (as null — not undefined), so source's value is NOT applied.
assert.equal(result.section, null);
});
// ---------------------------------------------------------------------------
// 6. deepMergeFallback — empty objects
// ---------------------------------------------------------------------------
test("deepMergeFallback: empty target gets all keys from source", () => {
const target: Record<string, unknown> = {};
const source: Record<string, unknown> = { a: "A", b: { c: "C" } };
const result = deepMergeFallback(target, source);
assert.equal(result.a, "A");
assert.deepEqual(result.b, { c: "C" });
});
test("deepMergeFallback: empty source leaves target unchanged", () => {
const target: Record<string, unknown> = { x: "locale-x" };
const source: Record<string, unknown> = {};
const result = deepMergeFallback(target, source);
assert.equal(result.x, "locale-x");
assert.equal(Object.keys(result).length, 1);
});
// ---------------------------------------------------------------------------
// 7. Scenario: realistic i18n shape — simulate en.json as fallback
// ---------------------------------------------------------------------------
test("realistic i18n: es locale with partial translations falls back to EN for missing keys", () => {
// Simulates what getRequestConfig does:
// localeMessages = es.json content (partial)
// fallbackMessages = en.json content (complete)
// messages = deepMergeFallback({ ...localeMessages }, fallbackMessages)
const esLocale: Record<string, unknown> = {
namespace1: {
localeKey: "Hola",
// fallbackKey is absent — will come from EN
},
// namespace2 is absent — will come from EN
};
const enFallback: Record<string, unknown> = {
namespace1: {
localeKey: "Hello",
fallbackKey: "Fallback EN",
},
namespace2: {
onlyEn: "Only EN",
},
};
// Simulate what the factory does: shallow copy first so we don't mutate the import cache
const messages = deepMergeFallback({ ...esLocale }, enFallback);
assert.equal(messages.namespace1, esLocale.namespace1, "namespace1 object is the same reference (mutated in-place)");
const ns1 = messages.namespace1 as Record<string, unknown>;
assert.equal(ns1.localeKey, "Hola", "locale-specific key wins");
assert.equal(ns1.fallbackKey, "Fallback EN", "missing key filled from EN fallback");
assert.deepEqual(messages.namespace2, { onlyEn: "Only EN" }, "entirely missing namespace filled from EN");
});
test("realistic i18n: en locale — shallow copy means no mutation of original en object", () => {
// When locale === 'en', the factory returns localeMessages as-is (no merge).
// This test verifies the shallow copy pattern does not mutate the original.
const enLocale: Record<string, unknown> = { key: "EN value" };
const copy = { ...enLocale };
deepMergeFallback(copy, {}); // noop — empty source
assert.equal(enLocale.key, "EN value", "original object must not be mutated");
});
test("realistic i18n: locale invalid → DEFAULT_LOCALE applies; merge still works for non-EN default", () => {
// If DEFAULT_LOCALE were "pt-BR" (not EN), we'd merge pt-BR with EN fallback.
// Simulate this by merging a pt-BR partial object with EN.
const ptBrLocale: Record<string, unknown> = {
common: { save: "Salvar" },
// 'common.cancel' is missing — should come from EN
};
const enFallback: Record<string, unknown> = {
common: { save: "Save", cancel: "Cancel" },
extra: { key: "Extra EN" },
};
const messages = deepMergeFallback({ ...ptBrLocale }, enFallback);
const common = messages.common as Record<string, unknown>;
assert.equal(common.save, "Salvar", "locale key wins in default locale");
assert.equal(common.cancel, "Cancel", "missing key filled from EN");
assert.deepEqual(messages.extra, { key: "Extra EN" }, "missing namespace filled from EN");
});