@carlonicora/nextjs-jsonapi 6.10.2 → 6.11.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/dist/{BlockNoteEditor-7GQWHXBC.mjs → BlockNoteEditor-5WM5DX3J.mjs} +4 -4
- package/dist/{BlockNoteEditor-LX7NMBV6.js → BlockNoteEditor-GY5R25YI.js} +19 -19
- package/dist/{BlockNoteEditor-LX7NMBV6.js.map → BlockNoteEditor-GY5R25YI.js.map} +1 -1
- package/dist/{BlockNoteViewer-NXV5PKR4.js → BlockNoteViewer-FLUHEXE6.js} +3 -3
- package/dist/{BlockNoteViewer-NXV5PKR4.js.map → BlockNoteViewer-FLUHEXE6.js.map} +1 -1
- package/dist/{BlockNoteViewer-D3Y4FUST.mjs → BlockNoteViewer-UCA3LV3N.mjs} +2 -2
- package/dist/billing/index.js +491 -491
- package/dist/billing/index.mjs +3 -3
- package/dist/{chunk-LDUTS7SJ.mjs → chunk-3PRLRCMF.mjs} +1281 -603
- package/dist/chunk-3PRLRCMF.mjs.map +1 -0
- package/dist/{chunk-QB2XU64X.mjs → chunk-6RLATJ2L.mjs} +2 -2
- package/dist/{chunk-62P4KIR2.mjs → chunk-BGLOFZ3K.mjs} +282 -2
- package/dist/chunk-BGLOFZ3K.mjs.map +1 -0
- package/dist/{chunk-62VVBUIL.js → chunk-FNPXCYHO.js} +8 -8
- package/dist/{chunk-62VVBUIL.js.map → chunk-FNPXCYHO.js.map} +1 -1
- package/dist/{chunk-3I5JCFPY.js → chunk-MP3DV2I5.js} +1647 -969
- package/dist/chunk-MP3DV2I5.js.map +1 -0
- package/dist/{chunk-TOPGUZSX.js → chunk-OHJSI6L2.js} +4 -4
- package/dist/{chunk-TOPGUZSX.js.map → chunk-OHJSI6L2.js.map} +1 -1
- package/dist/{chunk-Z4OVEA3N.js → chunk-YGEOX2XX.js} +283 -3
- package/dist/chunk-YGEOX2XX.js.map +1 -0
- package/dist/{chunk-GHAZOQTT.mjs → chunk-YP6W4AB5.mjs} +2 -2
- package/dist/client/index.js +4 -4
- package/dist/client/index.mjs +3 -3
- package/dist/components/index.d.mts +67 -29
- package/dist/components/index.d.ts +67 -29
- package/dist/components/index.js +10 -4
- package/dist/components/index.js.map +1 -1
- package/dist/components/index.mjs +9 -3
- package/dist/contexts/index.js +4 -4
- package/dist/contexts/index.mjs +3 -3
- package/dist/core/index.d.mts +159 -3
- package/dist/core/index.d.ts +159 -3
- package/dist/core/index.js +14 -2
- package/dist/core/index.js.map +1 -1
- package/dist/core/index.mjs +13 -1
- package/dist/email-automations/index.js +93 -93
- package/dist/email-automations/index.mjs +3 -3
- package/dist/features/analytics/index.js +120 -122
- package/dist/features/analytics/index.js.map +1 -1
- package/dist/features/analytics/index.mjs +7 -9
- package/dist/features/analytics/index.mjs.map +1 -1
- package/dist/features/analytics/server.js +4 -4
- package/dist/features/analytics/server.mjs +2 -2
- package/dist/features/blog/index.js +150 -150
- package/dist/features/blog/index.mjs +3 -3
- package/dist/features/blog/server.js +16 -16
- package/dist/features/blog/server.mjs +1 -1
- package/dist/features/help/index.js +37 -37
- package/dist/features/help/index.mjs +3 -3
- package/dist/features/tokenusage/index.js +126 -128
- package/dist/features/tokenusage/index.js.map +1 -1
- package/dist/features/tokenusage/index.mjs +14 -16
- package/dist/features/tokenusage/index.mjs.map +1 -1
- package/dist/index.d.mts +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +15 -3
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +14 -2
- package/dist/{HandbookSectionInterface-5I57rC1b.d.mts → referral-admin.interface-C87ApGMR.d.mts} +41 -1
- package/dist/{HandbookSectionInterface-IDYEzQMi.d.ts → referral-admin.interface-CMwl5NiV.d.ts} +41 -1
- package/dist/server/index.js +3 -3
- package/dist/server/index.mjs +2 -2
- package/messages/en.json +78 -1
- package/messages/it.json +78 -1
- package/package.json +1 -1
- package/src/core/index.ts +2 -0
- package/src/core/registry/ModuleRegistry.ts +5 -0
- package/src/features/referral/__tests__/referral-admin-models.spec.ts +122 -0
- package/src/features/referral/__tests__/referral-admin-service.spec.ts +70 -0
- package/src/features/referral/__tests__/referral-code-capture.spec.tsx +105 -0
- package/src/features/referral/__tests__/referral-invite-errors.spec.tsx +82 -0
- package/src/features/referral/__tests__/referral-visit-service.spec.ts +47 -0
- package/src/features/referral/__tests__/referrals-admin-container.spec.tsx +230 -0
- package/src/features/referral/components/ReferralCodeCapture.tsx +3 -0
- package/src/features/referral/components/ReferralDialog.tsx +9 -13
- package/src/features/referral/components/ReferralWidget.tsx +75 -45
- package/src/features/referral/components/ReferralsAdminFunnel.tsx +74 -0
- package/src/features/referral/components/ReferralsAdminTiles.tsx +128 -0
- package/src/features/referral/components/containers/ReferralsAdminContainer.tsx +205 -0
- package/src/features/referral/components/index.ts +3 -0
- package/src/features/referral/data/ReferralAdminReferral.ts +65 -0
- package/src/features/referral/data/ReferralAdminReferrer.ts +65 -0
- package/src/features/referral/data/ReferralAdminService.ts +51 -0
- package/src/features/referral/data/ReferralAdminSummary.ts +60 -0
- package/src/features/referral/data/ReferralService.ts +20 -0
- package/src/features/referral/data/ReferralVisit.ts +23 -0
- package/src/features/referral/data/index.ts +5 -0
- package/src/features/referral/hooks/index.ts +2 -0
- package/src/features/referral/hooks/useReferralAdminReferralTableStructure.tsx +127 -0
- package/src/features/referral/hooks/useReferralAdminReferrerTableStructure.tsx +229 -0
- package/src/features/referral/hooks/useReferralInvite.ts +2 -0
- package/src/features/referral/index.ts +1 -0
- package/src/features/referral/interfaces/index.ts +1 -0
- package/src/features/referral/interfaces/referral-admin.interface.ts +45 -0
- package/src/features/referral/referral-admin.modules.ts +32 -0
- package/dist/chunk-3I5JCFPY.js.map +0 -1
- package/dist/chunk-62P4KIR2.mjs.map +0 -1
- package/dist/chunk-D6UX37IA.mjs +0 -115
- package/dist/chunk-D6UX37IA.mjs.map +0 -1
- package/dist/chunk-KEHP2OBQ.js +0 -115
- package/dist/chunk-KEHP2OBQ.js.map +0 -1
- package/dist/chunk-LDUTS7SJ.mjs.map +0 -1
- package/dist/chunk-Z4OVEA3N.js.map +0 -1
- /package/dist/{BlockNoteEditor-7GQWHXBC.mjs.map → BlockNoteEditor-5WM5DX3J.mjs.map} +0 -0
- /package/dist/{BlockNoteViewer-D3Y4FUST.mjs.map → BlockNoteViewer-UCA3LV3N.mjs.map} +0 -0
- /package/dist/{chunk-QB2XU64X.mjs.map → chunk-6RLATJ2L.mjs.map} +0 -0
- /package/dist/{chunk-GHAZOQTT.mjs.map → chunk-YP6W4AB5.mjs.map} +0 -0
package/package.json
CHANGED
package/src/core/index.ts
CHANGED
|
@@ -129,6 +129,8 @@ export * from "../features/rbac/rbac.module";
|
|
|
129
129
|
export * from "../features/referral/data";
|
|
130
130
|
export * from "../features/referral/referral.module";
|
|
131
131
|
export * from "../features/referral/referral-stats.module";
|
|
132
|
+
export * from "../features/referral/referral-admin.modules";
|
|
133
|
+
export * from "../features/referral/interfaces/referral-admin.interface";
|
|
132
134
|
// Administrative token-usage reporting modules. Exported from core (not only
|
|
133
135
|
// from the client-marked ./tokenusage barrel) so a consuming app can register
|
|
134
136
|
// them in its bootstrapper without pulling the feature's client bundle —
|
|
@@ -62,6 +62,11 @@ export interface FoundationModuleDefinitions {
|
|
|
62
62
|
// Referral modules
|
|
63
63
|
Referral: ModuleWithPermissions;
|
|
64
64
|
ReferralStats: ModuleWithPermissions;
|
|
65
|
+
// Referral link visits and the administrative referral report (opt-in: registered by apps that spread referralAdminModules)
|
|
66
|
+
ReferralVisit: ModuleWithPermissions;
|
|
67
|
+
ReferralAdminSummary: ModuleWithPermissions;
|
|
68
|
+
ReferralAdminReferrer: ModuleWithPermissions;
|
|
69
|
+
ReferralAdminReferral: ModuleWithPermissions;
|
|
65
70
|
// Two-factor authentication modules
|
|
66
71
|
TotpAuthenticator: ModuleWithPermissions;
|
|
67
72
|
TotpSetup: ModuleWithPermissions;
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { beforeAll, describe, expect, it } from "vitest";
|
|
2
|
+
import { ApiRequestDataTypeInterface } from "../../../core/interfaces/ApiRequestDataTypeInterface";
|
|
3
|
+
import { ModuleRegistry } from "../../../core/registry/ModuleRegistry";
|
|
4
|
+
import { ModuleFactory, ModuleWithPermissions } from "../../../permissions/types";
|
|
5
|
+
import { ReferralAdminReferral } from "../data/ReferralAdminReferral";
|
|
6
|
+
import { ReferralAdminReferrer } from "../data/ReferralAdminReferrer";
|
|
7
|
+
import { ReferralAdminSummary } from "../data/ReferralAdminSummary";
|
|
8
|
+
import { referralAdminModules } from "../referral-admin.modules";
|
|
9
|
+
|
|
10
|
+
const moduleFactory: ModuleFactory = (params) =>
|
|
11
|
+
({ pageUrl: params.pageUrl, name: params.name, model: params.model }) as ModuleWithPermissions;
|
|
12
|
+
|
|
13
|
+
// ModuleRegistry is shared across test files in a worker, hence the
|
|
14
|
+
// register-if-absent guard (same idiom as the analytics models spec).
|
|
15
|
+
const registerIfAbsent = (key: string, module: ApiRequestDataTypeInterface) => {
|
|
16
|
+
try {
|
|
17
|
+
ModuleRegistry.get(key as any);
|
|
18
|
+
} catch {
|
|
19
|
+
ModuleRegistry.register(key, module);
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
beforeAll(() => {
|
|
24
|
+
for (const [key, module] of Object.entries(referralAdminModules(moduleFactory))) registerIfAbsent(key, module);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const hydrate = (id: string, attributes: Record<string, unknown>) =>
|
|
28
|
+
({ jsonApi: { id, type: "t", attributes }, included: [] }) as any;
|
|
29
|
+
|
|
30
|
+
describe("referral admin models — rehydrate", () => {
|
|
31
|
+
it("rehydrates every summary attribute", () => {
|
|
32
|
+
const m = new ReferralAdminSummary().rehydrate(
|
|
33
|
+
hydrate("current", {
|
|
34
|
+
window: "current",
|
|
35
|
+
visits: 12,
|
|
36
|
+
invitesSent: 8,
|
|
37
|
+
signups: 3,
|
|
38
|
+
paying: 2,
|
|
39
|
+
creditsGiven: 800,
|
|
40
|
+
sharingCompanies: 4,
|
|
41
|
+
eligibleCompanies: 40,
|
|
42
|
+
}),
|
|
43
|
+
);
|
|
44
|
+
expect(m.id).toBe("current");
|
|
45
|
+
expect(m.window).toBe("current");
|
|
46
|
+
expect(m.visits).toBe(12);
|
|
47
|
+
expect(m.invitesSent).toBe(8);
|
|
48
|
+
expect(m.signups).toBe(3);
|
|
49
|
+
expect(m.paying).toBe(2);
|
|
50
|
+
expect(m.creditsGiven).toBe(800);
|
|
51
|
+
expect(m.sharingCompanies).toBe(4);
|
|
52
|
+
expect(m.eligibleCompanies).toBe(40);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("rehydrates every referrer attribute and parses lastInviteAt into a Date", () => {
|
|
56
|
+
const m = new ReferralAdminReferrer().rehydrate(
|
|
57
|
+
hydrate("c1", {
|
|
58
|
+
name: "Luca",
|
|
59
|
+
email: "luca@x.test",
|
|
60
|
+
visits: 5,
|
|
61
|
+
invitesSent: 4,
|
|
62
|
+
signupsLink: 2,
|
|
63
|
+
signupsDirect: 1,
|
|
64
|
+
paying: 1,
|
|
65
|
+
creditsEarned: 200,
|
|
66
|
+
lastInviteAt: "2026-10-27T10:00:00.000Z",
|
|
67
|
+
}),
|
|
68
|
+
);
|
|
69
|
+
expect(m.id).toBe("c1");
|
|
70
|
+
expect(m.name).toBe("Luca");
|
|
71
|
+
expect(m.email).toBe("luca@x.test");
|
|
72
|
+
expect(m.visits).toBe(5);
|
|
73
|
+
expect(m.invitesSent).toBe(4);
|
|
74
|
+
expect(m.signupsLink).toBe(2);
|
|
75
|
+
expect(m.signupsDirect).toBe(1);
|
|
76
|
+
expect(m.paying).toBe(1);
|
|
77
|
+
expect(m.creditsEarned).toBe(200);
|
|
78
|
+
expect(m.lastInviteAt).toBeInstanceOf(Date);
|
|
79
|
+
expect(m.lastInviteAt?.toISOString()).toBe("2026-10-27T10:00:00.000Z");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("leaves a missing lastInviteAt undefined", () => {
|
|
83
|
+
const m = new ReferralAdminReferrer().rehydrate(hydrate("c2", { name: "Sara", email: "sara@x.test" }));
|
|
84
|
+
expect(m.lastInviteAt).toBeUndefined();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("rehydrates every referral attribute and parses the datetimes into Dates", () => {
|
|
88
|
+
const m = new ReferralAdminReferral().rehydrate(
|
|
89
|
+
hydrate("r1", {
|
|
90
|
+
name: "Marta",
|
|
91
|
+
email: "marta@x.test",
|
|
92
|
+
referrerName: "Luca",
|
|
93
|
+
referrerEmail: "luca@x.test",
|
|
94
|
+
source: "link",
|
|
95
|
+
status: "completed",
|
|
96
|
+
referredAt: "2026-10-20T08:00:00.000Z",
|
|
97
|
+
completedAt: "2026-10-27T08:00:00.000Z",
|
|
98
|
+
tokensAwarded: 200,
|
|
99
|
+
}),
|
|
100
|
+
);
|
|
101
|
+
expect(m.id).toBe("r1");
|
|
102
|
+
expect(m.name).toBe("Marta");
|
|
103
|
+
expect(m.email).toBe("marta@x.test");
|
|
104
|
+
expect(m.referrerName).toBe("Luca");
|
|
105
|
+
expect(m.referrerEmail).toBe("luca@x.test");
|
|
106
|
+
expect(m.source).toBe("link");
|
|
107
|
+
expect(m.status).toBe("completed");
|
|
108
|
+
expect(m.referredAt).toBeInstanceOf(Date);
|
|
109
|
+
expect(m.referredAt.toISOString()).toBe("2026-10-20T08:00:00.000Z");
|
|
110
|
+
expect(m.completedAt).toBeInstanceOf(Date);
|
|
111
|
+
expect(m.completedAt?.toISOString()).toBe("2026-10-27T08:00:00.000Z");
|
|
112
|
+
expect(m.tokensAwarded).toBe(200);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("leaves a missing completedAt and tokensAwarded undefined", () => {
|
|
116
|
+
const m = new ReferralAdminReferral().rehydrate(
|
|
117
|
+
hydrate("r2", { name: "Tom", status: "pending", source: "direct", referredAt: "2026-10-24T08:00:00.000Z" }),
|
|
118
|
+
);
|
|
119
|
+
expect(m.completedAt).toBeUndefined();
|
|
120
|
+
expect(m.tokensAwarded).toBeUndefined();
|
|
121
|
+
});
|
|
122
|
+
});
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { AbstractService } from "../../../core/abstracts/AbstractService";
|
|
3
|
+
import { ApiRequestDataTypeInterface } from "../../../core/interfaces/ApiRequestDataTypeInterface";
|
|
4
|
+
import { ModuleRegistry } from "../../../core/registry/ModuleRegistry";
|
|
5
|
+
import { ModuleFactory, ModuleWithPermissions } from "../../../permissions/types";
|
|
6
|
+
import { ReferralAdminService } from "../data/ReferralAdminService";
|
|
7
|
+
import { referralAdminModules } from "../referral-admin.modules";
|
|
8
|
+
|
|
9
|
+
const moduleFactory: ModuleFactory = (params) =>
|
|
10
|
+
({ pageUrl: params.pageUrl, name: params.name, model: params.model }) as ModuleWithPermissions;
|
|
11
|
+
|
|
12
|
+
const registerIfAbsent = (key: string, module: ApiRequestDataTypeInterface) => {
|
|
13
|
+
try {
|
|
14
|
+
ModuleRegistry.get(key as any);
|
|
15
|
+
} catch {
|
|
16
|
+
ModuleRegistry.register(key, module);
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
beforeAll(() => {
|
|
21
|
+
for (const [key, module] of Object.entries(referralAdminModules(moduleFactory))) registerIfAbsent(key, module);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
type CallApiParams = { type: ApiRequestDataTypeInterface; method: string; endpoint: string };
|
|
25
|
+
|
|
26
|
+
// `callApi` is a protected static on AbstractService; spying on the base class
|
|
27
|
+
// intercepts every call without touching the HTTP layer.
|
|
28
|
+
const callApi = () => vi.spyOn(AbstractService as any, "callApi").mockResolvedValue([]);
|
|
29
|
+
const firstCall = (spy: ReturnType<typeof callApi>): CallApiParams => spy.mock.calls[0][0] as CallApiParams;
|
|
30
|
+
|
|
31
|
+
describe("ReferralAdminService", () => {
|
|
32
|
+
beforeEach(() => vi.restoreAllMocks());
|
|
33
|
+
|
|
34
|
+
it("getSummary sends the period to the summary endpoint", async () => {
|
|
35
|
+
const spy = callApi();
|
|
36
|
+
|
|
37
|
+
await ReferralAdminService.getSummary({ period: "30d" });
|
|
38
|
+
|
|
39
|
+
const call = firstCall(spy);
|
|
40
|
+
expect(call.type).toBe(ModuleRegistry.get("ReferralAdminSummary" as any));
|
|
41
|
+
expect(call.method).toBe("GET");
|
|
42
|
+
expect(call.endpoint).toContain("referrals/administration/summary");
|
|
43
|
+
expect(call.endpoint).toContain("period=30d");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("getReferrers sends the period and the sort", async () => {
|
|
47
|
+
const spy = callApi();
|
|
48
|
+
|
|
49
|
+
await ReferralAdminService.getReferrers({ period: "90d", sort: "invites" });
|
|
50
|
+
|
|
51
|
+
const call = firstCall(spy);
|
|
52
|
+
expect(call.type).toBe(ModuleRegistry.get("ReferralAdminReferrer" as any));
|
|
53
|
+
expect(call.method).toBe("GET");
|
|
54
|
+
expect(call.endpoint).toContain("referrals/administration/referrers");
|
|
55
|
+
expect(call.endpoint).toContain("period=90d");
|
|
56
|
+
expect(call.endpoint).toContain("sort=invites");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("getReferrals sends the period", async () => {
|
|
60
|
+
const spy = callApi();
|
|
61
|
+
|
|
62
|
+
await ReferralAdminService.getReferrals({ period: "all" });
|
|
63
|
+
|
|
64
|
+
const call = firstCall(spy);
|
|
65
|
+
expect(call.type).toBe(ModuleRegistry.get("ReferralAdminReferral" as any));
|
|
66
|
+
expect(call.method).toBe("GET");
|
|
67
|
+
expect(call.endpoint).toContain("referrals/administration/referrals");
|
|
68
|
+
expect(call.endpoint).toContain("period=all");
|
|
69
|
+
});
|
|
70
|
+
});
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { act, render } from "@testing-library/react";
|
|
2
|
+
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { ApiRequestDataTypeInterface } from "../../../core/interfaces/ApiRequestDataTypeInterface";
|
|
4
|
+
import { ModuleRegistry } from "../../../core/registry/ModuleRegistry";
|
|
5
|
+
|
|
6
|
+
const REF = "11111111-1111-4111-8111-111111111111";
|
|
7
|
+
|
|
8
|
+
const mocks = vi.hoisted(() => ({
|
|
9
|
+
searchParams: new URLSearchParams(),
|
|
10
|
+
enabled: true,
|
|
11
|
+
existingCode: null as string | null,
|
|
12
|
+
setReferralCode: vi.fn(),
|
|
13
|
+
recordVisit: vi.fn(),
|
|
14
|
+
}));
|
|
15
|
+
|
|
16
|
+
vi.mock("next/navigation", () => ({ useSearchParams: () => mocks.searchParams }));
|
|
17
|
+
vi.mock("../config", () => ({
|
|
18
|
+
isReferralEnabled: () => mocks.enabled,
|
|
19
|
+
getReferralConfig: () => ({ urlParamName: "ref" }),
|
|
20
|
+
}));
|
|
21
|
+
vi.mock("../utils/referral-cookie", () => ({
|
|
22
|
+
getReferralCode: () => mocks.existingCode,
|
|
23
|
+
setReferralCode: mocks.setReferralCode,
|
|
24
|
+
}));
|
|
25
|
+
vi.mock("../data/ReferralService", () => ({ ReferralService: { recordVisit: mocks.recordVisit } }));
|
|
26
|
+
|
|
27
|
+
import { ReferralCodeCapture } from "../components/ReferralCodeCapture";
|
|
28
|
+
import { ReferralVisit } from "../data/ReferralVisit";
|
|
29
|
+
|
|
30
|
+
// ReferralVisit.createJsonApi() resolves Modules.ReferralVisit through the real ModuleRegistry.
|
|
31
|
+
const registerIfAbsent = (key: string, module: ApiRequestDataTypeInterface) => {
|
|
32
|
+
try {
|
|
33
|
+
ModuleRegistry.get(key as any);
|
|
34
|
+
} catch {
|
|
35
|
+
ModuleRegistry.register(key, module);
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
beforeAll(() => {
|
|
40
|
+
registerIfAbsent("ReferralVisit", { name: "referrals/visits", model: ReferralVisit } as any);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe("ReferralCodeCapture", () => {
|
|
44
|
+
beforeEach(() => {
|
|
45
|
+
vi.clearAllMocks();
|
|
46
|
+
mocks.searchParams = new URLSearchParams({ ref: REF });
|
|
47
|
+
mocks.enabled = true;
|
|
48
|
+
mocks.existingCode = null;
|
|
49
|
+
mocks.recordVisit.mockResolvedValue(undefined);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("records a visit when it sets the cookie", () => {
|
|
53
|
+
render(<ReferralCodeCapture />);
|
|
54
|
+
|
|
55
|
+
expect(mocks.setReferralCode).toHaveBeenCalledTimes(1);
|
|
56
|
+
expect(mocks.setReferralCode).toHaveBeenCalledWith(REF);
|
|
57
|
+
expect(mocks.recordVisit).toHaveBeenCalledTimes(1);
|
|
58
|
+
expect(mocks.recordVisit).toHaveBeenCalledWith({ referralCode: REF });
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("does not record a visit when a cookie already exists", () => {
|
|
62
|
+
mocks.existingCode = "22222222-2222-4222-8222-222222222222";
|
|
63
|
+
|
|
64
|
+
render(<ReferralCodeCapture />);
|
|
65
|
+
|
|
66
|
+
expect(mocks.setReferralCode).not.toHaveBeenCalled();
|
|
67
|
+
expect(mocks.recordVisit).not.toHaveBeenCalled();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("does nothing when the feature is disabled", () => {
|
|
71
|
+
mocks.enabled = false;
|
|
72
|
+
|
|
73
|
+
render(<ReferralCodeCapture />);
|
|
74
|
+
|
|
75
|
+
expect(mocks.setReferralCode).not.toHaveBeenCalled();
|
|
76
|
+
expect(mocks.recordVisit).not.toHaveBeenCalled();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("a failed visit call is swallowed", async () => {
|
|
80
|
+
const unhandled = vi.fn();
|
|
81
|
+
process.on("unhandledRejection", unhandled);
|
|
82
|
+
mocks.recordVisit.mockRejectedValue(new Error("network down"));
|
|
83
|
+
|
|
84
|
+
try {
|
|
85
|
+
render(<ReferralCodeCapture />);
|
|
86
|
+
await act(async () => {
|
|
87
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
expect(mocks.setReferralCode).toHaveBeenCalledWith(REF);
|
|
91
|
+
expect(mocks.recordVisit).toHaveBeenCalledTimes(1);
|
|
92
|
+
expect(unhandled).not.toHaveBeenCalled();
|
|
93
|
+
} finally {
|
|
94
|
+
process.off("unhandledRejection", unhandled);
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe("ReferralVisit.createJsonApi()", () => {
|
|
100
|
+
it("builds the visit body with no id", () => {
|
|
101
|
+
expect(new ReferralVisit().createJsonApi({ referralCode: "x" })).toEqual({
|
|
102
|
+
data: { type: "referrals/visits", attributes: { referralCode: "x" } },
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
});
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { act, fireEvent, render, renderHook, screen } from "@testing-library/react";
|
|
2
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
|
|
4
|
+
const mocks = vi.hoisted(() => ({
|
|
5
|
+
sendReferralEmail: vi.fn(),
|
|
6
|
+
sendInvite: vi.fn(),
|
|
7
|
+
showError: vi.fn(),
|
|
8
|
+
showToast: vi.fn(),
|
|
9
|
+
}));
|
|
10
|
+
|
|
11
|
+
// Echoes the key, so an assertion names the message the user would read.
|
|
12
|
+
vi.mock("next-intl", () => ({ useTranslations: () => (key: string) => key }));
|
|
13
|
+
vi.mock("../../../utils", async () => ({
|
|
14
|
+
...(await vi.importActual<object>("../../../utils")),
|
|
15
|
+
showError: mocks.showError,
|
|
16
|
+
showToast: mocks.showToast,
|
|
17
|
+
}));
|
|
18
|
+
vi.mock("../data/ReferralService", () => ({ ReferralService: { sendReferralEmail: mocks.sendReferralEmail } }));
|
|
19
|
+
vi.mock("../hooks/useReferralStats", () => ({
|
|
20
|
+
useReferralStats: () => ({ stats: { referralCode: "abc", completedReferrals: 0 }, loading: false, error: null }),
|
|
21
|
+
}));
|
|
22
|
+
vi.mock("../hooks/useReferralInvite", async () => ({
|
|
23
|
+
...(await vi.importActual<object>("../hooks/useReferralInvite")),
|
|
24
|
+
useReferralInvite: () => ({ sendInvite: mocks.sendInvite, sending: false }),
|
|
25
|
+
}));
|
|
26
|
+
|
|
27
|
+
import { ReferralWidget } from "../components/ReferralWidget";
|
|
28
|
+
import { configureReferral } from "../config";
|
|
29
|
+
|
|
30
|
+
/** The error callApi throws when suppressGlobalError is set: the JSON:API body rides on `body`. */
|
|
31
|
+
const apiError = (code?: string) =>
|
|
32
|
+
Object.assign(new Error("400:English text from the API"), {
|
|
33
|
+
status: 400,
|
|
34
|
+
body: { errors: [{ status: "400", ...(code ? { code } : {}), detail: "English text from the API" }] },
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const sendTo = (email: string) => {
|
|
38
|
+
fireEvent.change(screen.getByPlaceholderText("referral.emailPlaceholder"), { target: { value: email } });
|
|
39
|
+
fireEvent.click(screen.getByRole("button", { name: "referral.send" }));
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
describe("referral invite errors", () => {
|
|
43
|
+
beforeEach(() => {
|
|
44
|
+
vi.clearAllMocks();
|
|
45
|
+
configureReferral({ enabled: true });
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it.each([
|
|
49
|
+
["REFERRAL_ALREADY_INVITED", "referral.alreadyInvited"],
|
|
50
|
+
["REFERRAL_ALREADY_REGISTERED", "referral.alreadyRegistered"],
|
|
51
|
+
[undefined, "referral.inviteFailed"],
|
|
52
|
+
])("code %s shows %s, never the API text", async (code, message) => {
|
|
53
|
+
mocks.sendInvite.mockRejectedValue(apiError(code));
|
|
54
|
+
render(<ReferralWidget isDialog />);
|
|
55
|
+
|
|
56
|
+
await act(async () => sendTo("gm@example.com"));
|
|
57
|
+
|
|
58
|
+
expect(mocks.showError).toHaveBeenCalledWith(message);
|
|
59
|
+
expect(mocks.showToast).not.toHaveBeenCalled();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("a sent invite shows the translated confirmation", async () => {
|
|
63
|
+
mocks.sendInvite.mockResolvedValue(undefined);
|
|
64
|
+
render(<ReferralWidget isDialog />);
|
|
65
|
+
|
|
66
|
+
await act(async () => sendTo("gm@example.com"));
|
|
67
|
+
|
|
68
|
+
expect(mocks.showToast).toHaveBeenCalledWith("referral.inviteSent");
|
|
69
|
+
expect(mocks.showError).not.toHaveBeenCalled();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("useReferralInvite rethrows a refused invite", async () => {
|
|
73
|
+
const { useReferralInvite } =
|
|
74
|
+
await vi.importActual<typeof import("../hooks/useReferralInvite")>("../hooks/useReferralInvite");
|
|
75
|
+
mocks.sendReferralEmail.mockRejectedValue(apiError("REFERRAL_ALREADY_INVITED"));
|
|
76
|
+
const { result } = renderHook(() => useReferralInvite());
|
|
77
|
+
|
|
78
|
+
await act(async () => {
|
|
79
|
+
await expect(result.current.sendInvite("gm@example.com")).rejects.toMatchObject({ status: 400 });
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
});
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { AbstractService } from "../../../core/abstracts/AbstractService";
|
|
3
|
+
import { JsonApiDataFactory } from "../../../core/factories/JsonApiDataFactory";
|
|
4
|
+
import { ApiRequestDataTypeInterface } from "../../../core/interfaces/ApiRequestDataTypeInterface";
|
|
5
|
+
import { DataClassRegistry } from "../../../core/registry/DataClassRegistry";
|
|
6
|
+
import { ModuleRegistry } from "../../../core/registry/ModuleRegistry";
|
|
7
|
+
import { ModuleFactory, ModuleWithPermissions } from "../../../permissions/types";
|
|
8
|
+
import { ReferralService } from "../data/ReferralService";
|
|
9
|
+
import { ReferralVisit } from "../data/ReferralVisit";
|
|
10
|
+
import { referralAdminModules } from "../referral-admin.modules";
|
|
11
|
+
|
|
12
|
+
const moduleFactory: ModuleFactory = (params) =>
|
|
13
|
+
({ pageUrl: params.pageUrl, name: params.name, model: params.model }) as ModuleWithPermissions;
|
|
14
|
+
|
|
15
|
+
const registerIfAbsent = (key: string, module: ApiRequestDataTypeInterface) => {
|
|
16
|
+
try {
|
|
17
|
+
ModuleRegistry.get(key as any);
|
|
18
|
+
} catch {
|
|
19
|
+
ModuleRegistry.register(key, module);
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
beforeAll(() => {
|
|
24
|
+
for (const [key, module] of Object.entries(referralAdminModules(moduleFactory))) registerIfAbsent(key, module);
|
|
25
|
+
// bootstrap() does this in an app: the factory builds the body through the registered model.
|
|
26
|
+
DataClassRegistry.registerObjectClass(ModuleRegistry.get("ReferralVisit" as any), ReferralVisit);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
type CallApiParams = { type: ApiRequestDataTypeInterface; method: string; input: unknown };
|
|
30
|
+
|
|
31
|
+
describe("ReferralService.recordVisit", () => {
|
|
32
|
+
beforeEach(() => vi.restoreAllMocks());
|
|
33
|
+
|
|
34
|
+
// JsonApiPost runs the model's createJsonApi() on `input` itself, so the body the
|
|
35
|
+
// API receives is JsonApiDataFactory.create(type, input), never `input` as is.
|
|
36
|
+
it("the body the API receives carries the referral code", async () => {
|
|
37
|
+
const spy = vi.spyOn(AbstractService as any, "callApi").mockResolvedValue(undefined);
|
|
38
|
+
|
|
39
|
+
await ReferralService.recordVisit({ referralCode: "code-1" });
|
|
40
|
+
|
|
41
|
+
const call = spy.mock.calls[0][0] as CallApiParams;
|
|
42
|
+
expect(call.method).toBe("POST");
|
|
43
|
+
expect(JsonApiDataFactory.create(call.type, call.input)).toEqual({
|
|
44
|
+
data: { type: "referrals/visits", attributes: { referralCode: "code-1" } },
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
});
|