@cosmicdrift/kumiko-bundled-features 0.310.0 → 0.311.0
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 +9 -9
- package/src/audit/__tests__/escape-hatch-audit.integration.test.ts +16 -12
- package/src/delivery/__tests__/delivery.integration.test.ts +21 -29
- package/src/inbound-provider-imap/__tests__/plugin-mocked.test.ts +10 -12
- package/src/jobs/__tests__/projection-rebuild-job.integration.test.ts +11 -9
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.311.0",
|
|
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>",
|
|
@@ -133,12 +133,12 @@
|
|
|
133
133
|
"./workflow-runner": "./src/workflow-runner/index.ts"
|
|
134
134
|
},
|
|
135
135
|
"dependencies": {
|
|
136
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
137
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
138
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
139
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
140
|
-
"@cosmicdrift/kumiko-renderer-web": "0.
|
|
141
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
136
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.311.0",
|
|
137
|
+
"@cosmicdrift/kumiko-framework": "0.311.0",
|
|
138
|
+
"@cosmicdrift/kumiko-headless": "0.311.0",
|
|
139
|
+
"@cosmicdrift/kumiko-renderer": "0.311.0",
|
|
140
|
+
"@cosmicdrift/kumiko-renderer-web": "0.311.0",
|
|
141
|
+
"@cosmicdrift/kumiko-types": "0.311.0",
|
|
142
142
|
"@mollie/api-client": "^4.5.0",
|
|
143
143
|
"@node-rs/argon2": "^2.0.2",
|
|
144
144
|
"@types/mailparser": "^3.4.6",
|
|
@@ -167,8 +167,8 @@
|
|
|
167
167
|
],
|
|
168
168
|
"devDependencies": {
|
|
169
169
|
"@testing-library/user-event": "^14.6.1",
|
|
170
|
-
"@cosmicdrift/kumiko-locale-de": "0.
|
|
171
|
-
"@cosmicdrift/kumiko-locale-es": "0.
|
|
170
|
+
"@cosmicdrift/kumiko-locale-de": "0.311.0",
|
|
171
|
+
"@cosmicdrift/kumiko-locale-es": "0.311.0",
|
|
172
172
|
"jsqr": "^1.4.0"
|
|
173
173
|
}
|
|
174
174
|
}
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
type TestStack,
|
|
13
13
|
testTenantId,
|
|
14
14
|
} from "@cosmicdrift/kumiko-framework/stack";
|
|
15
|
+
import { waitFor } from "@cosmicdrift/kumiko-framework/testing";
|
|
15
16
|
import * as z from "zod";
|
|
16
17
|
import { createConfigFeature } from "../../config";
|
|
17
18
|
import { createTenantFeature } from "../../tenant";
|
|
@@ -71,18 +72,21 @@ type AuditRow = {
|
|
|
71
72
|
type AuditResponse = { rows: AuditRow[]; nextBefore: string | null };
|
|
72
73
|
|
|
73
74
|
async function pollForEscapeHatchRow(): Promise<AuditRow> {
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
75
|
+
let row: AuditRow | undefined;
|
|
76
|
+
await waitFor(
|
|
77
|
+
async () => {
|
|
78
|
+
const res = await stack.http.queryOk<AuditResponse>(
|
|
79
|
+
AuditQueries.list,
|
|
80
|
+
{ eventType: ESCAPE_HATCH_USED_EVENT },
|
|
81
|
+
adminOfSameTenant,
|
|
82
|
+
);
|
|
83
|
+
row = res.rows[0];
|
|
84
|
+
return row !== undefined;
|
|
85
|
+
},
|
|
86
|
+
{ delays: Array(40).fill(50) },
|
|
87
|
+
);
|
|
88
|
+
if (row === undefined) throw new Error("escape-hatch-used audit row did not appear within 2s");
|
|
89
|
+
return row;
|
|
86
90
|
}
|
|
87
91
|
|
|
88
92
|
describe("createEscapeHatchAuditSink — persists audit:event:escape-hatch-used", () => {
|
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
testTenantId,
|
|
27
27
|
unsafePushTables,
|
|
28
28
|
} from "@cosmicdrift/kumiko-framework/stack";
|
|
29
|
+
import { waitFor } from "@cosmicdrift/kumiko-framework/testing";
|
|
29
30
|
import * as z from "zod";
|
|
30
31
|
import { createChannelEmailFeature } from "../../channel-email/feature";
|
|
31
32
|
import { createInMemoryTransport, type EmailMessage } from "../../channel-email/types";
|
|
@@ -1502,21 +1503,6 @@ describe("flow 16: repeated unsubscribe clicks are idempotent", () => {
|
|
|
1502
1503
|
// non-string `to` as a broadcast target ("tenant" in to).
|
|
1503
1504
|
const asyncRecipient = "7";
|
|
1504
1505
|
|
|
1505
|
-
async function waitFor(check: () => Promise<void>, timeoutMs = 10000): Promise<void> {
|
|
1506
|
-
const start = Date.now();
|
|
1507
|
-
let lastErr: unknown;
|
|
1508
|
-
for (;;) {
|
|
1509
|
-
try {
|
|
1510
|
-
await check();
|
|
1511
|
-
return;
|
|
1512
|
-
} catch (err) {
|
|
1513
|
-
lastErr = err;
|
|
1514
|
-
if (Date.now() - start > timeoutMs) throw lastErr;
|
|
1515
|
-
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
1516
|
-
}
|
|
1517
|
-
}
|
|
1518
|
-
}
|
|
1519
|
-
|
|
1520
1506
|
function makeStubRunner(): {
|
|
1521
1507
|
runner: JobRunner;
|
|
1522
1508
|
dispatched: Array<{
|
|
@@ -1580,13 +1566,16 @@ describe("flow 17: async render→send pipeline", () => {
|
|
|
1580
1566
|
|
|
1581
1567
|
// Email: queued → render job → send job → sent. Proves the framework
|
|
1582
1568
|
// jobRunner-into-job-ctx injection (render dispatches send) end-to-end.
|
|
1583
|
-
await waitFor(
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1569
|
+
await waitFor(
|
|
1570
|
+
async () => {
|
|
1571
|
+
const rows = await selectMany(db, deliveryAttemptsTable, {
|
|
1572
|
+
notificationType: "app:notify:async-e2e",
|
|
1573
|
+
channel: "email",
|
|
1574
|
+
});
|
|
1575
|
+
expect(rows.some((r) => r["status"] === "sent")).toBe(true);
|
|
1576
|
+
},
|
|
1577
|
+
{ delays: Array(40).fill(250) },
|
|
1578
|
+
);
|
|
1590
1579
|
const email = emailTransport.sent.find((m) => m.to === testEmail(asyncRecipient));
|
|
1591
1580
|
expect(email).toBeDefined();
|
|
1592
1581
|
expect(email?.subject).toBe("Async Subject");
|
|
@@ -1594,13 +1583,16 @@ describe("flow 17: async render→send pipeline", () => {
|
|
|
1594
1583
|
expect(email?.html).toContain("<!DOCTYPE html>");
|
|
1595
1584
|
|
|
1596
1585
|
// Push: no render step → dispatched straight to delivery.send.
|
|
1597
|
-
await waitFor(
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1586
|
+
await waitFor(
|
|
1587
|
+
async () => {
|
|
1588
|
+
const rows = await selectMany(db, deliveryAttemptsTable, {
|
|
1589
|
+
notificationType: "app:notify:async-e2e",
|
|
1590
|
+
channel: "push",
|
|
1591
|
+
});
|
|
1592
|
+
expect(rows.some((r) => r["status"] === "sent")).toBe(true);
|
|
1593
|
+
},
|
|
1594
|
+
{ delays: Array(40).fill(250) },
|
|
1595
|
+
);
|
|
1604
1596
|
expect(
|
|
1605
1597
|
pushTransport.sent.find((m) => m.token === testPushToken(asyncRecipient)),
|
|
1606
1598
|
).toBeDefined();
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import { afterAll, beforeEach, describe, expect, mock, test } from "bun:test";
|
|
6
6
|
import { EventEmitter } from "node:events";
|
|
7
7
|
import { createSecret } from "@cosmicdrift/kumiko-framework/secrets";
|
|
8
|
+
import { sleep, waitFor } from "@cosmicdrift/kumiko-framework/testing";
|
|
8
9
|
import {
|
|
9
10
|
type InboundMailContext,
|
|
10
11
|
isInboundAuthError,
|
|
@@ -152,13 +153,6 @@ const account: MailAccountRecord = {
|
|
|
152
153
|
watchState: "idle",
|
|
153
154
|
};
|
|
154
155
|
|
|
155
|
-
async function waitFor(predicate: () => boolean, timeoutMs = 2000): Promise<void> {
|
|
156
|
-
const t0 = Date.now();
|
|
157
|
-
while (!predicate() && Date.now() - t0 < timeoutMs) {
|
|
158
|
-
await Bun.sleep(5);
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
|
-
|
|
162
156
|
function ctxWithDoc(doc: string | null): InboundMailContext {
|
|
163
157
|
return {
|
|
164
158
|
_userId: "imap-plugin-mocked",
|
|
@@ -332,7 +326,9 @@ describe("imapInboundMailPlugin — mocked imapflow", () => {
|
|
|
332
326
|
|
|
333
327
|
// drainNew() does MIME-parsing before onMessages fires — a fixed sleep
|
|
334
328
|
// flakes on a loaded CI runner; poll instead.
|
|
335
|
-
await waitFor(() => received.some((batch) => batch.some((m) => m.subject === "pushed"))
|
|
329
|
+
await waitFor(() => received.some((batch) => batch.some((m) => m.subject === "pushed")), {
|
|
330
|
+
delays: Array(40).fill(5),
|
|
331
|
+
});
|
|
336
332
|
expect(received.some((batch) => batch.some((m) => m.subject === "pushed"))).toBe(true);
|
|
337
333
|
|
|
338
334
|
await stop();
|
|
@@ -347,11 +343,13 @@ describe("imapInboundMailPlugin — mocked imapflow", () => {
|
|
|
347
343
|
},
|
|
348
344
|
});
|
|
349
345
|
lastIdleClient?.emit("error", new Error("socket hang up"));
|
|
350
|
-
await waitFor(() => errors >= 1);
|
|
346
|
+
await waitFor(() => errors >= 1, { delays: Array(40).fill(5) });
|
|
351
347
|
lastIdleClient?.emit("error", new Error("second"));
|
|
352
|
-
// Confirms onError stays unsubscribed after the first error
|
|
353
|
-
//
|
|
354
|
-
|
|
348
|
+
// Confirms onError stays unsubscribed after the first error. This is a
|
|
349
|
+
// grace period for a negative outcome, not a wait-for-true condition, so
|
|
350
|
+
// it's a single sleep (not a poll loop) rather than waitFor, which would
|
|
351
|
+
// throw once errors never reaches 2.
|
|
352
|
+
await sleep(100);
|
|
355
353
|
expect(errors).toBe(1);
|
|
356
354
|
await stop().catch(() => {});
|
|
357
355
|
});
|
|
@@ -40,7 +40,7 @@ import {
|
|
|
40
40
|
unsafeCreateEntityTable,
|
|
41
41
|
unsafePushTables,
|
|
42
42
|
} from "@cosmicdrift/kumiko-framework/stack";
|
|
43
|
-
import {
|
|
43
|
+
import { waitFor } from "@cosmicdrift/kumiko-framework/testing";
|
|
44
44
|
import { createJobsFeature } from "../feature";
|
|
45
45
|
import { createJobRunLogger } from "../job-run-logger";
|
|
46
46
|
import { jobRunLogsTable, jobRunsTable } from "../job-run-table";
|
|
@@ -148,7 +148,7 @@ describe("projection-rebuild job (jobs feature composed)", () => {
|
|
|
148
148
|
}
|
|
149
149
|
|
|
150
150
|
// Poll until the worker drained the queue and the rebuild refilled.
|
|
151
|
-
|
|
151
|
+
await waitFor(async () => (await getCount()) === 2, { delays: Array(40).fill(200) });
|
|
152
152
|
expect(await getCount()).toBe(2);
|
|
153
153
|
|
|
154
154
|
// getCount()==2 only proves rebuildProjection's own writes landed — the
|
|
@@ -156,13 +156,15 @@ describe("projection-rebuild job (jobs feature composed)", () => {
|
|
|
156
156
|
// async append that starts only after the handler returns, so it can
|
|
157
157
|
// still be in flight here. Poll status too instead of racing it.
|
|
158
158
|
let runs: readonly { jobName: string; status: string }[] = [];
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
jobName:
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
159
|
+
await waitFor(
|
|
160
|
+
async () => {
|
|
161
|
+
runs = await selectMany<{ jobName: string; status: string }>(db, jobRunsTable, {
|
|
162
|
+
jobName: PROJECTION_REBUILD_JOB,
|
|
163
|
+
});
|
|
164
|
+
return runs.some((r) => r.status === "completed");
|
|
165
|
+
},
|
|
166
|
+
{ delays: Array(40).fill(200) },
|
|
167
|
+
);
|
|
166
168
|
expect(runs.length).toBeGreaterThanOrEqual(1);
|
|
167
169
|
expect(runs.some((r) => r.status === "completed")).toBe(true);
|
|
168
170
|
}, 30000);
|