@buenojs/bueno 0.8.3 → 0.8.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +136 -16
- package/dist/cli/{index.js → bin.js} +3036 -1421
- package/dist/container/index.js +250 -0
- package/dist/context/index.js +219 -0
- package/dist/database/index.js +493 -0
- package/dist/frontend/index.js +7697 -0
- package/dist/health/index.js +364 -0
- package/dist/i18n/index.js +345 -0
- package/dist/index.js +11043 -6482
- package/dist/jobs/index.js +819 -0
- package/dist/lock/index.js +367 -0
- package/dist/logger/index.js +281 -0
- package/dist/metrics/index.js +289 -0
- package/dist/middleware/index.js +77 -0
- package/dist/migrations/index.js +571 -0
- package/dist/modules/index.js +3346 -0
- package/dist/notification/index.js +484 -0
- package/dist/observability/index.js +331 -0
- package/dist/openapi/index.js +776 -0
- package/dist/orm/index.js +1356 -0
- package/dist/router/index.js +886 -0
- package/dist/rpc/index.js +691 -0
- package/dist/schema/index.js +400 -0
- package/dist/telemetry/index.js +595 -0
- package/dist/template/index.js +640 -0
- package/dist/templates/index.js +640 -0
- package/dist/testing/index.js +1111 -0
- package/dist/types/index.js +60 -0
- package/package.json +121 -27
- package/src/cache/index.ts +2 -1
- package/src/cli/bin.ts +2 -2
- package/src/cli/commands/build.ts +183 -165
- package/src/cli/commands/dev.ts +96 -89
- package/src/cli/commands/generate.ts +142 -111
- package/src/cli/commands/help.ts +20 -16
- package/src/cli/commands/index.ts +3 -6
- package/src/cli/commands/migration.ts +124 -105
- package/src/cli/commands/new.ts +392 -438
- package/src/cli/commands/start.ts +81 -79
- package/src/cli/core/args.ts +68 -50
- package/src/cli/core/console.ts +89 -95
- package/src/cli/core/index.ts +4 -4
- package/src/cli/core/prompt.ts +65 -62
- package/src/cli/core/spinner.ts +23 -20
- package/src/cli/index.ts +46 -38
- package/src/cli/templates/database/index.ts +61 -0
- package/src/cli/templates/database/mysql.ts +14 -0
- package/src/cli/templates/database/none.ts +16 -0
- package/src/cli/templates/database/postgresql.ts +14 -0
- package/src/cli/templates/database/sqlite.ts +14 -0
- package/src/cli/templates/deploy.ts +29 -26
- package/src/cli/templates/docker.ts +41 -30
- package/src/cli/templates/frontend/index.ts +63 -0
- package/src/cli/templates/frontend/none.ts +17 -0
- package/src/cli/templates/frontend/react.ts +140 -0
- package/src/cli/templates/frontend/solid.ts +134 -0
- package/src/cli/templates/frontend/svelte.ts +131 -0
- package/src/cli/templates/frontend/vue.ts +130 -0
- package/src/cli/templates/generators/index.ts +339 -0
- package/src/cli/templates/generators/types.ts +56 -0
- package/src/cli/templates/index.ts +35 -2
- package/src/cli/templates/project/api.ts +81 -0
- package/src/cli/templates/project/default.ts +140 -0
- package/src/cli/templates/project/fullstack.ts +111 -0
- package/src/cli/templates/project/index.ts +95 -0
- package/src/cli/templates/project/minimal.ts +45 -0
- package/src/cli/templates/project/types.ts +94 -0
- package/src/cli/templates/project/website.ts +263 -0
- package/src/cli/utils/fs.ts +55 -41
- package/src/cli/utils/index.ts +3 -2
- package/src/cli/utils/strings.ts +47 -33
- package/src/cli/utils/version.ts +47 -0
- package/src/config/env-validation.ts +100 -0
- package/src/config/env.ts +169 -41
- package/src/config/index.ts +28 -20
- package/src/config/loader.ts +25 -16
- package/src/config/merge.ts +21 -10
- package/src/config/types.ts +545 -25
- package/src/config/validation.ts +215 -7
- package/src/container/forward-ref.ts +22 -22
- package/src/container/index.ts +34 -12
- package/src/context/index.ts +11 -1
- package/src/database/index.ts +7 -190
- package/src/database/orm/builder.ts +457 -0
- package/src/database/orm/casts/index.ts +130 -0
- package/src/database/orm/casts/types.ts +25 -0
- package/src/database/orm/compiler.ts +304 -0
- package/src/database/orm/hooks/index.ts +114 -0
- package/src/database/orm/index.ts +61 -0
- package/src/database/orm/model-registry.ts +59 -0
- package/src/database/orm/model.ts +821 -0
- package/src/database/orm/relationships/base.ts +146 -0
- package/src/database/orm/relationships/belongs-to-many.ts +179 -0
- package/src/database/orm/relationships/belongs-to.ts +56 -0
- package/src/database/orm/relationships/has-many.ts +45 -0
- package/src/database/orm/relationships/has-one.ts +41 -0
- package/src/database/orm/relationships/index.ts +11 -0
- package/src/database/orm/scopes/index.ts +55 -0
- package/src/events/__tests__/event-system.test.ts +235 -0
- package/src/events/config.ts +238 -0
- package/src/events/example-usage.ts +185 -0
- package/src/events/index.ts +278 -0
- package/src/events/manager.ts +385 -0
- package/src/events/registry.ts +182 -0
- package/src/events/types.ts +124 -0
- package/src/frontend/api-routes.ts +65 -23
- package/src/frontend/bundler.ts +76 -34
- package/src/frontend/console-client.ts +2 -2
- package/src/frontend/console-stream.ts +94 -38
- package/src/frontend/dev-server.ts +94 -46
- package/src/frontend/file-router.ts +61 -19
- package/src/frontend/frameworks/index.ts +37 -10
- package/src/frontend/frameworks/react.ts +10 -8
- package/src/frontend/frameworks/solid.ts +11 -9
- package/src/frontend/frameworks/svelte.ts +15 -9
- package/src/frontend/frameworks/vue.ts +13 -11
- package/src/frontend/hmr-client.ts +12 -10
- package/src/frontend/hmr.ts +146 -103
- package/src/frontend/index.ts +14 -5
- package/src/frontend/islands.ts +41 -22
- package/src/frontend/isr.ts +59 -37
- package/src/frontend/layout.ts +36 -21
- package/src/frontend/ssr/react.ts +74 -27
- package/src/frontend/ssr/solid.ts +54 -20
- package/src/frontend/ssr/svelte.ts +48 -14
- package/src/frontend/ssr/vue.ts +50 -18
- package/src/frontend/ssr.ts +83 -39
- package/src/frontend/types.ts +91 -56
- package/src/health/index.ts +21 -9
- package/src/i18n/engine.ts +305 -0
- package/src/i18n/index.ts +38 -0
- package/src/i18n/loader.ts +218 -0
- package/src/i18n/middleware.ts +164 -0
- package/src/i18n/negotiator.ts +162 -0
- package/src/i18n/types.ts +158 -0
- package/src/index.ts +179 -27
- package/src/jobs/drivers/memory.ts +315 -0
- package/src/jobs/drivers/redis.ts +459 -0
- package/src/jobs/index.ts +30 -0
- package/src/jobs/queue.ts +281 -0
- package/src/jobs/types.ts +295 -0
- package/src/jobs/worker.ts +380 -0
- package/src/logger/index.ts +1 -3
- package/src/logger/transports/index.ts +62 -22
- package/src/metrics/index.ts +25 -16
- package/src/migrations/index.ts +9 -0
- package/src/modules/filters.ts +13 -17
- package/src/modules/guards.ts +49 -26
- package/src/modules/index.ts +409 -298
- package/src/modules/interceptors.ts +58 -20
- package/src/modules/lazy.ts +11 -19
- package/src/modules/lifecycle.ts +15 -7
- package/src/modules/metadata.ts +15 -5
- package/src/modules/pipes.ts +94 -72
- package/src/notification/channels/base.ts +68 -0
- package/src/notification/channels/email.ts +105 -0
- package/src/notification/channels/push.ts +104 -0
- package/src/notification/channels/sms.ts +105 -0
- package/src/notification/channels/whatsapp.ts +104 -0
- package/src/notification/index.ts +48 -0
- package/src/notification/service.ts +354 -0
- package/src/notification/types.ts +344 -0
- package/src/observability/__tests__/observability.test.ts +483 -0
- package/src/observability/breadcrumbs.ts +114 -0
- package/src/observability/index.ts +136 -0
- package/src/observability/interceptor.ts +85 -0
- package/src/observability/service.ts +303 -0
- package/src/observability/trace.ts +37 -0
- package/src/observability/types.ts +196 -0
- package/src/openapi/__tests__/decorators.test.ts +335 -0
- package/src/openapi/__tests__/document-builder.test.ts +285 -0
- package/src/openapi/__tests__/route-scanner.test.ts +334 -0
- package/src/openapi/__tests__/schema-generator.test.ts +275 -0
- package/src/openapi/decorators.ts +328 -0
- package/src/openapi/document-builder.ts +274 -0
- package/src/openapi/index.ts +112 -0
- package/src/openapi/metadata.ts +112 -0
- package/src/openapi/route-scanner.ts +289 -0
- package/src/openapi/schema-generator.ts +256 -0
- package/src/openapi/swagger-module.ts +166 -0
- package/src/openapi/types.ts +398 -0
- package/src/orm/index.ts +10 -0
- package/src/rpc/index.ts +3 -1
- package/src/schema/index.ts +9 -0
- package/src/security/index.ts +15 -6
- package/src/ssg/index.ts +9 -8
- package/src/telemetry/index.ts +76 -22
- package/src/template/index.ts +7 -0
- package/src/templates/engine.ts +224 -0
- package/src/templates/index.ts +9 -0
- package/src/templates/loader.ts +331 -0
- package/src/templates/renderers/markdown.ts +212 -0
- package/src/templates/renderers/simple.ts +269 -0
- package/src/templates/types.ts +154 -0
- package/src/testing/index.ts +100 -27
- package/src/types/optional-deps.d.ts +347 -187
- package/src/validation/index.ts +92 -2
- package/src/validation/schemas.ts +536 -0
- package/tests/integration/fullstack.test.ts +4 -4
- package/tests/unit/database.test.ts +2 -72
- package/tests/unit/env-validation.test.ts +166 -0
- package/tests/unit/events.test.ts +910 -0
- package/tests/unit/i18n.test.ts +455 -0
- package/tests/unit/jobs.test.ts +493 -0
- package/tests/unit/notification.test.ts +988 -0
- package/tests/unit/observability.test.ts +453 -0
- package/tests/unit/orm/builder.test.ts +323 -0
- package/tests/unit/orm/casts.test.ts +179 -0
- package/tests/unit/orm/compiler.test.ts +220 -0
- package/tests/unit/orm/eager-loading.test.ts +285 -0
- package/tests/unit/orm/hooks.test.ts +191 -0
- package/tests/unit/orm/model.test.ts +373 -0
- package/tests/unit/orm/relationships.test.ts +303 -0
- package/tests/unit/orm/scopes.test.ts +74 -0
- package/tests/unit/templates-simple.test.ts +53 -0
- package/tests/unit/templates.test.ts +454 -0
- package/tests/unit/validation.test.ts +18 -24
- package/tsconfig.json +11 -3
|
@@ -0,0 +1,455 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* i18n System Unit Tests
|
|
3
|
+
*
|
|
4
|
+
* Tests for: locale negotiation, translation loader, translation engine,
|
|
5
|
+
* plural forms, interpolation, fallback, middleware, and helpers.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, test, expect, beforeEach, afterEach } from "bun:test";
|
|
9
|
+
import { mkdirSync, rmSync, writeFileSync } from "fs";
|
|
10
|
+
import { resolve } from "path";
|
|
11
|
+
import {
|
|
12
|
+
I18n,
|
|
13
|
+
createI18n,
|
|
14
|
+
LocaleNegotiator,
|
|
15
|
+
parseAcceptLanguage,
|
|
16
|
+
normaliseLocale,
|
|
17
|
+
TranslationLoader,
|
|
18
|
+
i18nMiddleware,
|
|
19
|
+
getLocale,
|
|
20
|
+
getT,
|
|
21
|
+
} from "../../src/i18n";
|
|
22
|
+
import { Context } from "../../src/context";
|
|
23
|
+
|
|
24
|
+
const TEST_DIR = resolve("./tests/.i18n");
|
|
25
|
+
|
|
26
|
+
// ============= Helpers =============
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Create a locale JSON file for testing
|
|
30
|
+
*/
|
|
31
|
+
function createLocaleFile(locale: string, data: Record<string, unknown>) {
|
|
32
|
+
mkdirSync(TEST_DIR, { recursive: true });
|
|
33
|
+
writeFileSync(
|
|
34
|
+
resolve(TEST_DIR, `${locale}.json`),
|
|
35
|
+
JSON.stringify(data)
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Clean up test directory
|
|
41
|
+
*/
|
|
42
|
+
function cleanup() {
|
|
43
|
+
try { rmSync(TEST_DIR, { recursive: true, force: true }); } catch {}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ============= 1. parseAcceptLanguage =============
|
|
47
|
+
|
|
48
|
+
describe("parseAcceptLanguage", () => {
|
|
49
|
+
test("should parse simple header", () => {
|
|
50
|
+
const result = parseAcceptLanguage("en");
|
|
51
|
+
expect(result).toHaveLength(1);
|
|
52
|
+
expect(result[0]!.locale).toBe("en");
|
|
53
|
+
expect(result[0]!.quality).toBe(1.0);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("should parse multi-value header with qualities", () => {
|
|
57
|
+
const result = parseAcceptLanguage("en-US,en;q=0.9,fr;q=0.8");
|
|
58
|
+
expect(result).toHaveLength(3);
|
|
59
|
+
expect(result[0]!.locale).toBe("en-US");
|
|
60
|
+
expect(result[0]!.quality).toBe(1.0);
|
|
61
|
+
expect(result[1]!.locale).toBe("en");
|
|
62
|
+
expect(result[1]!.quality).toBe(0.9);
|
|
63
|
+
expect(result[2]!.locale).toBe("fr");
|
|
64
|
+
expect(result[2]!.quality).toBe(0.8);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("should sort by quality descending", () => {
|
|
68
|
+
const result = parseAcceptLanguage("fr;q=0.5,en;q=0.9,de;q=0.7");
|
|
69
|
+
expect(result.map((e) => e.locale)).toEqual(["en", "de", "fr"]);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("should return empty array for empty string", () => {
|
|
73
|
+
expect(parseAcceptLanguage("")).toHaveLength(0);
|
|
74
|
+
expect(parseAcceptLanguage(" ")).toHaveLength(0);
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
// ============= 2. normaliseLocale =============
|
|
79
|
+
|
|
80
|
+
describe("normaliseLocale", () => {
|
|
81
|
+
test("should lowercase language subtag", () => {
|
|
82
|
+
expect(normaliseLocale("EN")).toBe("en");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("should uppercase region subtag", () => {
|
|
86
|
+
expect(normaliseLocale("en-us")).toBe("en-US");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("should convert underscore to hyphen", () => {
|
|
90
|
+
expect(normaliseLocale("en_US")).toBe("en-US");
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("should handle single subtag", () => {
|
|
94
|
+
expect(normaliseLocale("fr")).toBe("fr");
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// ============= 3. LocaleNegotiator =============
|
|
99
|
+
|
|
100
|
+
describe("LocaleNegotiator", () => {
|
|
101
|
+
let negotiator: LocaleNegotiator;
|
|
102
|
+
|
|
103
|
+
beforeEach(() => {
|
|
104
|
+
negotiator = new LocaleNegotiator(["en", "fr", "de"], "en");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("should return exact match", () => {
|
|
108
|
+
const match = negotiator.negotiate("fr");
|
|
109
|
+
expect(match.locale).toBe("fr");
|
|
110
|
+
expect(match.strategy).toBe("exact");
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("should match by language prefix ('fr-CA' → 'fr')", () => {
|
|
114
|
+
const match = negotiator.negotiate("fr-CA");
|
|
115
|
+
expect(match.locale).toBe("fr");
|
|
116
|
+
expect(match.strategy).toBe("prefix");
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("should prefer higher quality locale", () => {
|
|
120
|
+
const match = negotiator.negotiate("de;q=0.9,fr;q=0.8");
|
|
121
|
+
expect(match.locale).toBe("de");
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("should fall back to default when no match", () => {
|
|
125
|
+
const match = negotiator.negotiate("zh-TW,zh;q=0.9");
|
|
126
|
+
expect(match.locale).toBe("en");
|
|
127
|
+
expect(match.strategy).toBe("default");
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("should return default for empty header", () => {
|
|
131
|
+
const match = negotiator.negotiate("");
|
|
132
|
+
expect(match.locale).toBe("en");
|
|
133
|
+
expect(match.strategy).toBe("default");
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("should validate supported locale", () => {
|
|
137
|
+
expect(negotiator.isSupported("fr")).toBe(true);
|
|
138
|
+
expect(negotiator.isSupported("zh")).toBe(false);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("should prefer exact over prefix match across entries", () => {
|
|
142
|
+
// fr-CA is exact candidate, fr is prefix candidate for fr-BE
|
|
143
|
+
const neg = new LocaleNegotiator(["en", "fr", "fr-CA"], "en");
|
|
144
|
+
const match = neg.negotiate("fr-CA,fr-BE;q=0.9");
|
|
145
|
+
expect(match.locale).toBe("fr-CA");
|
|
146
|
+
expect(match.strategy).toBe("exact");
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
// ============= 4. TranslationLoader =============
|
|
151
|
+
|
|
152
|
+
describe("TranslationLoader", () => {
|
|
153
|
+
afterEach(cleanup);
|
|
154
|
+
|
|
155
|
+
test("should load flat JSON locale file", () => {
|
|
156
|
+
createLocaleFile("en", { welcome: "Welcome", "nav.home": "Home" });
|
|
157
|
+
const loader = new TranslationLoader({
|
|
158
|
+
defaultLocale: "en",
|
|
159
|
+
supportedLocales: ["en"],
|
|
160
|
+
basePath: TEST_DIR,
|
|
161
|
+
fallbackToDefault: true,
|
|
162
|
+
cookieName: "bueno_locale",
|
|
163
|
+
cookieMaxAge: 31536000,
|
|
164
|
+
});
|
|
165
|
+
const bundle = loader.load("en");
|
|
166
|
+
expect(bundle.translations.get("welcome")).toBe("Welcome");
|
|
167
|
+
expect(bundle.translations.get("nav.home")).toBe("Home");
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test("should flatten nested JSON keys", () => {
|
|
171
|
+
createLocaleFile("en", {
|
|
172
|
+
nav: { home: "Home", about: "About" },
|
|
173
|
+
greeting: "Hi",
|
|
174
|
+
});
|
|
175
|
+
const loader = new TranslationLoader({
|
|
176
|
+
defaultLocale: "en",
|
|
177
|
+
supportedLocales: ["en"],
|
|
178
|
+
basePath: TEST_DIR,
|
|
179
|
+
fallbackToDefault: true,
|
|
180
|
+
cookieName: "bueno_locale",
|
|
181
|
+
cookieMaxAge: 31536000,
|
|
182
|
+
});
|
|
183
|
+
const bundle = loader.load("en");
|
|
184
|
+
expect(bundle.translations.get("nav.home")).toBe("Home");
|
|
185
|
+
expect(bundle.translations.get("nav.about")).toBe("About");
|
|
186
|
+
expect(bundle.translations.get("greeting")).toBe("Hi");
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test("should return empty bundle for missing non-default locale", () => {
|
|
190
|
+
createLocaleFile("en", { welcome: "Welcome" });
|
|
191
|
+
const loader = new TranslationLoader({
|
|
192
|
+
defaultLocale: "en",
|
|
193
|
+
supportedLocales: ["en", "fr"],
|
|
194
|
+
basePath: TEST_DIR,
|
|
195
|
+
fallbackToDefault: true,
|
|
196
|
+
cookieName: "bueno_locale",
|
|
197
|
+
cookieMaxAge: 31536000,
|
|
198
|
+
});
|
|
199
|
+
const bundle = loader.load("fr");
|
|
200
|
+
expect(bundle.translations.size).toBe(0);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test("should throw for missing default locale file", () => {
|
|
204
|
+
const loader = new TranslationLoader({
|
|
205
|
+
defaultLocale: "en",
|
|
206
|
+
supportedLocales: ["en"],
|
|
207
|
+
basePath: TEST_DIR,
|
|
208
|
+
fallbackToDefault: true,
|
|
209
|
+
cookieName: "bueno_locale",
|
|
210
|
+
cookieMaxAge: 31536000,
|
|
211
|
+
});
|
|
212
|
+
expect(() => loader.load("en")).toThrow(/Default locale file not found/);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test("should cache loaded bundle", () => {
|
|
216
|
+
createLocaleFile("en", { welcome: "Welcome" });
|
|
217
|
+
const loader = new TranslationLoader({
|
|
218
|
+
defaultLocale: "en",
|
|
219
|
+
supportedLocales: ["en"],
|
|
220
|
+
basePath: TEST_DIR,
|
|
221
|
+
fallbackToDefault: true,
|
|
222
|
+
cookieName: "bueno_locale",
|
|
223
|
+
cookieMaxAge: 31536000,
|
|
224
|
+
});
|
|
225
|
+
const b1 = loader.load("en");
|
|
226
|
+
const b2 = loader.load("en");
|
|
227
|
+
// Same object reference (from cache)
|
|
228
|
+
expect(b1).toBe(b2);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
test("should invalidate cache", () => {
|
|
232
|
+
createLocaleFile("en", { welcome: "Welcome" });
|
|
233
|
+
const loader = new TranslationLoader({
|
|
234
|
+
defaultLocale: "en",
|
|
235
|
+
supportedLocales: ["en"],
|
|
236
|
+
basePath: TEST_DIR,
|
|
237
|
+
fallbackToDefault: true,
|
|
238
|
+
cookieName: "bueno_locale",
|
|
239
|
+
cookieMaxAge: 31536000,
|
|
240
|
+
});
|
|
241
|
+
const b1 = loader.load("en");
|
|
242
|
+
loader.invalidate("en");
|
|
243
|
+
const b2 = loader.load("en");
|
|
244
|
+
expect(b1).not.toBe(b2);
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
// ============= 5. I18n Engine — Core Translation =============
|
|
249
|
+
|
|
250
|
+
describe("I18n engine", () => {
|
|
251
|
+
afterEach(cleanup);
|
|
252
|
+
|
|
253
|
+
function makeEngine() {
|
|
254
|
+
createLocaleFile("en", {
|
|
255
|
+
welcome: "Welcome",
|
|
256
|
+
greeting: "Hello, {{name}}!",
|
|
257
|
+
nav: { home: "Home", about: "About" },
|
|
258
|
+
items_zero: "No items",
|
|
259
|
+
items_one: "One item",
|
|
260
|
+
items_other: "{{count}} items",
|
|
261
|
+
});
|
|
262
|
+
createLocaleFile("fr", {
|
|
263
|
+
welcome: "Bienvenue",
|
|
264
|
+
greeting: "Bonjour, {{name}}!",
|
|
265
|
+
});
|
|
266
|
+
return createI18n({
|
|
267
|
+
defaultLocale: "en",
|
|
268
|
+
supportedLocales: ["en", "fr"],
|
|
269
|
+
basePath: TEST_DIR,
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
test("should translate a flat key", () => {
|
|
274
|
+
const engine = makeEngine();
|
|
275
|
+
expect(engine.t("en", "welcome")).toBe("Welcome");
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
test("should translate a nested key using dot notation", () => {
|
|
279
|
+
const engine = makeEngine();
|
|
280
|
+
expect(engine.t("en", "nav.home")).toBe("Home");
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
test("should interpolate variables", () => {
|
|
284
|
+
const engine = makeEngine();
|
|
285
|
+
expect(engine.t("en", "greeting", { name: "Alice" })).toBe("Hello, Alice!");
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
test("should return empty string for missing variable", () => {
|
|
289
|
+
const engine = makeEngine();
|
|
290
|
+
expect(engine.t("en", "greeting", {})).toBe("Hello, !");
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
test("should fall back to default locale for missing key", () => {
|
|
294
|
+
const engine = makeEngine();
|
|
295
|
+
// 'nav.home' exists in 'en' but not in 'fr'
|
|
296
|
+
expect(engine.t("fr", "nav.home")).toBe("Home");
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
test("should return key string for complete miss", () => {
|
|
300
|
+
const engine = makeEngine();
|
|
301
|
+
expect(engine.t("en", "nonexistent.key")).toBe("nonexistent.key");
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
test("should translate in requested locale when available", () => {
|
|
305
|
+
const engine = makeEngine();
|
|
306
|
+
expect(engine.t("fr", "welcome")).toBe("Bienvenue");
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
// ============= Plural forms =============
|
|
310
|
+
|
|
311
|
+
describe("Plural forms", () => {
|
|
312
|
+
test("should use _zero form for count=0", () => {
|
|
313
|
+
const engine = makeEngine();
|
|
314
|
+
expect(engine.t("en", "items", { count: 0 })).toBe("No items");
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
test("should use _one form for count=1", () => {
|
|
318
|
+
const engine = makeEngine();
|
|
319
|
+
expect(engine.t("en", "items", { count: 1 })).toBe("One item");
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
test("should use _other form for count=3", () => {
|
|
323
|
+
const engine = makeEngine();
|
|
324
|
+
expect(engine.t("en", "items", { count: 3 })).toBe("3 items");
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
test("should fall back to bare key when plural variants missing", () => {
|
|
328
|
+
const engine = makeEngine();
|
|
329
|
+
// count=1 → looks for welcome_one → not found → falls back to welcome
|
|
330
|
+
expect(engine.t("en", "welcome", { count: 1 })).toBe("Welcome");
|
|
331
|
+
});
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
// ============= Metrics =============
|
|
335
|
+
|
|
336
|
+
describe("Metrics", () => {
|
|
337
|
+
test("should track hits, fallbacks, misses", () => {
|
|
338
|
+
const engine = makeEngine();
|
|
339
|
+
engine.t("en", "welcome"); // hit
|
|
340
|
+
engine.t("fr", "nav.home"); // fallback
|
|
341
|
+
engine.t("en", "does.not.exist"); // miss
|
|
342
|
+
const m = engine.getMetrics();
|
|
343
|
+
expect(m.totalLookups).toBe(3);
|
|
344
|
+
expect(m.hits).toBe(1);
|
|
345
|
+
expect(m.fallbacks).toBe(1);
|
|
346
|
+
expect(m.misses).toBe(1);
|
|
347
|
+
});
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
// ============= createTranslator =============
|
|
351
|
+
|
|
352
|
+
test("createTranslator should return bound t function", () => {
|
|
353
|
+
const engine = makeEngine();
|
|
354
|
+
const t = engine.createTranslator("fr");
|
|
355
|
+
expect(t("welcome")).toBe("Bienvenue");
|
|
356
|
+
expect(t("nav.home")).toBe("Home"); // falls back to 'en'
|
|
357
|
+
});
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
// ============= 6. i18nMiddleware =============
|
|
361
|
+
|
|
362
|
+
describe("i18nMiddleware", () => {
|
|
363
|
+
afterEach(cleanup);
|
|
364
|
+
|
|
365
|
+
function createRequest(headers: Record<string, string> = {}): Request {
|
|
366
|
+
return new Request("http://localhost/test", { headers });
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function runMiddleware(req: Request, options: Record<string, unknown> = {}) {
|
|
370
|
+
createLocaleFile("en", { welcome: "Welcome" });
|
|
371
|
+
createLocaleFile("fr", { welcome: "Bienvenue" });
|
|
372
|
+
const middleware = i18nMiddleware({
|
|
373
|
+
defaultLocale: "en",
|
|
374
|
+
supportedLocales: ["en", "fr"],
|
|
375
|
+
basePath: TEST_DIR,
|
|
376
|
+
...options,
|
|
377
|
+
});
|
|
378
|
+
const ctx = new Context(req);
|
|
379
|
+
const handler = async () => new Response("OK");
|
|
380
|
+
return { ctx, run: () => middleware(ctx, handler) };
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
test("should detect locale from Accept-Language header", async () => {
|
|
384
|
+
const req = createRequest({ "accept-language": "fr,en;q=0.9" });
|
|
385
|
+
const { ctx, run } = runMiddleware(req);
|
|
386
|
+
await run();
|
|
387
|
+
expect(getLocale(ctx)).toBe("fr");
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
test("should prefer cookie over Accept-Language header", async () => {
|
|
391
|
+
const req = createRequest({
|
|
392
|
+
"accept-language": "fr",
|
|
393
|
+
"cookie": "bueno_locale=en",
|
|
394
|
+
});
|
|
395
|
+
const { ctx, run } = runMiddleware(req);
|
|
396
|
+
await run();
|
|
397
|
+
expect(getLocale(ctx)).toBe("en");
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
test("should fall back to default locale", async () => {
|
|
401
|
+
const req = createRequest({ "accept-language": "zh-TW" });
|
|
402
|
+
const { ctx, run } = runMiddleware(req);
|
|
403
|
+
await run();
|
|
404
|
+
expect(getLocale(ctx)).toBe("en");
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
test("should set t() on context", async () => {
|
|
408
|
+
const req = createRequest({ "accept-language": "fr" });
|
|
409
|
+
const { ctx, run } = runMiddleware(req);
|
|
410
|
+
await run();
|
|
411
|
+
const t = getT(ctx);
|
|
412
|
+
expect(t("welcome")).toBe("Bienvenue");
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
test("should set Set-Cookie header on response", async () => {
|
|
416
|
+
const req = createRequest({ "accept-language": "fr" });
|
|
417
|
+
const { run } = runMiddleware(req);
|
|
418
|
+
const response = await run();
|
|
419
|
+
const setCookie = response.headers.get("set-cookie");
|
|
420
|
+
expect(setCookie).toContain("bueno_locale=fr");
|
|
421
|
+
expect(setCookie).toContain("Max-Age=31536000");
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
test("should set Vary: Accept-Language header", async () => {
|
|
425
|
+
const req = createRequest();
|
|
426
|
+
const { run } = runMiddleware(req);
|
|
427
|
+
const response = await run();
|
|
428
|
+
expect(response.headers.get("vary")).toContain("Accept-Language");
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
test("should ignore unsupported cookie locale and use header", async () => {
|
|
432
|
+
const req = createRequest({
|
|
433
|
+
"accept-language": "fr",
|
|
434
|
+
"cookie": "bueno_locale=zh", // unsupported
|
|
435
|
+
});
|
|
436
|
+
const { ctx, run } = runMiddleware(req);
|
|
437
|
+
await run();
|
|
438
|
+
expect(getLocale(ctx)).toBe("fr"); // fell through to header
|
|
439
|
+
});
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
// ============= 7. getLocale / getT helpers =============
|
|
443
|
+
|
|
444
|
+
describe("getLocale and getT helpers", () => {
|
|
445
|
+
test("getLocale should return 'en' when middleware has not run", () => {
|
|
446
|
+
const ctx = new Context(new Request("http://localhost/"));
|
|
447
|
+
expect(getLocale(ctx)).toBe("en");
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
test("getT should return identity function when middleware has not run", () => {
|
|
451
|
+
const ctx = new Context(new Request("http://localhost/"));
|
|
452
|
+
const t = getT(ctx);
|
|
453
|
+
expect(t("any.key")).toBe("any.key");
|
|
454
|
+
});
|
|
455
|
+
});
|