create-ailk 0.15.0 → 0.17.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 +124 -0
- package/dist/add-module.js +52 -7
- package/dist/add-route.js +8 -0
- package/dist/adopt.d.ts +74 -0
- package/dist/adopt.js +314 -0
- package/dist/cli.js +82 -2
- package/dist/derive-architecture.d.ts +22 -0
- package/dist/derive-architecture.js +58 -1
- package/dist/lib/declared-env.d.ts +142 -0
- package/dist/lib/declared-env.js +985 -0
- package/dist/lib/env-catalog.d.ts +17 -0
- package/dist/lib/env-catalog.js +234 -0
- package/dist/lib/extract-module-bundle.d.ts +9 -0
- package/dist/lib/extract-module-bundle.js +18 -1
- package/dist/lib/module-tier.d.ts +2 -0
- package/dist/lib/module-tier.js +4 -0
- package/dist/module-architecture.js +76 -0
- package/dist/parse-args.d.ts +5 -0
- package/dist/parse-args.js +17 -1
- package/dist/programmatic.d.ts +8 -0
- package/dist/programmatic.js +78 -9
- package/dist/seed-flows.d.ts +65 -0
- package/dist/seed-flows.js +350 -0
- package/dist/site-manifest.d.ts +43 -0
- package/dist/site-manifest.js +98 -3
- package/dist/site-roster.d.ts +13 -0
- package/dist/site-roster.js +22 -8
- package/dist/spoke-checkout.d.ts +67 -0
- package/dist/spoke-checkout.js +562 -0
- package/dist/surfaces.js +7 -0
- package/dist/sync-routes.js +23 -8
- package/dist/topology.d.ts +124 -9
- package/dist/topology.js +451 -40
- package/package.json +3 -3
- package/templates/.env.example +10 -0
- package/templates/apps/api/CLAUDE.md +19 -4
- package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +95 -16
- package/templates/apps/api/src/__tests__/openapi-keyless-hub.test.ts +122 -0
- package/templates/apps/api/src/__tests__/support/hub-sign-in-fake-db.ts +362 -0
- package/templates/apps/api/src/bin/spokes.ts +62 -1
- package/templates/apps/api/src/config/__tests__/hub-require-spoke-key.test.ts +75 -0
- package/templates/apps/api/src/config/index.ts +35 -0
- package/templates/apps/api/src/lib/__mocks__/prisma.ts +89 -0
- package/templates/apps/api/src/lib/__tests__/deliverable-asset-store.test.ts +66 -5
- package/templates/apps/api/src/lib/__tests__/hub-sign-in-store.test.ts +209 -0
- package/templates/apps/api/src/lib/__tests__/spoke-store.test.ts +20 -0
- package/templates/apps/api/src/lib/deliverable-asset-store.ts +24 -5
- package/templates/apps/api/src/lib/hub-sign-in-store.ts +419 -0
- package/templates/apps/api/src/lib/spoke-store.ts +25 -3
- package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +310 -0
- package/templates/apps/api/src/middleware/auth.ts +126 -0
- package/templates/apps/api/src/routes/auth/__tests__/hub-sign-in.test.ts +483 -0
- package/templates/apps/api/src/routes/auth/hub-sign-in.ts +201 -0
- package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +7 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +105 -0
- package/templates/apps/api/src/routes/webhooks/stripe.ts +72 -1
- package/templates/apps/api/src/server.ts +105 -0
- package/templates/apps/api/src/services/__tests__/deliverable-fulfillment.test.ts +4 -4
- package/templates/apps/api/src/services/__tests__/hub-sign-in.test.ts +854 -0
- package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +293 -0
- package/templates/apps/api/src/services/__tests__/person.test.ts +553 -0
- package/templates/apps/api/src/services/hub-sign-in.ts +471 -0
- package/templates/apps/api/src/services/lead-routing.ts +131 -2
- package/templates/apps/api/src/services/person.ts +541 -0
- package/templates/apps/web/.env.example +58 -5
- package/templates/apps/web/app/[locale]/(auth)/sign-in/next-path.ts +0 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +206 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +256 -3
- package/templates/apps/web/app/[locale]/(authed)/account/account-data.ts +5 -8
- package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/__tests__/route.test.ts +205 -0
- package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/route.ts +82 -0
- package/templates/apps/web/app/[locale]/(authed)/account/settings/page.tsx +98 -29
- package/templates/apps/web/app/[locale]/(authed)/account/support-actions.ts +6 -3
- package/templates/apps/web/app/[locale]/(authed)/layout.tsx +19 -101
- package/templates/apps/web/app/[locale]/(authed)/signed-out-gate.generated.tsx +90 -0
- package/templates/apps/web/app/[locale]/layout.tsx +28 -15
- package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +295 -0
- package/templates/apps/web/app/api/auth/hub/authorize/route.ts +156 -0
- package/templates/apps/web/app/api/hub-session/__tests__/routes.test.ts +341 -0
- package/templates/apps/web/app/api/hub-session/callback/route.ts +168 -0
- package/templates/apps/web/app/api/hub-session/sign-in/route.ts +76 -0
- package/templates/apps/web/app/api/hub-session/sign-out/route.ts +63 -0
- package/templates/apps/web/app/openapi.json/__tests__/route.test.ts +57 -24
- package/templates/apps/web/app/openapi.json/route.ts +3 -6
- package/templates/apps/web/auth.ts +29 -2
- package/templates/apps/web/components/sign-out-trigger.tsx +20 -1
- package/templates/apps/web/components/signed-in-nav.tsx +17 -3
- package/templates/apps/web/lib/__tests__/account-config.test.ts +5 -2
- package/templates/apps/web/lib/__tests__/listings-config.test.ts +83 -0
- package/templates/apps/web/lib/__tests__/listings.test.tsx +49 -0
- package/templates/apps/web/lib/__tests__/meeting-slugs.test.ts +49 -1
- package/templates/apps/web/lib/__tests__/relay.test.ts +33 -0
- package/templates/apps/web/lib/__tests__/server-api.test.ts +37 -28
- package/templates/apps/web/lib/account-config.ts +130 -1
- package/templates/apps/web/lib/api-origin.generated.ts +11 -0
- package/templates/apps/web/lib/hub-session/__tests__/hub-session.test.ts +262 -0
- package/templates/apps/web/lib/hub-session/config.ts +104 -0
- package/templates/apps/web/lib/hub-session/index.ts +23 -0
- package/templates/apps/web/lib/hub-session/seal.ts +82 -0
- package/templates/apps/web/lib/hub-session/session.ts +180 -0
- package/templates/apps/web/lib/hub-session/sign-in-gate.tsx +79 -0
- package/templates/apps/web/lib/listings-config.ts +31 -0
- package/templates/apps/web/lib/listings.tsx +7 -3
- package/templates/apps/web/lib/meeting-slugs.ts +11 -1
- package/templates/apps/web/lib/next-path.ts +35 -0
- package/templates/apps/web/lib/relay.ts +3 -1
- package/templates/apps/web/lib/server-api.ts +11 -1
- package/templates/apps/web/lib/topology.generated.ts +15 -5
- package/templates/apps/web/messages/en.json +32 -3
- package/templates/database/CHANGELOG.md +26 -0
- package/templates/database/__tests__/agents.db.test.ts +25 -5
- package/templates/database/__tests__/fixtures/person-backfill.json +264 -0
- package/templates/database/__tests__/member-area.db.test.ts +245 -0
- package/templates/database/__tests__/person.db.test.ts +584 -0
- package/templates/database/auth/schema.prisma +3 -2
- package/templates/database/config/schema.prisma +13 -0
- package/templates/database/inbox/schema.prisma +98 -0
- package/templates/database/migrations/20261010120000_person/migration.sql +106 -0
- package/templates/database/migrations/20261010121000_person_backfill/migration.sql +132 -0
- package/templates/database/migrations/20261010130000_agent_grants_people_read/migration.sql +20 -0
- package/templates/database/migrations/20261011120000_member_area/migration.sql +158 -0
- package/templates/database/migrations/20261011130000_agent_grants_member_pieces_manage/migration.sql +21 -0
- package/templates/database/package.json +1 -1
- package/templates/database/scripts/person-backfill.sql +132 -0
- package/templates/database/tenancy.ts +53 -0
- package/templates/tools/check-declared-env.ts +37 -0
- package/templates/tools/declared-env.ts +1163 -0
- package/versions.json +35 -35
|
@@ -33,8 +33,12 @@ import * as fs from "fs/promises";
|
|
|
33
33
|
import * as os from "os";
|
|
34
34
|
import * as path from "path";
|
|
35
35
|
|
|
36
|
+
import { logger } from "@working-theory/observability";
|
|
37
|
+
|
|
38
|
+
import { MOCK_PERSON_ID } from "../../lib/__mocks__/prisma.js";
|
|
36
39
|
import { prisma } from "../../lib/prisma.js";
|
|
37
40
|
import { routeLead } from "../lead-routing";
|
|
41
|
+
import * as personService from "../person";
|
|
38
42
|
|
|
39
43
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
40
44
|
const sourceMeta = {
|
|
@@ -946,3 +950,292 @@ describe("#7869 — routeLead with an idempotency key", () => {
|
|
|
946
950
|
expect(result).toMatchObject({ ok: true, persisted: "file" });
|
|
947
951
|
});
|
|
948
952
|
});
|
|
953
|
+
|
|
954
|
+
// ─── #852 D6 — every submitted lead carries its Person ──────────────────────
|
|
955
|
+
//
|
|
956
|
+
// The Person step runs through the shared mock's `person` delegate, whose
|
|
957
|
+
// `findFirst` default resolves one fixed Person (`MOCK_PERSON_ID`). Lead
|
|
958
|
+
// capture is a never-gate surface: an unstorable address or a failed step
|
|
959
|
+
// writes the lead with `personId: null` and changes nothing else.
|
|
960
|
+
describe("#852 — the lead's Person (AC-3)", () => {
|
|
961
|
+
const ENV_KEYS = [
|
|
962
|
+
"DATABASE_URL",
|
|
963
|
+
"NODE_ENV",
|
|
964
|
+
"AILK_LEAD_NOTIFICATION_TO",
|
|
965
|
+
"AILK_LEAD_NOTIFICATION_FROM",
|
|
966
|
+
] as const;
|
|
967
|
+
let saved: Partial<Record<(typeof ENV_KEYS)[number], string | undefined>>;
|
|
968
|
+
const LEAD_ID = "lead_00000000-0000-7000-8000-000000000852";
|
|
969
|
+
const createData = () =>
|
|
970
|
+
jest.mocked(prisma.lead.create).mock.calls[0]?.[0].data as Record<string, unknown>;
|
|
971
|
+
const personDelegate = () => ({
|
|
972
|
+
findFirst: jest.mocked(prisma.person.findFirst),
|
|
973
|
+
createMany: jest.mocked(prisma.person.createMany),
|
|
974
|
+
updateMany: jest.mocked(prisma.person.updateMany),
|
|
975
|
+
});
|
|
976
|
+
const personCalls = () =>
|
|
977
|
+
Object.values(personDelegate()).reduce((n, fn) => n + fn.mock.calls.length, 0);
|
|
978
|
+
let warn: jest.SpyInstance;
|
|
979
|
+
let error: jest.SpyInstance;
|
|
980
|
+
|
|
981
|
+
beforeEach(() => {
|
|
982
|
+
jest.clearAllMocks();
|
|
983
|
+
saved = {};
|
|
984
|
+
for (const k of ENV_KEYS) saved[k] = process.env[k];
|
|
985
|
+
process.env.DATABASE_URL = "postgres://stub";
|
|
986
|
+
delete process.env.NODE_ENV;
|
|
987
|
+
process.env.AILK_LEAD_NOTIFICATION_TO = "owner@example.com";
|
|
988
|
+
process.env.AILK_LEAD_NOTIFICATION_FROM = "noreply@example.com";
|
|
989
|
+
(prisma.lead.create as jest.Mock).mockResolvedValue({ id: LEAD_ID });
|
|
990
|
+
mockSendLeadNotification.mockResolvedValue({ ok: true, messageId: "msg-852" });
|
|
991
|
+
warn = jest.spyOn(logger, "warn").mockImplementation(() => undefined as never);
|
|
992
|
+
error = jest.spyOn(logger, "error").mockImplementation(() => undefined as never);
|
|
993
|
+
});
|
|
994
|
+
|
|
995
|
+
afterEach(() => {
|
|
996
|
+
for (const k of ENV_KEYS) {
|
|
997
|
+
if (saved[k] === undefined) delete process.env[k];
|
|
998
|
+
else process.env[k] = saved[k];
|
|
999
|
+
}
|
|
1000
|
+
warn.mockRestore();
|
|
1001
|
+
error.mockRestore();
|
|
1002
|
+
});
|
|
1003
|
+
|
|
1004
|
+
it("a submit writes the Person findOrCreatePerson returned for (siteId, email)", async () => {
|
|
1005
|
+
const result = await routeLead(
|
|
1006
|
+
{ ...leadInput, email: "Test@Example.com", payload: { name: "Tess", message: "hi" } },
|
|
1007
|
+
sourceMeta,
|
|
1008
|
+
);
|
|
1009
|
+
|
|
1010
|
+
expect(result).toMatchObject({ ok: true, id: LEAD_ID, persisted: "db" });
|
|
1011
|
+
expect(personDelegate()["findFirst"]).toHaveBeenCalledWith(
|
|
1012
|
+
expect.objectContaining({ where: { workspaceId: "site-abc", email: "test@example.com" } }),
|
|
1013
|
+
);
|
|
1014
|
+
expect(createData()).toEqual(expect.objectContaining({ personId: MOCK_PERSON_ID }));
|
|
1015
|
+
});
|
|
1016
|
+
|
|
1017
|
+
it("subscribe and callback make no person call", async () => {
|
|
1018
|
+
(prisma.subscriber.create as jest.Mock).mockResolvedValue({ id: "subscr_x" });
|
|
1019
|
+
(prisma.callback.create as jest.Mock).mockResolvedValue({ id: "callbk_x" });
|
|
1020
|
+
await routeLead(subscribeInput, sourceMeta);
|
|
1021
|
+
await routeLead(callbackInput, sourceMeta);
|
|
1022
|
+
expect(personCalls()).toBe(0);
|
|
1023
|
+
});
|
|
1024
|
+
|
|
1025
|
+
it("an invalid_email Person result writes the lead with personId: null and one person-skipped warn with no email", async () => {
|
|
1026
|
+
// `leadInputSchema`'s email check refuses most unstorable addresses before
|
|
1027
|
+
// this step, so the Person result is stubbed to reach the branch.
|
|
1028
|
+
jest
|
|
1029
|
+
.spyOn(personService, "findOrCreatePerson")
|
|
1030
|
+
.mockResolvedValueOnce({ ok: false, error: { kind: "invalid_email" } });
|
|
1031
|
+
const result = await routeLead(leadInput, sourceMeta);
|
|
1032
|
+
|
|
1033
|
+
expect(result).toMatchObject({ ok: true, id: LEAD_ID, persisted: "db" });
|
|
1034
|
+
expect(createData()).toEqual(expect.objectContaining({ personId: null }));
|
|
1035
|
+
const skipped = warn.mock.calls.filter(([fields]) => (fields as { event?: string }).event === "person-skipped");
|
|
1036
|
+
expect(skipped).toHaveLength(1);
|
|
1037
|
+
expect(skipped[0]![0]).toEqual({ event: "person-skipped", kind: "invalid_email", siteId: "site-abc" });
|
|
1038
|
+
expect(JSON.stringify(warn.mock.calls)).not.toContain("example.com");
|
|
1039
|
+
});
|
|
1040
|
+
|
|
1041
|
+
it("an internal_error Person result is the same never-gate path as a throwing delegate", async () => {
|
|
1042
|
+
jest
|
|
1043
|
+
.spyOn(personService, "findOrCreatePerson")
|
|
1044
|
+
.mockResolvedValueOnce({ ok: false, error: { kind: "internal_error" } });
|
|
1045
|
+
const result = await routeLead(leadInput, sourceMeta);
|
|
1046
|
+
|
|
1047
|
+
expect(result).toMatchObject({ ok: true, id: LEAD_ID, persisted: "db" });
|
|
1048
|
+
expect(createData()).toEqual(expect.objectContaining({ personId: null }));
|
|
1049
|
+
expect(error.mock.calls.map(([fields]) => fields)).toContainEqual({
|
|
1050
|
+
event: "person-failed",
|
|
1051
|
+
siteId: "site-abc",
|
|
1052
|
+
});
|
|
1053
|
+
});
|
|
1054
|
+
|
|
1055
|
+
it("a Person step that fails writes the lead with personId: null, logs person-failed with no email, and answers like a successful capture", async () => {
|
|
1056
|
+
const ok = await routeLead(leadInput, sourceMeta);
|
|
1057
|
+
jest.mocked(prisma.lead.create).mockClear();
|
|
1058
|
+
mockSendLeadNotification.mockClear();
|
|
1059
|
+
personDelegate()["findFirst"]!.mockRejectedValueOnce(new Error("relation \"people\" does not exist"));
|
|
1060
|
+
|
|
1061
|
+
const failed = await routeLead(leadInput, sourceMeta);
|
|
1062
|
+
|
|
1063
|
+
expect(failed).toEqual(ok);
|
|
1064
|
+
expect(failed).toMatchObject({ ok: true, id: LEAD_ID, persisted: "db", email: { ok: true } });
|
|
1065
|
+
expect(createData()).toEqual(expect.objectContaining({ personId: null }));
|
|
1066
|
+
const lines = error.mock.calls.filter(([fields]) => (fields as { event?: string }).event === "person-failed");
|
|
1067
|
+
expect(lines).toHaveLength(1);
|
|
1068
|
+
expect(lines[0]![0]).toEqual({ event: "person-failed", siteId: "site-abc" });
|
|
1069
|
+
expect(JSON.stringify(error.mock.calls)).not.toContain("test@example.com");
|
|
1070
|
+
});
|
|
1071
|
+
});
|
|
1072
|
+
|
|
1073
|
+
// ─── #7877 D10 — a spoke's checkout lead sets the Person's plan (AC-5) ──────
|
|
1074
|
+
//
|
|
1075
|
+
// The plan step runs only after this request's own `lead.create`, for a LIVE,
|
|
1076
|
+
// paid checkout lead a registered spoke's server posted with the session id
|
|
1077
|
+
// as its key. Every other lead makes no plan call; a failing plan step never
|
|
1078
|
+
// changes the lead's result.
|
|
1079
|
+
describe("#7877 — the checkout lead sets the plan (AC-5)", () => {
|
|
1080
|
+
const ENV_KEYS = [
|
|
1081
|
+
"DATABASE_URL",
|
|
1082
|
+
"NODE_ENV",
|
|
1083
|
+
"AILK_LEAD_NOTIFICATION_TO",
|
|
1084
|
+
"AILK_LEAD_NOTIFICATION_FROM",
|
|
1085
|
+
] as const;
|
|
1086
|
+
let saved: Partial<Record<(typeof ENV_KEYS)[number], string | undefined>>;
|
|
1087
|
+
const LEAD_ID = "lead_00000000-0000-7000-8000-000000007877";
|
|
1088
|
+
const WS = "wksp_00000000-0000-7000-8000-000000007877";
|
|
1089
|
+
const KEY = "cs_live_a1B2c3"; // gitleaks:allow -- a synthetic Checkout Session id shape (#7877 AC-5), not a credential
|
|
1090
|
+
const spokeMeta = { ...sourceMeta, viaSpoke: true };
|
|
1091
|
+
const checkoutLead = {
|
|
1092
|
+
kind: "submit" as const,
|
|
1093
|
+
siteId: WS,
|
|
1094
|
+
source: "web",
|
|
1095
|
+
email: "cleo@x.io",
|
|
1096
|
+
payload: { site: "wendi", page: "checkout", offering: "wendi_retainer" } as Record<string, unknown>,
|
|
1097
|
+
};
|
|
1098
|
+
let setPlan: jest.SpyInstance;
|
|
1099
|
+
let info: jest.SpyInstance;
|
|
1100
|
+
let error: jest.SpyInstance;
|
|
1101
|
+
|
|
1102
|
+
beforeEach(() => {
|
|
1103
|
+
jest.clearAllMocks();
|
|
1104
|
+
saved = {};
|
|
1105
|
+
for (const k of ENV_KEYS) saved[k] = process.env[k];
|
|
1106
|
+
process.env.DATABASE_URL = "postgres://stub";
|
|
1107
|
+
delete process.env.NODE_ENV;
|
|
1108
|
+
process.env.AILK_LEAD_NOTIFICATION_TO = "owner@example.com";
|
|
1109
|
+
process.env.AILK_LEAD_NOTIFICATION_FROM = "noreply@example.com";
|
|
1110
|
+
(prisma.lead.create as jest.Mock).mockResolvedValue({ id: LEAD_ID });
|
|
1111
|
+
(prisma.lead.findUnique as jest.Mock).mockResolvedValue(null);
|
|
1112
|
+
mockSendLeadNotification.mockResolvedValue({ ok: true, messageId: "msg-7877" });
|
|
1113
|
+
setPlan = jest.spyOn(personService, "setPersonPlanOffering").mockResolvedValue("set");
|
|
1114
|
+
info = jest.spyOn(logger, "info").mockImplementation(() => undefined as never);
|
|
1115
|
+
error = jest.spyOn(logger, "error").mockImplementation(() => undefined as never);
|
|
1116
|
+
});
|
|
1117
|
+
|
|
1118
|
+
afterEach(() => {
|
|
1119
|
+
for (const k of ENV_KEYS) {
|
|
1120
|
+
if (saved[k] === undefined) delete process.env[k];
|
|
1121
|
+
else process.env[k] = saved[k];
|
|
1122
|
+
}
|
|
1123
|
+
setPlan.mockRestore();
|
|
1124
|
+
info.mockRestore();
|
|
1125
|
+
error.mockRestore();
|
|
1126
|
+
});
|
|
1127
|
+
|
|
1128
|
+
const planLines = () =>
|
|
1129
|
+
[...info.mock.calls, ...error.mock.calls]
|
|
1130
|
+
.map(([fields]) => fields as Record<string, unknown>)
|
|
1131
|
+
.filter((fields) => fields.event === "checkout-plan");
|
|
1132
|
+
|
|
1133
|
+
it("a live spoke checkout lead sets the plan to the offering minus the <site>_ prefix", async () => {
|
|
1134
|
+
const result = await routeLead(checkoutLead, spokeMeta, { idempotencyKey: KEY });
|
|
1135
|
+
expect(result).toMatchObject({ ok: true, id: LEAD_ID, persisted: "db" });
|
|
1136
|
+
expect(setPlan).toHaveBeenCalledTimes(1);
|
|
1137
|
+
expect(setPlan).toHaveBeenCalledWith(WS, MOCK_PERSON_ID, "retainer");
|
|
1138
|
+
expect(planLines()).toEqual([{ event: "checkout-plan", outcome: "set", siteId: WS }]);
|
|
1139
|
+
});
|
|
1140
|
+
|
|
1141
|
+
it("a bare slug offering is taken as it is", async () => {
|
|
1142
|
+
await routeLead(
|
|
1143
|
+
{ ...checkoutLead, payload: { ...checkoutLead.payload, offering: "retainer" } },
|
|
1144
|
+
spokeMeta,
|
|
1145
|
+
{ idempotencyKey: KEY },
|
|
1146
|
+
);
|
|
1147
|
+
expect(setPlan).toHaveBeenCalledWith(WS, MOCK_PERSON_ID, "retainer");
|
|
1148
|
+
});
|
|
1149
|
+
|
|
1150
|
+
it("a positive integer amountTotal still qualifies", async () => {
|
|
1151
|
+
await routeLead(
|
|
1152
|
+
{ ...checkoutLead, payload: { ...checkoutLead.payload, amountTotal: 4900 } },
|
|
1153
|
+
spokeMeta,
|
|
1154
|
+
{ idempotencyKey: KEY },
|
|
1155
|
+
);
|
|
1156
|
+
expect(setPlan).toHaveBeenCalledTimes(1);
|
|
1157
|
+
});
|
|
1158
|
+
|
|
1159
|
+
it.each([
|
|
1160
|
+
["an anonymous lead (viaSpoke false)", checkoutLead, sourceMeta, KEY],
|
|
1161
|
+
["a test-mode session key", checkoutLead, spokeMeta, "cs_test_a1B2c3"],
|
|
1162
|
+
[
|
|
1163
|
+
"amountTotal 0",
|
|
1164
|
+
{ ...checkoutLead, payload: { ...checkoutLead.payload, amountTotal: 0 } },
|
|
1165
|
+
spokeMeta,
|
|
1166
|
+
KEY,
|
|
1167
|
+
],
|
|
1168
|
+
[
|
|
1169
|
+
"a negative amountTotal",
|
|
1170
|
+
{ ...checkoutLead, payload: { ...checkoutLead.payload, amountTotal: -5 } },
|
|
1171
|
+
spokeMeta,
|
|
1172
|
+
KEY,
|
|
1173
|
+
],
|
|
1174
|
+
["a key that is not a session id", checkoutLead, spokeMeta, "abcdefghijklmnop"],
|
|
1175
|
+
["no key", checkoutLead, spokeMeta, undefined],
|
|
1176
|
+
["source contact", { ...checkoutLead, source: "contact" }, spokeMeta, KEY],
|
|
1177
|
+
[
|
|
1178
|
+
"page contact",
|
|
1179
|
+
{ ...checkoutLead, payload: { ...checkoutLead.payload, page: "contact" } },
|
|
1180
|
+
spokeMeta,
|
|
1181
|
+
KEY,
|
|
1182
|
+
],
|
|
1183
|
+
[
|
|
1184
|
+
"a numeric offering",
|
|
1185
|
+
{ ...checkoutLead, payload: { ...checkoutLead.payload, offering: 42 } },
|
|
1186
|
+
spokeMeta,
|
|
1187
|
+
KEY,
|
|
1188
|
+
],
|
|
1189
|
+
] as const)("no plan call for %s", async (_label, input, meta, key) => {
|
|
1190
|
+
const result = await routeLead(
|
|
1191
|
+
input as typeof checkoutLead,
|
|
1192
|
+
meta,
|
|
1193
|
+
key === undefined ? {} : { idempotencyKey: key },
|
|
1194
|
+
);
|
|
1195
|
+
expect(result).toMatchObject({ ok: true, persisted: "db" });
|
|
1196
|
+
expect(setPlan).not.toHaveBeenCalled();
|
|
1197
|
+
expect(jest.mocked(prisma.person.updateMany)).not.toHaveBeenCalled();
|
|
1198
|
+
expect(planLines()).toEqual([]);
|
|
1199
|
+
});
|
|
1200
|
+
|
|
1201
|
+
it("an offering that is not a key after the strip logs skipped and makes no plan call", async () => {
|
|
1202
|
+
await routeLead(
|
|
1203
|
+
{ ...checkoutLead, payload: { ...checkoutLead.payload, offering: "Wendi_Retainer!" } },
|
|
1204
|
+
spokeMeta,
|
|
1205
|
+
{ idempotencyKey: KEY },
|
|
1206
|
+
);
|
|
1207
|
+
expect(setPlan).not.toHaveBeenCalled();
|
|
1208
|
+
expect(planLines()).toEqual([{ event: "checkout-plan", outcome: "skipped", siteId: WS }]);
|
|
1209
|
+
});
|
|
1210
|
+
|
|
1211
|
+
it("a replayed key writes nothing and sets nothing", async () => {
|
|
1212
|
+
(prisma.lead.findUnique as jest.Mock).mockResolvedValue({
|
|
1213
|
+
id: LEAD_ID,
|
|
1214
|
+
email: checkoutLead.email,
|
|
1215
|
+
source: checkoutLead.source,
|
|
1216
|
+
payload: checkoutLead.payload,
|
|
1217
|
+
});
|
|
1218
|
+
const result = await routeLead(checkoutLead, spokeMeta, { idempotencyKey: KEY });
|
|
1219
|
+
expect(result).toMatchObject({ ok: true, replayed: true, id: LEAD_ID });
|
|
1220
|
+
expect(prisma.lead.create).not.toHaveBeenCalled();
|
|
1221
|
+
expect(setPlan).not.toHaveBeenCalled();
|
|
1222
|
+
});
|
|
1223
|
+
|
|
1224
|
+
it.each([
|
|
1225
|
+
["throws", () => setPlan.mockRejectedValueOnce(new Error("down"))],
|
|
1226
|
+
["answers not_found", () => setPlan.mockResolvedValueOnce("not_found")],
|
|
1227
|
+
])("a plan step that %s answers exactly like a success, with one failed line naming no offering, email or key", async (_label, arrange) => {
|
|
1228
|
+
const ok = await routeLead(checkoutLead, spokeMeta, { idempotencyKey: KEY });
|
|
1229
|
+
info.mockClear();
|
|
1230
|
+
error.mockClear();
|
|
1231
|
+
arrange();
|
|
1232
|
+
const failed = await routeLead(checkoutLead, spokeMeta, { idempotencyKey: KEY });
|
|
1233
|
+
expect(failed).toEqual(ok);
|
|
1234
|
+
expect(failed).toMatchObject({ ok: true, id: LEAD_ID, persisted: "db" });
|
|
1235
|
+
expect(planLines()).toEqual([{ event: "checkout-plan", outcome: "failed", siteId: WS }]);
|
|
1236
|
+
const logged = JSON.stringify([...info.mock.calls, ...error.mock.calls]);
|
|
1237
|
+
expect(logged).not.toContain("retainer");
|
|
1238
|
+
expect(logged).not.toContain("cleo@x.io");
|
|
1239
|
+
expect(logged).not.toContain(KEY);
|
|
1240
|
+
});
|
|
1241
|
+
});
|