@cosmicdrift/kumiko-bundled-features 0.176.1 → 0.176.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.176.1",
3
+ "version": "0.176.2",
4
4
  "description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -120,12 +120,12 @@
120
120
  "./step-dispatcher": "./src/step-dispatcher/index.ts"
121
121
  },
122
122
  "dependencies": {
123
- "@cosmicdrift/kumiko-dispatcher-live": "0.176.1",
124
- "@cosmicdrift/kumiko-framework": "0.176.1",
125
- "@cosmicdrift/kumiko-headless": "0.176.1",
126
- "@cosmicdrift/kumiko-renderer": "0.176.1",
127
- "@cosmicdrift/kumiko-renderer-web": "0.176.1",
128
- "@cosmicdrift/kumiko-types": "0.176.1",
123
+ "@cosmicdrift/kumiko-dispatcher-live": "0.176.2",
124
+ "@cosmicdrift/kumiko-framework": "0.176.2",
125
+ "@cosmicdrift/kumiko-headless": "0.176.2",
126
+ "@cosmicdrift/kumiko-renderer": "0.176.2",
127
+ "@cosmicdrift/kumiko-renderer-web": "0.176.2",
128
+ "@cosmicdrift/kumiko-types": "0.176.2",
129
129
  "@mollie/api-client": "^4.5.0",
130
130
  "imapflow": "^1.3.3",
131
131
  "mailparser": "^3.9.8",
@@ -86,19 +86,21 @@ function ok<T>(value: T): GateOk<T> {
86
86
  return { ok: true, value };
87
87
  }
88
88
 
89
+ type AuthenticatableUserRow = AuthUserRow & { readonly passwordHash: string };
90
+
89
91
  /** Uniform response on any credential miss — burns argon2 cost (#774). */
90
92
  export async function gateResolveAuthUser(
91
93
  ctx: HandlerContext,
92
94
  systemUser: SessionUser,
93
95
  email: string,
94
96
  password: string,
95
- ): Promise<GateOutcome<AuthUserRow>> {
97
+ ): Promise<GateOutcome<AuthenticatableUserRow>> {
96
98
  const found = parseAuthUserRow(await ctx.queryAs(systemUser, UserQueries.findForAuth, { email }));
97
99
  if (!found?.passwordHash || found.isDeleted) {
98
100
  await verifyDummyPassword(password);
99
101
  return reject(invalidCredentials());
100
102
  }
101
- return ok(found);
103
+ return ok({ ...found, passwordHash: found.passwordHash });
102
104
  }
103
105
 
104
106
  /**
@@ -124,14 +126,12 @@ export async function gateEnforceLockout(
124
126
  /** Verify password; record miss / clear lockout on hit. */
125
127
  export async function gateVerifyPassword(
126
128
  ctx: HandlerContext,
127
- found: AuthUserRow,
129
+ found: AuthenticatableUserRow,
128
130
  password: string,
129
131
  maxFailedAttempts: number,
130
132
  lockoutDurationMinutes: number,
131
133
  ): Promise<GateOutcome<undefined>> {
132
- const passwordHash = found.passwordHash;
133
- if (!passwordHash) return reject(invalidCredentials());
134
- const passwordOk = await verifyPassword(passwordHash, password);
134
+ const passwordOk = await verifyPassword(found.passwordHash, password);
135
135
  if (!passwordOk) {
136
136
  if (ctx.redis) {
137
137
  await recordFailedAttempt(ctx.redis, found.id, maxFailedAttempts, lockoutDurationMinutes);
@@ -88,8 +88,13 @@ describe("buildHandlerContext ctx.tz resolution", () => {
88
88
  });
89
89
 
90
90
  test("ctx.tz.user reads SessionUser.timezone independently of tenant", async () => {
91
- // Same tenant as the previous test (Europe/Berlin already set) — proves
92
- // user overrides without needing to touch tenant config.
91
+ const admin = createTestUser({ id: 13, roles: ["Admin"] });
92
+ await stack.http.writeOk(
93
+ "config:write:set",
94
+ { key: "tenant:config:timezone", value: "Europe/Berlin" },
95
+ admin,
96
+ );
97
+
93
98
  const user = createTestUser({ id: 12, timezone: "Asia/Tokyo" });
94
99
  const res = await stack.http.writeOk<{ tenant: string; user: string }>(
95
100
  "probe:write:read-tz",
@@ -1,39 +1,22 @@
1
1
  // Catalog + trigger hardening for #1602 — manual-only surface.
2
+ // Real HTTP via setupTestStack — no mocks, mirrors jobs-security.integration.test.ts.
2
3
 
3
4
  import { afterAll, beforeAll, describe, expect, test } from "bun:test";
4
- import { buildServer, type JwtHelper } from "@cosmicdrift/kumiko-framework/api";
5
- import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
5
+ import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
6
6
  import {
7
- createRegistry,
8
- defineFeature,
9
- type SessionUser,
10
- } from "@cosmicdrift/kumiko-framework/engine";
11
- import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
12
- import { createJobRunner, type JobRunner } from "@cosmicdrift/kumiko-framework/jobs";
13
- import {
14
- createTestDb,
15
- createTestRedis,
16
- type TestDb,
17
- type TestRedis,
7
+ setupTestStack,
8
+ type TestStack,
18
9
  TestUsers,
19
10
  unsafePushTables,
20
11
  } from "@cosmicdrift/kumiko-framework/stack";
21
- import type { Hono } from "hono";
22
12
  import { z } from "zod";
23
13
  import { JobErrors, JobHandlers, JobQueries } from "../constants";
24
14
  import { createJobsFeature } from "../feature";
25
- import { createJobRunLogger } from "../job-run-logger";
26
15
  import { jobRunLogsTable, jobRunsTable } from "../job-run-table";
27
16
 
28
- let testDb: TestDb;
29
- let testRedis: TestRedis;
30
- let db: DbConnection;
31
- let app: Hono;
32
- let jwt: JwtHelper;
33
- let jobRunner: JobRunner;
17
+ let stack: TestStack;
34
18
 
35
19
  const systemAdmin = TestUsers.systemAdmin;
36
- const JWT_SECRET = "test-jwt-secret-for-jobs-catalog-32chars!!";
37
20
 
38
21
  const appFeature = defineFeature("catalog-app", (r) => {
39
22
  r.job(
@@ -45,115 +28,70 @@ const appFeature = defineFeature("catalog-app", (r) => {
45
28
  });
46
29
 
47
30
  beforeAll(async () => {
48
- testDb = await createTestDb();
49
- testRedis = await createTestRedis();
50
- db = testDb.db;
51
-
52
- const registry = createRegistry([appFeature, createJobsFeature()]);
53
- await unsafePushTables(db, { jobRunsTable, jobRunLogsTable });
54
- await createEventsTable(db);
55
-
56
- const redisUrl = `redis://${testRedis.redis.options.host}:${testRedis.redis.options.port}/${testRedis.redis.options.db}`;
57
- const logger = createJobRunLogger({ db, registry });
58
- jobRunner = createJobRunner({
59
- registry,
60
- context: { db },
61
- redisUrl,
62
- consumerLane: "worker",
63
- queueNamePrefix: `kumiko-jobs-catalog-test-${Date.now()}`,
64
- ...logger,
31
+ stack = await setupTestStack({
32
+ features: [appFeature, createJobsFeature()],
33
+ jobs: { consumerLane: "worker", queueNamePrefix: `kumiko-jobs-catalog-test-${Date.now()}` },
65
34
  });
66
- const context = { db, registry, jobRunner };
67
- const server = buildServer({ registry, context, jwtSecret: JWT_SECRET });
68
- app = server.app;
69
- jwt = server.jwt;
70
-
71
- await jobRunner.start();
35
+ await unsafePushTables(stack.db, { jobRunsTable, jobRunLogsTable });
72
36
  });
73
37
 
74
38
  afterAll(async () => {
75
- await jobRunner.stop();
76
- await testDb.cleanup();
77
- await testRedis.cleanup();
39
+ await stack.cleanup();
78
40
  });
79
41
 
80
- async function req(
81
- method: string,
82
- path: string,
83
- user: SessionUser,
84
- body?: unknown,
85
- ): Promise<Response> {
86
- const token = await jwt.sign(user);
87
- const init: RequestInit = {
88
- method,
89
- headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
90
- };
91
- if (body) init.body = JSON.stringify(body);
92
- return app.request(path, init);
93
- }
94
-
95
- async function write(user: SessionUser, type: string, payload: unknown) {
96
- const res = await req("POST", "/api/write", user, { type, payload });
97
- return res.json();
98
- }
99
-
100
- async function query(user: SessionUser, type: string, payload: unknown) {
101
- const res = await req("POST", "/api/query", user, { type, payload });
102
- const body = await res.json();
103
- if (res.status !== 200) {
104
- throw new Error(`query ${type} → ${res.status}: ${JSON.stringify(body)}`);
105
- }
106
- return body;
107
- }
108
-
109
42
  describe("jobs:query:catalog", () => {
110
43
  test("lists only manual jobs including framework builtins", async () => {
111
- const result = await query(systemAdmin, JobQueries.catalog, {});
112
44
  type CatalogRow = {
113
45
  readonly jobName: string;
114
46
  readonly perTenant: boolean;
115
47
  readonly payloadSchema: Record<string, unknown> | null;
116
48
  };
117
- const rows = (result.data as { rows: readonly CatalogRow[] }).rows;
118
- const names = rows.map((r) => r.jobName);
49
+ const result = await stack.http.queryOk<{ rows: readonly CatalogRow[] }>(
50
+ JobQueries.catalog,
51
+ {},
52
+ systemAdmin,
53
+ );
54
+ const names = result.rows.map((r) => r.jobName);
119
55
  expect(names).toContain("catalog-app:job:manual-echo");
120
56
  expect(names).toContain("jobs:job:reindex-entity");
121
57
  expect(names).toContain("jobs:job:projection-rebuild");
122
58
  expect(names).not.toContain("catalog-app:job:cron-only");
123
59
 
124
- const echo = rows.find((r) => r.jobName === "catalog-app:job:manual-echo");
60
+ const echo = result.rows.find((r) => r.jobName === "catalog-app:job:manual-echo");
125
61
  expect(echo).toBeDefined();
126
62
  expect(echo?.payloadSchema).not.toBeNull();
127
63
 
128
- const reindex = rows.find((r) => r.jobName === "jobs:job:reindex-entity");
64
+ const reindex = result.rows.find((r) => r.jobName === "jobs:job:reindex-entity");
129
65
  expect(reindex?.perTenant).toBe(true);
130
66
  });
131
67
  });
132
68
 
133
69
  describe("jobs:write:trigger hardening", () => {
134
70
  test("rejects cron-only jobs", async () => {
135
- const result = await write(systemAdmin, JobHandlers.trigger, {
136
- jobName: "catalog-app:job:cron-only",
137
- });
138
- expect(result.isSuccess).toBe(false);
139
- expect(result.error?.code).toBe("unprocessable");
140
- expect(result.error?.details).toMatchObject({ reason: JobErrors.notManual });
71
+ const err = await stack.http.writeErr(
72
+ JobHandlers.trigger,
73
+ { jobName: "catalog-app:job:cron-only" },
74
+ systemAdmin,
75
+ );
76
+ expect(err.code).toBe("unprocessable");
77
+ expect(err.details).toMatchObject({ reason: JobErrors.notManual });
141
78
  });
142
79
 
143
80
  test("rejects invalid payload against job schema", async () => {
144
- const result = await write(systemAdmin, JobHandlers.trigger, {
145
- jobName: "catalog-app:job:manual-echo",
146
- payload: {},
147
- });
148
- expect(result.isSuccess).toBe(false);
149
- expect(result.error?.code).toBe("validation_error");
81
+ const err = await stack.http.writeErr(
82
+ JobHandlers.trigger,
83
+ { jobName: "catalog-app:job:manual-echo", payload: {} },
84
+ systemAdmin,
85
+ );
86
+ expect(err.code).toBe("validation_error");
150
87
  });
151
88
 
152
89
  test("accepts valid schema payload", async () => {
153
- const result = await write(systemAdmin, JobHandlers.trigger, {
154
- jobName: "catalog-app:job:manual-echo",
155
- payload: { entity: "credit" },
156
- });
157
- expect(result.isSuccess).toBe(true);
90
+ const result = await stack.http.writeOk<{ jobName: string; bullJobId: string }>(
91
+ JobHandlers.trigger,
92
+ { jobName: "catalog-app:job:manual-echo", payload: { entity: "credit" } },
93
+ systemAdmin,
94
+ );
95
+ expect(result.jobName).toBe("catalog-app:job:manual-echo");
158
96
  });
159
97
  });
@@ -86,6 +86,16 @@ export function JobRunsScreen(): ReactNode {
86
86
  ? JSON.stringify(selected.payloadSchema, null, 2)
87
87
  : null;
88
88
 
89
+ // Switching jobs invalidates any typed payload/messages against the new
90
+ // job's schema — reset so a submit can't validate stale payload text
91
+ // against the wrong job.
92
+ const handleJobNameChange = (name: string): void => {
93
+ setJobName(name);
94
+ setPayloadText("{}");
95
+ setClientError(null);
96
+ setSuccessMessage(null);
97
+ };
98
+
89
99
  const onTrigger = async (): Promise<void> => {
90
100
  setClientError(null);
91
101
  setSuccessMessage(null);
@@ -168,7 +178,7 @@ export function JobRunsScreen(): ReactNode {
168
178
  id="job-trigger-name"
169
179
  name="job-trigger-name"
170
180
  value={jobName}
171
- onChange={setJobName}
181
+ onChange={handleJobNameChange}
172
182
  options={jobOptions}
173
183
  />
174
184
  </Field>
@@ -12,6 +12,7 @@ import { defaultPrimitives } from "@cosmicdrift/kumiko-renderer-web";
12
12
  import { act, fireEvent, render, screen } from "@testing-library/react";
13
13
  import type { ReactNode } from "react";
14
14
  import { textBlocksClient } from "../client-plugin";
15
+ import { defaultTranslations } from "../i18n";
15
16
 
16
17
  mock.module("@cosmicdrift/kumiko-bundled-features/auth-email-password/web", () => ({
17
18
  useShellUser: mock(),
@@ -50,11 +51,22 @@ function getEditor() {
50
51
  return Editor;
51
52
  }
52
53
 
53
- const localeResolver = createStaticLocaleResolver();
54
+ const localeResolver = createStaticLocaleResolver({ locale: "de" });
54
55
 
55
56
  function Wrapper({ children }: { readonly children: ReactNode }): ReactNode {
56
57
  return (
57
- <LocaleProvider resolver={localeResolver}>
58
+ <LocaleProvider resolver={localeResolver} fallbackBundles={[defaultTranslations]}>
59
+ <PrimitivesProvider value={defaultPrimitives}>{children}</PrimitivesProvider>
60
+ </LocaleProvider>
61
+ );
62
+ }
63
+
64
+ function EnglishWrapper({ children }: { readonly children: ReactNode }): ReactNode {
65
+ return (
66
+ <LocaleProvider
67
+ resolver={createStaticLocaleResolver({ locale: "en" })}
68
+ fallbackBundles={[defaultTranslations]}
69
+ >
58
70
  <PrimitivesProvider value={defaultPrimitives}>{children}</PrimitivesProvider>
59
71
  </LocaleProvider>
60
72
  );
@@ -115,6 +127,19 @@ describe("TextContentEditor — role-based write-access", () => {
115
127
  });
116
128
  });
117
129
 
130
+ describe("TextContentEditor — en locale", () => {
131
+ test("TenantAdmin sieht englische Labels statt hartcodiertem Deutsch (#1754)", () => {
132
+ // biome-ignore lint/suspicious/noExplicitAny: Bun mock function
133
+ (useShellUser as any).mockReturnValue({ id: "u1", roles: ["TenantAdmin"] });
134
+ const Editor = getEditor();
135
+ render(<Editor target={TARGET} onClose={() => {}} />, { wrapper: EnglishWrapper });
136
+
137
+ expect(screen.getByRole("button", { name: /^save$/i })).toBeTruthy();
138
+ expect(screen.getByLabelText(/title/i)).toBeTruthy();
139
+ expect(screen.getByLabelText(/content/i)).toBeTruthy();
140
+ });
141
+ });
142
+
118
143
  describe("TextContentEditor — handleSave", () => {
119
144
  test("reicht das geladene folder unverändert an den Write-Payload durch (#898)", async () => {
120
145
  // biome-ignore lint/suspicious/noExplicitAny: Bun mock function
@@ -17,10 +17,16 @@ import type {
17
17
  TreeChildrenSubscribe,
18
18
  TreeNode,
19
19
  } from "@cosmicdrift/kumiko-framework/engine";
20
- import { useDispatcher, usePrimitives, useQuery } from "@cosmicdrift/kumiko-renderer";
20
+ import {
21
+ useDispatcher,
22
+ usePrimitives,
23
+ useQuery,
24
+ useTranslation,
25
+ } from "@cosmicdrift/kumiko-renderer";
21
26
  import type { ClientFeatureDefinition } from "@cosmicdrift/kumiko-renderer-web";
22
27
  import { type FormEvent, type ReactNode, useEffect, useState } from "react";
23
28
  import { TemplateResolverHandlers, TemplateResolverQueries } from "../qualified-names";
29
+ import { defaultTranslations } from "./i18n";
24
30
 
25
31
  // Exported for the unit test — groupBlocksByFolder is a pure function.
26
32
  export type BlockSummary = {
@@ -211,6 +217,7 @@ function TextBlockEditor({
211
217
  const { Form, Field, Input, Button, Banner } = usePrimitives();
212
218
  const dispatcher = useDispatcher();
213
219
  const user = useShellUser();
220
+ const t = useTranslation();
214
221
  const canWrite =
215
222
  user?.roles.includes("TenantAdmin") === true || user?.roles.includes("SystemAdmin") === true;
216
223
 
@@ -254,14 +261,20 @@ function TextBlockEditor({
254
261
  ...(tenantIdOverride !== undefined && { tenantIdOverride }),
255
262
  });
256
263
  if (result.isSuccess) {
257
- setSavedMsg(result.data.isNew ? "Neu angelegt." : "Gespeichert.");
264
+ setSavedMsg(
265
+ result.data.isNew
266
+ ? t("template-resolver.editor.created")
267
+ : t("template-resolver.editor.saved"),
268
+ );
258
269
  return;
259
270
  }
260
- setSaveError(result.error.message ?? result.error.code ?? "Speichern fehlgeschlagen.");
271
+ setSaveError(
272
+ result.error.message ?? result.error.code ?? t("template-resolver.editor.saveFailed"),
273
+ );
261
274
  } catch (e) {
262
275
  // Network blip / dispatcher throw — otherwise submitting stays true and
263
276
  // the save button is locked forever with no feedback.
264
- setSaveError(e instanceof Error ? e.message : "Netzwerkfehler beim Speichern.");
277
+ setSaveError(e instanceof Error ? e.message : t("template-resolver.editor.networkError"));
265
278
  } finally {
266
279
  setSubmitting(false);
267
280
  }
@@ -283,21 +296,21 @@ function TextBlockEditor({
283
296
  actions={
284
297
  canWrite ? (
285
298
  <Button type="submit" loading={submitting} disabled={disabled}>
286
- {submitting ? "Speichern…" : "Speichern"}
299
+ {submitting ? t("template-resolver.editor.saving") : t("template-resolver.editor.save")}
287
300
  </Button>
288
301
  ) : undefined
289
302
  }
290
303
  >
291
- {loading && <Banner variant="loading">Lädt aktuellen Stand…</Banner>}
304
+ {loading && <Banner variant="loading">{t("template-resolver.editor.loading")}</Banner>}
292
305
  {loadError !== null && (
293
- <Banner variant="error">Konnte Block nicht laden: {loadError.code}</Banner>
306
+ <Banner variant="error">
307
+ {t("template-resolver.editor.loadFailed")}: {loadError.code}
308
+ </Banner>
294
309
  )}
295
310
  {!canWrite && !loading && (
296
- <Banner variant="info">
297
- Read-only — TenantAdmin- oder SystemAdmin-Rolle f&uuml;r &Auml;nderungen erforderlich.
298
- </Banner>
311
+ <Banner variant="info">{t("template-resolver.editor.readOnly")}</Banner>
299
312
  )}
300
- <Field id="text-block-title" label="Titel" required>
313
+ <Field id="text-block-title" label={t("template-resolver.editor.titleLabel")} required>
301
314
  <Input
302
315
  kind="text"
303
316
  id="text-block-title"
@@ -308,7 +321,7 @@ function TextBlockEditor({
308
321
  required
309
322
  />
310
323
  </Field>
311
- <Field id="text-block-content" label="Inhalt">
324
+ <Field id="text-block-content" label={t("template-resolver.editor.contentLabel")}>
312
325
  <Input
313
326
  kind="textarea"
314
327
  id="text-block-content"
@@ -348,5 +361,6 @@ export function textBlocksClient(opts?: {
348
361
  resolvers: {
349
362
  "template-resolver:edit": TextBlockEditor,
350
363
  },
364
+ translations: defaultTranslations,
351
365
  };
352
366
  }
@@ -0,0 +1,39 @@
1
+ // @runtime client
2
+ // Default translation bundle for the text-block editor. textBlocksClient()
3
+ // hangs it into the LocaleProvider as a fallback bundle — apps override
4
+ // individual keys via mergeTranslations at the createKumikoApp level.
5
+ //
6
+ // Keys follow `template-resolver.editor.<slug>`.
7
+
8
+ import type { TranslationsByLocale } from "@cosmicdrift/kumiko-renderer";
9
+
10
+ export const defaultTranslations: TranslationsByLocale = {
11
+ de: {
12
+ "template-resolver.editor.titleLabel": "Titel",
13
+ "template-resolver.editor.contentLabel": "Inhalt",
14
+ "template-resolver.editor.save": "Speichern",
15
+ "template-resolver.editor.saving": "Speichern…",
16
+ "template-resolver.editor.created": "Neu angelegt.",
17
+ "template-resolver.editor.saved": "Gespeichert.",
18
+ "template-resolver.editor.saveFailed": "Speichern fehlgeschlagen.",
19
+ "template-resolver.editor.networkError": "Netzwerkfehler beim Speichern.",
20
+ "template-resolver.editor.loading": "Lädt aktuellen Stand…",
21
+ "template-resolver.editor.loadFailed": "Konnte Block nicht laden",
22
+ "template-resolver.editor.readOnly":
23
+ "Read-only — TenantAdmin- oder SystemAdmin-Rolle für Änderungen erforderlich.",
24
+ },
25
+ en: {
26
+ "template-resolver.editor.titleLabel": "Title",
27
+ "template-resolver.editor.contentLabel": "Content",
28
+ "template-resolver.editor.save": "Save",
29
+ "template-resolver.editor.saving": "Saving…",
30
+ "template-resolver.editor.created": "Created.",
31
+ "template-resolver.editor.saved": "Saved.",
32
+ "template-resolver.editor.saveFailed": "Save failed.",
33
+ "template-resolver.editor.networkError": "Network error while saving.",
34
+ "template-resolver.editor.loading": "Loading current version…",
35
+ "template-resolver.editor.loadFailed": "Could not load block",
36
+ "template-resolver.editor.readOnly":
37
+ "Read-only — TenantAdmin or SystemAdmin role required to make changes.",
38
+ },
39
+ };
@@ -0,0 +1,60 @@
1
+ // Regression guard for fw#1648: dispatch-shared.ts hardcodes the literal
2
+ // "tenant:config:timezone" (TENANT_TIMEZONE_CONFIG_KEY) since framework/pipeline
3
+ // can't import the bundled `tenant` feature. tz-resolution.integration.test.ts
4
+ // exercises that literal against a standalone probe feature, which can't see a
5
+ // drift to the REAL tenant feature's key name — this test boots the actual
6
+ // createTenantFeature() instead.
7
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
8
+ import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
9
+ import {
10
+ createTestUser,
11
+ setupTestStack,
12
+ type TestStack,
13
+ unsafePushTables,
14
+ } from "@cosmicdrift/kumiko-framework/stack";
15
+ import { z } from "zod";
16
+ import { createConfigAccessorFactory, createConfigFeature } from "../../config/feature";
17
+ import { createConfigResolver } from "../../config/resolver";
18
+ import { configValuesTable } from "../../config/table";
19
+ import { createTenantFeature } from "../feature";
20
+
21
+ const probeFeature = defineFeature("tz-probe", (r) => {
22
+ r.requires("tenant");
23
+ r.writeHandler(
24
+ "read-tz",
25
+ z.object({}),
26
+ async (_event, ctx) => ({ isSuccess: true, data: { tenant: ctx.tz.tenant } }),
27
+ { access: { openToAll: true } },
28
+ );
29
+ });
30
+
31
+ describe("ctx.tz.tenant against the real tenant feature (fw#1648)", () => {
32
+ let stack: TestStack;
33
+
34
+ beforeAll(async () => {
35
+ const resolver = createConfigResolver();
36
+ stack = await setupTestStack({
37
+ features: [createConfigFeature(), createTenantFeature(), probeFeature],
38
+ extraContext: ({ registry }) => ({
39
+ configResolver: resolver,
40
+ _configAccessorFactory: createConfigAccessorFactory(registry, resolver),
41
+ }),
42
+ });
43
+ await unsafePushTables(stack.db, { configValuesTable });
44
+ });
45
+
46
+ afterAll(async () => {
47
+ await stack.cleanup();
48
+ });
49
+
50
+ test("ctx.tz.tenant reflects tenant:config:timezone set via the real tenant feature", async () => {
51
+ const admin = createTestUser({ id: 20, roles: ["Admin"] });
52
+ await stack.http.writeOk(
53
+ "config:write:set",
54
+ { key: "tenant:config:timezone", value: "Asia/Tokyo" },
55
+ admin,
56
+ );
57
+ const res = await stack.http.writeOk<{ tenant: string }>("tz-probe:write:read-tz", {}, admin);
58
+ expect(res.tenant).toBe("Asia/Tokyo");
59
+ });
60
+ });