create-ailk 0.13.0 → 0.15.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/README.md +12 -4
- package/component-catalog.json +250 -36
- package/component-catalog.md +10 -6
- package/dist/add-module.d.ts +7 -0
- package/dist/add-module.js +34 -2
- package/dist/add-route.js +18 -2
- package/dist/apply-design.d.ts +32 -2
- package/dist/apply-design.js +155 -22
- package/dist/cli.js +24 -3
- package/dist/derive-architecture.d.ts +15 -2
- package/dist/derive-architecture.js +34 -6
- package/dist/design-compile.d.ts +70 -2
- package/dist/design-compile.js +97 -1
- package/dist/design-home.d.ts +74 -0
- package/dist/design-home.js +256 -0
- package/dist/design-package.d.ts +39 -2
- package/dist/design-package.js +224 -9
- package/dist/design-presets.d.ts +17 -1
- package/dist/design-presets.js +17 -5
- package/dist/design-scaffold.d.ts +6 -25
- package/dist/design-scaffold.js +26 -74
- package/dist/index.d.ts +2 -1
- package/dist/index.js +1 -1
- package/dist/lib/chrome-readers.d.ts +21 -0
- package/dist/lib/chrome-readers.js +100 -0
- package/dist/lib/design-checks.d.ts +12 -1
- package/dist/lib/design-checks.js +38 -2
- package/dist/lib/extract-module-bundle.d.ts +11 -0
- package/dist/lib/extract-module-bundle.js +36 -6
- package/dist/lib/license-key-files.d.ts +6 -1
- package/dist/lib/license-key-files.js +9 -4
- package/dist/lib/module-license-gate.d.ts +9 -0
- package/dist/lib/module-license-gate.js +18 -0
- package/dist/module-architecture.d.ts +9 -0
- package/dist/module-architecture.js +6 -0
- package/dist/parse-args.d.ts +22 -0
- package/dist/parse-args.js +73 -0
- package/dist/programmatic.d.ts +20 -0
- package/dist/programmatic.js +180 -35
- package/dist/site-chrome.d.ts +42 -0
- package/dist/site-chrome.js +140 -2
- package/dist/site-fonts.d.ts +21 -0
- package/dist/site-fonts.js +54 -0
- package/dist/site-manifest.d.ts +12 -5
- package/dist/site-manifest.js +25 -11
- package/dist/site-roster.d.ts +16 -0
- package/dist/site-roster.js +81 -0
- package/dist/starter-grid.js +15 -0
- package/dist/surfaces.js +12 -0
- package/dist/sync-routes.js +11 -2
- package/dist/team-profiles.d.ts +29 -0
- package/dist/team-profiles.js +43 -0
- package/dist/topology.d.ts +131 -0
- package/dist/topology.js +533 -0
- package/package.json +3 -3
- package/release-scope.json +1 -1
- package/site-templates/_base/content/en/TeamList/team.mdx +68 -0
- package/site-templates/_base/content/en/TeamMember/first-member.mdx +22 -0
- package/site-templates/_base/content/en/TeamMember/second-member.mdx +22 -0
- package/site-templates/_base/content/en/TeamMember/third-member.mdx +22 -0
- package/site-templates/designs/minimalism/DESIGN.md +166 -36
- package/site-templates/designs/minimalism/home/local-business-service.mdx +119 -0
- package/site-templates/designs/minimalism/home/organization-product.mdx +132 -0
- package/site-templates/designs/minimalism/home/organization-service.mdx +131 -0
- package/site-templates/designs/minimalism/home/person-service.mdx +101 -0
- package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +34 -1
- package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +20 -0
- package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.json +34 -1
- package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.md +22 -1
- package/site-templates/marketing-site-free/ailk-template.json +1 -1
- package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.json +34 -1
- package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.md +26 -7
- package/site-templates/shapes/local-business-service/ailk-template.json +1 -1
- package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.json +34 -1
- package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.md +26 -7
- package/site-templates/shapes/organization-service/ailk-template.json +1 -1
- package/templates/README.md +2 -0
- package/templates/apps/api/CLAUDE.md +6 -1
- package/templates/apps/api/package.json +1 -0
- package/templates/apps/api/src/__tests__/factories/agent-caller.ts +5 -1
- package/templates/apps/api/src/lib/__mocks__/prisma.ts +10 -0
- package/templates/apps/api/src/lib/__tests__/agent-store.test.ts +114 -1
- package/templates/apps/api/src/lib/__tests__/site.test.ts +54 -0
- package/templates/apps/api/src/lib/agent-store.ts +95 -3
- package/templates/apps/api/src/lib/site.ts +37 -0
- package/templates/apps/api/src/lib/spoke-store.ts +18 -2
- package/templates/apps/api/src/middleware/__tests__/auth.bearer-path.test.ts +4 -0
- package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +22 -0
- package/templates/apps/api/src/middleware/auth.ts +3 -0
- package/templates/apps/api/src/openapi/__tests__/openapi.test.ts +23 -0
- package/templates/apps/api/src/openapi/spec.ts +38 -2
- package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +50 -2
- package/templates/apps/api/src/routes/checkout/sessions.ts +7 -0
- package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +275 -0
- package/templates/apps/api/src/routes/leads/index.ts +69 -6
- package/templates/apps/api/src/routes/webhooks/README.md +39 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-site-filter.test.ts +366 -0
- package/templates/apps/api/src/routes/webhooks/stripe.ts +61 -1
- package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +199 -0
- package/templates/apps/api/src/services/default-tenant.ts +41 -7
- package/templates/apps/api/src/services/lead-routing.ts +148 -29
- package/templates/apps/api/src/services/public-site-resolver.ts +48 -0
- package/templates/apps/mcp/__tests__/index.test.ts +5 -5
- package/templates/apps/mcp/package.json +1 -1
- package/templates/apps/mcp/src/index.ts +6 -4
- package/templates/apps/mcp/src/lib/content-adapter.ts +2 -2
- package/templates/apps/mcp/src/lib/topology.generated.ts +24 -0
- package/templates/apps/web/CLAUDE.md +12 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/settings.test.tsx +10 -0
- package/templates/apps/web/app/[locale]/(authed)/account/settings/__tests__/theme-modes.test.ts +42 -0
- package/templates/apps/web/app/[locale]/(authed)/account/settings/theme-modes.ts +8 -0
- package/templates/apps/web/app/[locale]/[slug]/[detail]/__tests__/page.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/[slug]/__tests__/page.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/case-studies/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/clients/__tests__/page-renderer-migration.test.tsx +14 -1
- package/templates/apps/web/app/[locale]/contact/__tests__/page-renderer-migration.test.tsx +66 -14
- package/templates/apps/web/app/[locale]/contact/__tests__/site-contact-lines.test.ts +163 -0
- package/templates/apps/web/app/[locale]/contact/site-contact-lines.ts +68 -0
- package/templates/apps/web/app/[locale]/courses/__tests__/page-renderer-migration.test.tsx +4 -4
- package/templates/apps/web/app/[locale]/downloads/__tests__/page-renderer-migration.test.tsx +15 -3
- package/templates/apps/web/app/[locale]/events/__tests__/page-renderer-migration.test.tsx +17 -6
- package/templates/apps/web/app/[locale]/for/__tests__/page-renderer-migration.test.tsx +14 -1
- package/templates/apps/web/app/[locale]/how-to/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/integrations/__tests__/page-renderer-migration.test.tsx +4 -6
- package/templates/apps/web/app/[locale]/layout.tsx +212 -57
- package/templates/apps/web/app/[locale]/newsletter/__tests__/page.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/page.tsx +37 -0
- package/templates/apps/web/app/[locale]/partners/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/products/__tests__/page-renderer-migration.test.tsx +4 -6
- package/templates/apps/web/app/[locale]/projects/__tests__/page-renderer-migration.test.tsx +5 -2
- package/templates/apps/web/app/[locale]/resources/__tests__/page-renderer-migration.test.tsx +15 -2
- package/templates/apps/web/app/[locale]/services/__tests__/page-renderer-migration.test.tsx +43 -6
- package/templates/apps/web/app/[locale]/services/page.tsx +14 -2
- package/templates/apps/web/app/[locale]/software/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/team/[slug]/page.tsx +45 -0
- package/templates/apps/web/app/[locale]/team/__tests__/team-routes.test.tsx +155 -0
- package/templates/apps/web/app/[locale]/team/page.tsx +49 -0
- package/templates/apps/web/app/[locale]/tech-articles/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/testimonials/__tests__/page-renderer-migration.test.tsx +14 -1
- package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/page.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/whitepapers/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/not-found.tsx +47 -0
- package/templates/apps/web/app/openapi.json/__tests__/route.test.ts +89 -1
- package/templates/apps/web/app/openapi.json/route.ts +69 -1
- package/templates/apps/web/instrumentation.ts +7 -4
- package/templates/apps/web/lib/__tests__/closing-footer-path.test.ts +96 -0
- package/templates/apps/web/lib/__tests__/profile-back-link.test.ts +95 -0
- package/templates/apps/web/lib/__tests__/relay.test.ts +290 -0
- package/templates/apps/web/lib/__tests__/site-brand.test.ts +52 -0
- package/templates/apps/web/lib/__tests__/site-theme-head.test.ts +70 -0
- package/templates/apps/web/lib/__tests__/site-theme.test.ts +64 -1
- package/templates/apps/web/lib/closing-footer-path.ts +50 -0
- package/templates/apps/web/lib/profile-back-link.ts +51 -0
- package/templates/apps/web/lib/relay.ts +206 -2
- package/templates/apps/web/lib/site-brand.tsx +22 -1
- package/templates/apps/web/lib/site-chrome.ts +15 -0
- package/templates/apps/web/lib/site-fonts.ts +15 -0
- package/templates/apps/web/lib/site-logo.tsx +39 -1
- package/templates/apps/web/lib/site-theme-head.ts +33 -0
- package/templates/apps/web/lib/site-theme.ts +13 -1
- package/templates/apps/web/lib/topology.generated.ts +23 -0
- package/templates/apps/web/messages/en.json +7 -1
- package/templates/apps/web/package.json +1 -1
- package/templates/database/CHANGELOG.md +32 -0
- package/templates/database/__tests__/agents.db.test.ts +65 -0
- package/templates/database/__tests__/lead-idempotency-key.db.test.ts +92 -0
- package/templates/database/auth/schema.prisma +23 -0
- package/templates/database/inbox/schema.prisma +13 -0
- package/templates/database/jest.config.cjs +7 -11
- package/templates/database/migrations/20261008120000_user_last_workspace/migration.sql +35 -0
- package/templates/database/migrations/20261008130000_lead_idempotency_key/migration.sql +20 -0
- package/templates/database/migrations/20261009120000_agent_grants/migration.sql +33 -0
- package/templates/database/package.json +1 -1
- package/versions.json +35 -34
- package/site-templates/designs/minimalism/sample/README.md +0 -48
- package/site-templates/designs/minimalism/sample/content/en/HomePage/home.mdx +0 -244
- package/site-templates/designs/minimalism/sample/public/avatar-placeholder-1.svg +0 -7
- package/site-templates/designs/minimalism/sample/public/avatar-placeholder-2.svg +0 -7
- package/site-templates/designs/minimalism/sample/public/avatar-placeholder-3.svg +0 -7
- package/site-templates/designs/minimalism/sample/public/avatar-placeholder-4.svg +0 -7
- package/site-templates/designs/minimalism/sample/public/avatar-placeholder-5.svg +0 -7
- package/site-templates/designs/minimalism/sample/public/avatar-placeholder-6.svg +0 -7
- package/site-templates/designs/minimalism/sample/public/hero-backdrop.svg +0 -17
- package/site-templates/designs/minimalism/sample/public/logo-placeholder-1.svg +0 -6
- package/site-templates/designs/minimalism/sample/public/logo-placeholder-2.svg +0 -6
- package/site-templates/designs/minimalism/sample/public/logo-placeholder-3.svg +0 -6
- package/site-templates/designs/minimalism/sample/public/wordmark-placeholder.svg +0 -28
|
@@ -18,11 +18,13 @@
|
|
|
18
18
|
* the hub — read that workspace's enabled meeting types and public content,
|
|
19
19
|
* and write leads and bookings into it.
|
|
20
20
|
*
|
|
21
|
-
*
|
|
21
|
+
* Three kinds of caller, kept apart:
|
|
22
22
|
* - the public resolver (`services/public-site-resolver.ts`) calls
|
|
23
23
|
* `hashSpokeKey`, `findSpokeByKeyHash` and `hasRegisteredSpoke` only;
|
|
24
24
|
* - the operator CLI (`bin/spokes.ts`) calls `registerSpoke`, `revokeSpoke`
|
|
25
|
-
* and `listSpokes`. No route imports them
|
|
25
|
+
* and `listSpokes`. No route imports them;
|
|
26
|
+
* - the operator flow-copy service (`services/flow-copy.ts`, run only by
|
|
27
|
+
* `bin/flows-copy.ts`) calls `hasLiveSpokeForWorkspace` only (#7844 D7).
|
|
26
28
|
*
|
|
27
29
|
* Nothing here caches a row: a revocation takes effect on the next request
|
|
28
30
|
* (NFR4). The resolver holds `hasRegisteredSpoke`'s answer itself (#7633 D3).
|
|
@@ -73,6 +75,20 @@ export async function hasRegisteredSpoke(): Promise<boolean> {
|
|
|
73
75
|
return Boolean(row);
|
|
74
76
|
}
|
|
75
77
|
|
|
78
|
+
/**
|
|
79
|
+
* Whether at least one unrevoked spoke is bound to this workspace (#7844 D7):
|
|
80
|
+
* the flow-copy tool's target check. Selects `id` only.
|
|
81
|
+
*/
|
|
82
|
+
export async function hasLiveSpokeForWorkspace(
|
|
83
|
+
workspaceId: string,
|
|
84
|
+
): Promise<boolean> {
|
|
85
|
+
const row = await prisma.spoke.findFirst({
|
|
86
|
+
where: { workspaceId, revokedAt: null },
|
|
87
|
+
select: { id: true },
|
|
88
|
+
});
|
|
89
|
+
return Boolean(row);
|
|
90
|
+
}
|
|
91
|
+
|
|
76
92
|
// ── Operator functions — `bin/spokes.ts` only ────────────────────────────────
|
|
77
93
|
|
|
78
94
|
export interface SpokeListRow {
|
|
@@ -194,6 +194,8 @@ const WS_A1 = "wksp_00000000-0000-7000-8000-0000000000a1";
|
|
|
194
194
|
|
|
195
195
|
type AgentFixture = {
|
|
196
196
|
id: string;
|
|
197
|
+
/** Absent → the row carries none (the double omits the column). */
|
|
198
|
+
grants?: string[];
|
|
197
199
|
enabled?: boolean;
|
|
198
200
|
keyIssuedToMembershipId?: string | null;
|
|
199
201
|
lastActiveAt?: Date | null;
|
|
@@ -215,6 +217,7 @@ function installAgents(agents: Array<AgentFixture & { key: string }>): void {
|
|
|
215
217
|
workspaceId: WS_A1,
|
|
216
218
|
organizationId: ORG_A,
|
|
217
219
|
permissionLevel: "member",
|
|
220
|
+
...(a.grants === undefined ? {} : { grants: a.grants }),
|
|
218
221
|
enabled: a.enabled ?? true,
|
|
219
222
|
keyIssuedToMembershipId:
|
|
220
223
|
a.keyIssuedToMembershipId === undefined ? ADMIN_MEMBERSHIP : a.keyIssuedToMembershipId,
|
|
@@ -298,6 +301,7 @@ describe("auth preHandler — agent branch (#3591)", () => {
|
|
|
298
301
|
organizationId: ORG_A,
|
|
299
302
|
responsibleMembershipId: PERSON_MEMBERSHIP,
|
|
300
303
|
permissionLevel: "member",
|
|
304
|
+
grants: [],
|
|
301
305
|
},
|
|
302
306
|
actor: { type: "agent", agentId: AGENT_A, onBehalfOfUserId: PERSON },
|
|
303
307
|
sessionTenant: null,
|
|
@@ -530,6 +530,7 @@ describe("defaultTenantPreHandler — agents (#3591 D5, D7)", () => {
|
|
|
530
530
|
workspaceId: string;
|
|
531
531
|
organizationId: string;
|
|
532
532
|
responsibleMembershipId: string;
|
|
533
|
+
grants: string[];
|
|
533
534
|
}> = {},
|
|
534
535
|
) {
|
|
535
536
|
return {
|
|
@@ -539,6 +540,7 @@ describe("defaultTenantPreHandler — agents (#3591 D5, D7)", () => {
|
|
|
539
540
|
responsibleMembershipId: (overrides.responsibleMembershipId ??
|
|
540
541
|
OPERATOR_MEMBERSHIP_ID) as Id<"membership">,
|
|
541
542
|
permissionLevel: "member" as const,
|
|
543
|
+
grants: overrides.grants ?? [],
|
|
542
544
|
};
|
|
543
545
|
}
|
|
544
546
|
|
|
@@ -582,6 +584,26 @@ describe("defaultTenantPreHandler — agents (#3591 D5, D7)", () => {
|
|
|
582
584
|
await app.close();
|
|
583
585
|
});
|
|
584
586
|
|
|
587
|
+
it("a granted agent of the owner-operator carries its grants on the tenant; a forged one is dropped (#7864 D3)", async () => {
|
|
588
|
+
mockSingleTenantWorkspace();
|
|
589
|
+
mockOperatorMembership();
|
|
590
|
+
const app = buildAgentApp(
|
|
591
|
+
agentBinding({ grants: ["magnets-manage", "agents-manage", "content-edit"] }),
|
|
592
|
+
);
|
|
593
|
+
|
|
594
|
+
const res = await app.inject({ method: "GET", url: "/scoped" });
|
|
595
|
+
|
|
596
|
+
expect(res.statusCode).toBe(200);
|
|
597
|
+
expect(res.json().tenant).toEqual({
|
|
598
|
+
userId: USER_ID,
|
|
599
|
+
organizationId: DEFAULT_ORG_ID,
|
|
600
|
+
workspaceId: DEFAULT_WORKSPACE_ID,
|
|
601
|
+
role: "member",
|
|
602
|
+
grants: ["content-edit", "magnets-manage"],
|
|
603
|
+
});
|
|
604
|
+
await app.close();
|
|
605
|
+
});
|
|
606
|
+
|
|
585
607
|
it("reaches the grant through the operator's own reads: the same membership compound key", async () => {
|
|
586
608
|
mockSingleTenantWorkspace();
|
|
587
609
|
mockOperatorMembership();
|
|
@@ -354,6 +354,9 @@ async function authenticateAgent(
|
|
|
354
354
|
organizationId: row.organizationId as Id<"organization">,
|
|
355
355
|
responsibleMembershipId: row.responsible.id as Id<"membership">,
|
|
356
356
|
permissionLevel: row.permissionLevel,
|
|
357
|
+
// #7864 D3 — the one place a missing value becomes none; the tenant
|
|
358
|
+
// guards keep only the grants its person's role also holds.
|
|
359
|
+
grants: row.grants ?? [],
|
|
357
360
|
};
|
|
358
361
|
bindActor(request, reply, {
|
|
359
362
|
type: "agent",
|
|
@@ -222,6 +222,29 @@ describe("GET /api/openapi.json", () => {
|
|
|
222
222
|
},
|
|
223
223
|
);
|
|
224
224
|
|
|
225
|
+
it("documents /v1/leads/submit's optional Idempotency-Key header and its 422 (#7869)", async () => {
|
|
226
|
+
const res = await app.inject({
|
|
227
|
+
method: "GET",
|
|
228
|
+
url: "/v1/openapi.json",
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
const spec = JSON.parse(res.body);
|
|
232
|
+
const post = spec.paths["/v1/leads/submit"].post;
|
|
233
|
+
expect(post.parameters).toEqual(
|
|
234
|
+
expect.arrayContaining([
|
|
235
|
+
expect.objectContaining({
|
|
236
|
+
name: "Idempotency-Key",
|
|
237
|
+
in: "header",
|
|
238
|
+
required: false,
|
|
239
|
+
}),
|
|
240
|
+
]),
|
|
241
|
+
);
|
|
242
|
+
expect(
|
|
243
|
+
post.responses["422"].content["application/json"].schema.properties.error
|
|
244
|
+
.enum,
|
|
245
|
+
).toEqual(["idempotency_key_reused"]);
|
|
246
|
+
});
|
|
247
|
+
|
|
225
248
|
it("no longer describes the API as read-only", async () => {
|
|
226
249
|
const res = await app.inject({
|
|
227
250
|
method: "GET",
|
|
@@ -751,7 +751,25 @@ export function buildOpenApiSpec(
|
|
|
751
751
|
"only), or, on a deployment with no database, the site operator's inbox " +
|
|
752
752
|
"by email (`persisted: \"email\"`); with a database, the response's " +
|
|
753
753
|
"`email` field reports, best-effort, whether the operator was notified. " +
|
|
754
|
-
"A lead is never reported captured when nothing stored or delivered it."
|
|
754
|
+
"A lead is never reported captured when nothing stored or delivered it. " +
|
|
755
|
+
"An optional `Idempotency-Key` header makes a retry safe: with a " +
|
|
756
|
+
"database, a repeat of a stored key in the same workspace with the same " +
|
|
757
|
+
"`email`, `source` and `payload` writes nothing and answers the first " +
|
|
758
|
+
"lead's id with a 201; with anything different it is a 422. Keys are " +
|
|
759
|
+
"scoped per workspace, are first-write-wins, and should be unguessable " +
|
|
760
|
+
"(a UUID, a Stripe Checkout Session id). Without a database there is no " +
|
|
761
|
+
"stored lead to match, so the key has no effect.",
|
|
762
|
+
parameters: [
|
|
763
|
+
{
|
|
764
|
+
name: "Idempotency-Key",
|
|
765
|
+
in: "header",
|
|
766
|
+
required: false,
|
|
767
|
+
description:
|
|
768
|
+
"An unguessable value identifying this lead write, so a retry " +
|
|
769
|
+
"answers the first lead instead of writing a second (#7869).",
|
|
770
|
+
schema: { type: "string", pattern: "^[\\x21-\\x7E]{16,255}$" },
|
|
771
|
+
},
|
|
772
|
+
],
|
|
755
773
|
requestBody: {
|
|
756
774
|
required: true,
|
|
757
775
|
content: {
|
|
@@ -770,13 +788,31 @@ export function buildOpenApiSpec(
|
|
|
770
788
|
},
|
|
771
789
|
},
|
|
772
790
|
"400": {
|
|
773
|
-
description:
|
|
791
|
+
description:
|
|
792
|
+
"Validation error — invalid request body, or a malformed Idempotency-Key",
|
|
774
793
|
content: {
|
|
775
794
|
"application/json": {
|
|
776
795
|
schema: { $ref: "#/components/schemas/InvalidInputResponse" },
|
|
777
796
|
},
|
|
778
797
|
},
|
|
779
798
|
},
|
|
799
|
+
"422": {
|
|
800
|
+
description:
|
|
801
|
+
"The Idempotency-Key was already used in this workspace for a lead " +
|
|
802
|
+
"with a different email, source or payload; nothing was written",
|
|
803
|
+
content: {
|
|
804
|
+
"application/json": {
|
|
805
|
+
schema: {
|
|
806
|
+
type: "object",
|
|
807
|
+
required: ["error", "message"],
|
|
808
|
+
properties: {
|
|
809
|
+
error: { type: "string", enum: ["idempotency_key_reused"] },
|
|
810
|
+
message: { type: "string" },
|
|
811
|
+
},
|
|
812
|
+
},
|
|
813
|
+
},
|
|
814
|
+
},
|
|
815
|
+
},
|
|
780
816
|
"503": {
|
|
781
817
|
description:
|
|
782
818
|
"Lead capture unavailable — persistence failed, delivery failed, or " +
|
|
@@ -1080,7 +1080,8 @@ describe("POST /v1/checkout/sessions — signed-in buyer stamp (#6866)", () => {
|
|
|
1080
1080
|
expect(res.statusCode).toBe(201);
|
|
1081
1081
|
const arg = mockCreateCheckoutSession.mock.calls[0][0];
|
|
1082
1082
|
expect(arg).not.toHaveProperty("buyer");
|
|
1083
|
-
|
|
1083
|
+
// #7868 D4 — the only metadata is the hub's own site stamp.
|
|
1084
|
+
expect(arg.metadata).toEqual({ site: "default" });
|
|
1084
1085
|
expect(arg).not.toHaveProperty("ailk_user_id");
|
|
1085
1086
|
});
|
|
1086
1087
|
|
|
@@ -1093,7 +1094,8 @@ describe("POST /v1/checkout/sessions — signed-in buyer stamp (#6866)", () => {
|
|
|
1093
1094
|
|
|
1094
1095
|
const arg = mockCreateCheckoutSession.mock.calls[0][0];
|
|
1095
1096
|
expect(arg.buyer.userId).toBe("usr_buyer");
|
|
1096
|
-
|
|
1097
|
+
// #7868 D4 — the only metadata is the hub's own site stamp.
|
|
1098
|
+
expect(arg.metadata).toEqual({ site: "default" });
|
|
1097
1099
|
});
|
|
1098
1100
|
|
|
1099
1101
|
it("#6866 R5: with no cookie, the call is exactly today's argument object", async () => {
|
|
@@ -1109,6 +1111,8 @@ describe("POST /v1/checkout/sessions — signed-in buyer stamp (#6866)", () => {
|
|
|
1109
1111
|
successUrl: VALID_BODY.successUrl,
|
|
1110
1112
|
cancelUrl: VALID_BODY.cancelUrl,
|
|
1111
1113
|
quantity: 1,
|
|
1114
|
+
// #7868 D4 — the hub's own site stamp (AILK_SITE_ID unset).
|
|
1115
|
+
metadata: { site: "default" },
|
|
1112
1116
|
});
|
|
1113
1117
|
expect(prisma.session.findUnique).not.toHaveBeenCalled();
|
|
1114
1118
|
expect(prisma.user.findUnique).not.toHaveBeenCalled();
|
|
@@ -1116,6 +1120,50 @@ describe("POST /v1/checkout/sessions — signed-in buyer stamp (#6866)", () => {
|
|
|
1116
1120
|
expect(createdLog()).not.toHaveProperty("identity");
|
|
1117
1121
|
});
|
|
1118
1122
|
|
|
1123
|
+
it("#7868 D4: the site stamp follows AILK_SITE_ID", async () => {
|
|
1124
|
+
const original = process.env.AILK_SITE_ID;
|
|
1125
|
+
process.env.AILK_SITE_ID = "wksp_hub";
|
|
1126
|
+
try {
|
|
1127
|
+
const res = await post({});
|
|
1128
|
+
|
|
1129
|
+
expect(res.statusCode).toBe(201);
|
|
1130
|
+
expect(mockCreateCheckoutSession.mock.calls[0][0].metadata).toEqual({
|
|
1131
|
+
site: "wksp_hub",
|
|
1132
|
+
});
|
|
1133
|
+
} finally {
|
|
1134
|
+
if (original === undefined) delete process.env.AILK_SITE_ID;
|
|
1135
|
+
else process.env.AILK_SITE_ID = original;
|
|
1136
|
+
}
|
|
1137
|
+
});
|
|
1138
|
+
|
|
1139
|
+
it("#7868 D4: the anonymous retry after a stale customer carries the site stamp too", async () => {
|
|
1140
|
+
givenUser({ billingProvider: "stripe", billingCustomerId: "cus_bound" });
|
|
1141
|
+
mockCreateCheckoutSession.mockRejectedValueOnce(new UnknownCustomerError());
|
|
1142
|
+
|
|
1143
|
+
const res = await signedIn();
|
|
1144
|
+
|
|
1145
|
+
expect(res.statusCode).toBe(201);
|
|
1146
|
+
expect(mockCreateCheckoutSession).toHaveBeenCalledTimes(2);
|
|
1147
|
+
expect(mockCreateCheckoutSession.mock.calls[0][0].metadata).toEqual({
|
|
1148
|
+
site: "default",
|
|
1149
|
+
});
|
|
1150
|
+
expect(mockCreateCheckoutSession.mock.calls[1][0].metadata).toEqual({
|
|
1151
|
+
site: "default",
|
|
1152
|
+
});
|
|
1153
|
+
});
|
|
1154
|
+
|
|
1155
|
+
it("#7868 D4: a signed-in call carries both the buyer and the site stamp", async () => {
|
|
1156
|
+
const res = await signedIn();
|
|
1157
|
+
|
|
1158
|
+
expect(res.statusCode).toBe(201);
|
|
1159
|
+
const arg = mockCreateCheckoutSession.mock.calls[0][0];
|
|
1160
|
+
expect(arg.buyer).toEqual({
|
|
1161
|
+
userId: "usr_buyer",
|
|
1162
|
+
email: "buyer@example.com",
|
|
1163
|
+
});
|
|
1164
|
+
expect(arg.metadata).toEqual({ site: "default" });
|
|
1165
|
+
});
|
|
1166
|
+
|
|
1119
1167
|
it.each([
|
|
1120
1168
|
[
|
|
1121
1169
|
"an unknown session",
|
|
@@ -22,6 +22,11 @@
|
|
|
22
22
|
* identifies a buyer. Any lookup failure, and a pinned customer Stripe
|
|
23
23
|
* does not know, degrades to today's anonymous Checkout — never an error.
|
|
24
24
|
* The stamped id is never echoed or logged.
|
|
25
|
+
* - Site stamp (#7868): every session carries `metadata.site` from
|
|
26
|
+
* `siteStamp()` (lib/site.ts) — on the signed-in try and the anonymous
|
|
27
|
+
* retry alike — so the Stripe webhook can tell this site's sessions from
|
|
28
|
+
* a spoke's on a shared account. The body schema has no metadata field,
|
|
29
|
+
* so no caller can set or override the stamp.
|
|
25
30
|
* - X-Internal-Auth gate: when OSS_API_INTERNAL_SECRET env var is set,
|
|
26
31
|
* the X-Internal-Auth header must match; requests without a matching header
|
|
27
32
|
* are rejected with 401. When the env var is unset, no gate is applied
|
|
@@ -55,6 +60,7 @@ import type { FastifyReply, FastifyRequest } from "fastify";
|
|
|
55
60
|
import { z } from "zod";
|
|
56
61
|
|
|
57
62
|
import { config } from "../../config/index.js";
|
|
63
|
+
import { siteStamp } from "../../lib/site.js";
|
|
58
64
|
import {
|
|
59
65
|
assertSandboxKey,
|
|
60
66
|
createCheckoutSession,
|
|
@@ -306,6 +312,7 @@ export async function checkoutSessionsHandler(
|
|
|
306
312
|
// Omitted when the caller says nothing — Stripe's default (no promo-code
|
|
307
313
|
// field) is unchanged for every existing caller.
|
|
308
314
|
...(allowPromotionCodes !== undefined && { allowPromotionCodes }),
|
|
315
|
+
metadata: siteStamp(),
|
|
309
316
|
};
|
|
310
317
|
// Built only from the session cookie — never from `parsed.data` (#6866 D5).
|
|
311
318
|
let { buyer, identity } = await resolveBuyer(request);
|
|
@@ -54,6 +54,11 @@ jest.mock("@working-theory/license", () => ({
|
|
|
54
54
|
}),
|
|
55
55
|
}));
|
|
56
56
|
|
|
57
|
+
// #7869 — the Idempotency-Key suite asserts a replay records no new capture
|
|
58
|
+
// event; `track` is fire-and-forget and never throws, so mocking it changes
|
|
59
|
+
// nothing for the cases above.
|
|
60
|
+
jest.mock("@working-theory/analytics/server", () => ({ track: jest.fn() }));
|
|
61
|
+
|
|
57
62
|
import {
|
|
58
63
|
leadSubmitRoute,
|
|
59
64
|
leadSubscribeRoute,
|
|
@@ -1336,3 +1341,273 @@ describe("POST /v1/leads/submit — #6730 delivery modes", () => {
|
|
|
1336
1341
|
await expect(fs.access(fileEnv.getCaptureFilePath())).rejects.toThrow();
|
|
1337
1342
|
});
|
|
1338
1343
|
});
|
|
1344
|
+
|
|
1345
|
+
// ── #7869 — Idempotency-Key on submit (spec D1, D3–D7, D10 cases 1–8) ─────────
|
|
1346
|
+
//
|
|
1347
|
+
// `inbox` delivery (DATABASE_URL set), the sentinel workspace resolvable. The
|
|
1348
|
+
// app logs into `logLines` so case 8 can prove the key reaches no log line.
|
|
1349
|
+
describe("POST /v1/leads/submit — Idempotency-Key (#7869)", () => {
|
|
1350
|
+
let app: FastifyInstance;
|
|
1351
|
+
let logLines: string[];
|
|
1352
|
+
const KEY = "fixture-retry-0000-0000-0002";
|
|
1353
|
+
const STORED_ID = "lead_00000000-0000-7000-8000-00000000a7a7";
|
|
1354
|
+
const NEW_ID = "lead_00000000-0000-7000-8000-00000000b8b8";
|
|
1355
|
+
const sendLeadNotification = jest.requireMock("@working-theory/email")
|
|
1356
|
+
.sendLeadNotification as jest.Mock;
|
|
1357
|
+
const track = jest.requireMock("@working-theory/analytics/server")
|
|
1358
|
+
.track as jest.Mock;
|
|
1359
|
+
|
|
1360
|
+
const body = {
|
|
1361
|
+
kind: "submit",
|
|
1362
|
+
source: "contact",
|
|
1363
|
+
email: "visitor@example.com",
|
|
1364
|
+
payload: { message: "hello", name: "Jamie" },
|
|
1365
|
+
};
|
|
1366
|
+
const send = (
|
|
1367
|
+
headers: Record<string, string> = {},
|
|
1368
|
+
url = "/v1/leads/submit",
|
|
1369
|
+
payload: Record<string, unknown> = body,
|
|
1370
|
+
) => app.inject({ method: "POST", url, headers, payload });
|
|
1371
|
+
const keyLookups = () =>
|
|
1372
|
+
jest
|
|
1373
|
+
.mocked(prisma.lead.findUnique)
|
|
1374
|
+
.mock.calls.filter(
|
|
1375
|
+
(c) => "workspaceId_idempotencyKey" in (c[0]?.where ?? {}),
|
|
1376
|
+
);
|
|
1377
|
+
const keyIsNowhere = (resBody: string) => {
|
|
1378
|
+
expect(resBody).not.toContain(KEY);
|
|
1379
|
+
for (const line of logLines) expect(line).not.toContain(KEY);
|
|
1380
|
+
};
|
|
1381
|
+
|
|
1382
|
+
beforeEach(async () => {
|
|
1383
|
+
jest.clearAllMocks();
|
|
1384
|
+
stateSentinelWorkspaceExists();
|
|
1385
|
+
process.env.DATABASE_URL = "postgres://stub";
|
|
1386
|
+
process.env.AILK_LEAD_NOTIFICATION_TO = "owner@example.com";
|
|
1387
|
+
process.env.AILK_LEAD_NOTIFICATION_FROM = "noreply@example.com";
|
|
1388
|
+
(prisma.lead.create as jest.Mock).mockResolvedValue({ id: NEW_ID });
|
|
1389
|
+
(prisma.lead.findUnique as jest.Mock).mockResolvedValue(null);
|
|
1390
|
+
|
|
1391
|
+
logLines = [];
|
|
1392
|
+
const fastify = require("fastify")({
|
|
1393
|
+
logger: {
|
|
1394
|
+
level: "info",
|
|
1395
|
+
stream: { write: (msg: string) => logLines.push(msg) },
|
|
1396
|
+
},
|
|
1397
|
+
});
|
|
1398
|
+
await fastify.register(leadSubmitRoute);
|
|
1399
|
+
await fastify.register(leadSubscribeRoute);
|
|
1400
|
+
await fastify.register(leadCallbackRoute);
|
|
1401
|
+
app = fastify;
|
|
1402
|
+
});
|
|
1403
|
+
|
|
1404
|
+
afterEach(async () => {
|
|
1405
|
+
await app.close();
|
|
1406
|
+
delete process.env.DATABASE_URL;
|
|
1407
|
+
delete process.env.AILK_LEAD_NOTIFICATION_TO;
|
|
1408
|
+
delete process.env.AILK_LEAD_NOTIFICATION_FROM;
|
|
1409
|
+
});
|
|
1410
|
+
|
|
1411
|
+
it("case 1 — no header: one create with no idempotencyKey, no key lookup, 201", async () => {
|
|
1412
|
+
const res = await send();
|
|
1413
|
+
|
|
1414
|
+
expect(res.statusCode).toBe(201);
|
|
1415
|
+
expect(prisma.lead.create).toHaveBeenCalledTimes(1);
|
|
1416
|
+
expect(
|
|
1417
|
+
jest.mocked(prisma.lead.create).mock.calls[0]?.[0].data,
|
|
1418
|
+
).not.toHaveProperty("idempotencyKey");
|
|
1419
|
+
expect(keyLookups()).toHaveLength(0);
|
|
1420
|
+
});
|
|
1421
|
+
|
|
1422
|
+
it("case 2 — a key with no stored row: scoped lookup, the create carries the key, 201", async () => {
|
|
1423
|
+
const res = await send({ "Idempotency-Key": KEY });
|
|
1424
|
+
|
|
1425
|
+
expect(res.statusCode).toBe(201);
|
|
1426
|
+
expect(res.json()).toEqual({
|
|
1427
|
+
id: NEW_ID,
|
|
1428
|
+
persisted: "db",
|
|
1429
|
+
email: { ok: true, messageId: "msg-test" },
|
|
1430
|
+
});
|
|
1431
|
+
expect(keyLookups()[0]?.[0]).toEqual({
|
|
1432
|
+
where: {
|
|
1433
|
+
workspaceId_idempotencyKey: {
|
|
1434
|
+
workspaceId: RESOLVED_WORKSPACE_ID,
|
|
1435
|
+
idempotencyKey: KEY,
|
|
1436
|
+
},
|
|
1437
|
+
},
|
|
1438
|
+
select: { id: true, email: true, source: true, payload: true },
|
|
1439
|
+
});
|
|
1440
|
+
expect(
|
|
1441
|
+
jest.mocked(prisma.lead.create).mock.calls[0]?.[0].data,
|
|
1442
|
+
).toMatchObject({
|
|
1443
|
+
idempotencyKey: KEY,
|
|
1444
|
+
});
|
|
1445
|
+
keyIsNowhere(res.body);
|
|
1446
|
+
});
|
|
1447
|
+
|
|
1448
|
+
it("case 3 — a stored row with the same email, source and payload (keys reordered) replays: 201, the stored id, no create, no notification, no track, no capture line", async () => {
|
|
1449
|
+
(prisma.lead.findUnique as jest.Mock).mockResolvedValue({
|
|
1450
|
+
id: STORED_ID,
|
|
1451
|
+
email: body.email,
|
|
1452
|
+
source: body.source,
|
|
1453
|
+
payload: { name: "Jamie", message: "hello" },
|
|
1454
|
+
});
|
|
1455
|
+
|
|
1456
|
+
const res = await send({ "Idempotency-Key": KEY });
|
|
1457
|
+
|
|
1458
|
+
expect(res.statusCode).toBe(201);
|
|
1459
|
+
expect(res.json()).toEqual({
|
|
1460
|
+
id: STORED_ID,
|
|
1461
|
+
persisted: "db",
|
|
1462
|
+
email: { skipped: true, reason: "idempotent_replay" },
|
|
1463
|
+
});
|
|
1464
|
+
expect(Object.keys(res.json()).sort()).toEqual([
|
|
1465
|
+
"email",
|
|
1466
|
+
"id",
|
|
1467
|
+
"persisted",
|
|
1468
|
+
]);
|
|
1469
|
+
expect(prisma.lead.create).not.toHaveBeenCalled();
|
|
1470
|
+
expect(sendLeadNotification).not.toHaveBeenCalled();
|
|
1471
|
+
expect(track).not.toHaveBeenCalled();
|
|
1472
|
+
await expect(fs.access(fileEnv.getCaptureFilePath())).rejects.toThrow();
|
|
1473
|
+
const routed = logLines.find((l) => l.includes("lead routed"));
|
|
1474
|
+
expect(JSON.parse(routed!)).toMatchObject({ replayed: true });
|
|
1475
|
+
keyIsNowhere(res.body);
|
|
1476
|
+
});
|
|
1477
|
+
|
|
1478
|
+
it.each([
|
|
1479
|
+
["email", { email: "other@example.com" }],
|
|
1480
|
+
["source", { source: "footer" }],
|
|
1481
|
+
["payload", { payload: { message: "a different message", name: "Jamie" } }],
|
|
1482
|
+
])(
|
|
1483
|
+
"case 4 — a stored row with a different %s is 422 idempotency_key_reused, no create, one warn log",
|
|
1484
|
+
async (_field, override) => {
|
|
1485
|
+
(prisma.lead.findUnique as jest.Mock).mockResolvedValue({
|
|
1486
|
+
id: STORED_ID,
|
|
1487
|
+
email: body.email,
|
|
1488
|
+
source: body.source,
|
|
1489
|
+
payload: body.payload,
|
|
1490
|
+
...override,
|
|
1491
|
+
});
|
|
1492
|
+
|
|
1493
|
+
const res = await send({ "Idempotency-Key": KEY });
|
|
1494
|
+
|
|
1495
|
+
expect(res.statusCode).toBe(422);
|
|
1496
|
+
expect(res.json()).toEqual({
|
|
1497
|
+
error: "idempotency_key_reused",
|
|
1498
|
+
message: "This Idempotency-Key was used for a different lead.",
|
|
1499
|
+
});
|
|
1500
|
+
expect(prisma.lead.create).not.toHaveBeenCalled();
|
|
1501
|
+
const warns = logLines
|
|
1502
|
+
.map((l) => JSON.parse(l) as Record<string, unknown>)
|
|
1503
|
+
.filter((l) => l["outcome"] === "idempotency_conflict");
|
|
1504
|
+
expect(warns).toHaveLength(1);
|
|
1505
|
+
expect(warns[0]).toMatchObject({
|
|
1506
|
+
level: 40,
|
|
1507
|
+
kind: "submit",
|
|
1508
|
+
siteId: RESOLVED_WORKSPACE_ID,
|
|
1509
|
+
});
|
|
1510
|
+
keyIsNowhere(res.body);
|
|
1511
|
+
},
|
|
1512
|
+
);
|
|
1513
|
+
|
|
1514
|
+
it.each([
|
|
1515
|
+
["empty", ""],
|
|
1516
|
+
["15 characters", "a".repeat(15)],
|
|
1517
|
+
["256 characters", "a".repeat(256)],
|
|
1518
|
+
["a space", "abcdefghij klmnopqrs"],
|
|
1519
|
+
["a non-ASCII character", "abcdefghijéklmnopqrs"],
|
|
1520
|
+
])(
|
|
1521
|
+
"case 5 — a malformed key (%s) is 400 invalid_input with no lead read or write",
|
|
1522
|
+
async (_label, key) => {
|
|
1523
|
+
const res = await app.inject({
|
|
1524
|
+
method: "POST",
|
|
1525
|
+
url: "/v1/leads/submit",
|
|
1526
|
+
headers: { "idempotency-key": key },
|
|
1527
|
+
payload: body,
|
|
1528
|
+
});
|
|
1529
|
+
|
|
1530
|
+
expect(res.statusCode).toBe(400);
|
|
1531
|
+
expect(res.json()).toEqual({
|
|
1532
|
+
error: "invalid_input",
|
|
1533
|
+
message: "Idempotency-Key must be 16 to 255 visible ASCII characters.",
|
|
1534
|
+
});
|
|
1535
|
+
expect(prisma.lead.findUnique).not.toHaveBeenCalled();
|
|
1536
|
+
expect(prisma.lead.create).not.toHaveBeenCalled();
|
|
1537
|
+
},
|
|
1538
|
+
);
|
|
1539
|
+
|
|
1540
|
+
it("case 6 — the same key stored only in another workspace is a miss here: create, 201 with the new id", async () => {
|
|
1541
|
+
jest.mocked(prisma.lead.findUnique).mockImplementation((async (args: {
|
|
1542
|
+
where: { workspaceId_idempotencyKey?: { workspaceId: string } };
|
|
1543
|
+
}) =>
|
|
1544
|
+
args.where.workspaceId_idempotencyKey?.workspaceId ===
|
|
1545
|
+
FOREIGN_WORKSPACE_ID
|
|
1546
|
+
? {
|
|
1547
|
+
id: STORED_ID,
|
|
1548
|
+
email: body.email,
|
|
1549
|
+
source: body.source,
|
|
1550
|
+
payload: body.payload,
|
|
1551
|
+
}
|
|
1552
|
+
: null) as never);
|
|
1553
|
+
|
|
1554
|
+
const res = await send({ "Idempotency-Key": KEY });
|
|
1555
|
+
|
|
1556
|
+
expect(res.statusCode).toBe(201);
|
|
1557
|
+
expect(res.json().id).toBe(NEW_ID);
|
|
1558
|
+
expect(prisma.lead.create).toHaveBeenCalledTimes(1);
|
|
1559
|
+
});
|
|
1560
|
+
|
|
1561
|
+
it("case 8 — the key reaches no response body and no log line on a fresh write, a replay or a conflict", async () => {
|
|
1562
|
+
const stored = {
|
|
1563
|
+
id: STORED_ID,
|
|
1564
|
+
email: body.email,
|
|
1565
|
+
source: body.source,
|
|
1566
|
+
payload: body.payload,
|
|
1567
|
+
};
|
|
1568
|
+
(prisma.lead.findUnique as jest.Mock)
|
|
1569
|
+
.mockResolvedValueOnce(null) // fresh write
|
|
1570
|
+
.mockResolvedValueOnce(stored) // replay
|
|
1571
|
+
.mockResolvedValueOnce({ ...stored, email: "other@example.com" }); // conflict
|
|
1572
|
+
|
|
1573
|
+
const statuses: number[] = [];
|
|
1574
|
+
for (let i = 0; i < 3; i++) {
|
|
1575
|
+
const res = await send({ "Idempotency-Key": KEY });
|
|
1576
|
+
statuses.push(res.statusCode);
|
|
1577
|
+
keyIsNowhere(res.body);
|
|
1578
|
+
}
|
|
1579
|
+
|
|
1580
|
+
expect(statuses).toEqual([201, 201, 422]);
|
|
1581
|
+
expect(logLines.length).toBeGreaterThan(0);
|
|
1582
|
+
});
|
|
1583
|
+
|
|
1584
|
+
it("case 7 — subscribe and callback ignore the header", async () => {
|
|
1585
|
+
(prisma.subscriber.create as jest.Mock).mockResolvedValue({
|
|
1586
|
+
id: "subscr_1",
|
|
1587
|
+
});
|
|
1588
|
+
(prisma.callback.create as jest.Mock).mockResolvedValue({ id: "callbk_1" });
|
|
1589
|
+
|
|
1590
|
+
const sub = await send(
|
|
1591
|
+
{ "Idempotency-Key": "bad" },
|
|
1592
|
+
"/v1/leads/subscribe",
|
|
1593
|
+
{
|
|
1594
|
+
kind: "subscribe",
|
|
1595
|
+
email: "sub@example.com",
|
|
1596
|
+
source: "footer",
|
|
1597
|
+
},
|
|
1598
|
+
);
|
|
1599
|
+
const cb = await send({ "Idempotency-Key": KEY }, "/v1/leads/callback", {
|
|
1600
|
+
kind: "callback",
|
|
1601
|
+
name: "Alice",
|
|
1602
|
+
phone: "+15551234567",
|
|
1603
|
+
payload: {},
|
|
1604
|
+
});
|
|
1605
|
+
|
|
1606
|
+
expect(sub.statusCode).toBe(201);
|
|
1607
|
+
expect(cb.statusCode).toBe(201);
|
|
1608
|
+
expect(keyLookups()).toHaveLength(0);
|
|
1609
|
+
expect(
|
|
1610
|
+
jest.mocked(prisma.callback.create).mock.calls[0]?.[0].data,
|
|
1611
|
+
).not.toHaveProperty("idempotencyKey");
|
|
1612
|
+
});
|
|
1613
|
+
});
|