Skip to content

Commit 1224e51

Browse files
committed
test(dymo): add unit tests for fraud blocking
1 parent 25ca544 commit 1224e51

File tree

3 files changed

+304
-36
lines changed

3 files changed

+304
-36
lines changed

packages/dymo/package.json

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,14 +14,16 @@
1414
},
1515
"scripts": {
1616
"build": "tsdown",
17-
"typecheck": "tsc --noEmit"
17+
"typecheck": "tsc --noEmit",
18+
"test": "vitest"
1819
},
1920
"dependencies": {
2021
"zod": "^3.25.76"
2122
},
2223
"devDependencies": {
2324
"paykitjs": "workspace:*",
24-
"tsdown": "^0.21.1"
25+
"tsdown": "^0.21.1",
26+
"vitest": "^4.0.18"
2527
},
2628
"peerDependencies": {
2729
"paykitjs": "workspace:*"
Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
import type { BeforeSubscribeHookCtx } from "paykitjs";
2+
import { beforeEach, describe, expect, it, vi } from "vitest";
3+
4+
import { DymoPlugin } from "../plugin";
5+
6+
function createMockHookContext(
7+
overrides: Partial<BeforeSubscribeHookCtx> = {},
8+
): BeforeSubscribeHookCtx {
9+
return {
10+
customerId: "customer_123",
11+
customerEmail: "test@example.com",
12+
plan: {
13+
id: "pro-plan",
14+
name: "Pro Plan",
15+
priceAmount: 2900,
16+
priceInterval: "month",
17+
trialDays: null,
18+
group: "default",
19+
hash: "abc123",
20+
isDefault: false,
21+
includes: [],
22+
},
23+
ip: "192.168.1.1",
24+
...overrides,
25+
};
26+
}
27+
28+
describe("DymoPlugin", () => {
29+
beforeEach(() => {
30+
vi.clearAllMocks();
31+
});
32+
33+
it("should allow subscription when email and IP are valid", async () => {
34+
const plugin = new DymoPlugin({
35+
apiKey: "test-key",
36+
resilience: { enabled: false },
37+
});
38+
39+
const mockClient = {
40+
isValidEmail: vi.fn().mockResolvedValue({
41+
allow: true,
42+
reasons: [],
43+
}),
44+
isValidIP: vi.fn().mockResolvedValue({
45+
allow: true,
46+
reasons: [],
47+
}),
48+
};
49+
50+
plugin["client"] = mockClient;
51+
52+
const ctx = createMockHookContext();
53+
54+
await expect(plugin.onBeforeSubscribe(ctx)).resolves.not.toThrow();
55+
expect(mockClient.isValidEmail).toHaveBeenCalledWith("test@example.com");
56+
expect(mockClient.isValidIP).toHaveBeenCalledWith("192.168.1.1");
57+
});
58+
59+
it("should block subscription when email is fraudulent", async () => {
60+
const plugin = new DymoPlugin({
61+
apiKey: "test-key",
62+
resilience: { enabled: false },
63+
});
64+
65+
const mockClient = {
66+
isValidEmail: vi.fn().mockResolvedValue({
67+
allow: false,
68+
reasons: ["FRAUD", "DISPOSABLE"],
69+
}),
70+
isValidIP: vi.fn().mockResolvedValue({
71+
allow: true,
72+
reasons: [],
73+
}),
74+
};
75+
76+
plugin["client"] = mockClient;
77+
78+
const ctx = createMockHookContext();
79+
80+
await expect(plugin.onBeforeSubscribe(ctx)).rejects.toThrow(
81+
"Fraud detection blocked subscription for test@example.com: FRAUD, DISPOSABLE",
82+
);
83+
});
84+
85+
it("should block subscription when IP is fraudulent", async () => {
86+
const plugin = new DymoPlugin({
87+
apiKey: "test-key",
88+
resilience: { enabled: false },
89+
});
90+
91+
const mockClient = {
92+
isValidEmail: vi.fn().mockResolvedValue({
93+
allow: true,
94+
reasons: [],
95+
}),
96+
isValidIP: vi.fn().mockResolvedValue({
97+
allow: false,
98+
reasons: ["VPN", "TOR_NETWORK"],
99+
}),
100+
};
101+
102+
plugin["client"] = mockClient;
103+
104+
const ctx = createMockHookContext();
105+
106+
await expect(plugin.onBeforeSubscribe(ctx)).rejects.toThrow(
107+
"Fraud detection blocked subscription for test@example.com: VPN, TOR_NETWORK",
108+
);
109+
});
110+
111+
it("should skip email check when customerEmail is undefined", async () => {
112+
const plugin = new DymoPlugin({
113+
apiKey: "test-key",
114+
resilience: { enabled: false },
115+
});
116+
117+
const mockClient = {
118+
isValidEmail: vi.fn(),
119+
isValidIP: vi.fn().mockResolvedValue({
120+
allow: true,
121+
reasons: [],
122+
}),
123+
};
124+
125+
plugin["client"] = mockClient;
126+
127+
const ctx = createMockHookContext({ customerEmail: undefined });
128+
129+
await expect(plugin.onBeforeSubscribe(ctx)).resolves.not.toThrow();
130+
expect(mockClient.isValidEmail).not.toHaveBeenCalled();
131+
expect(mockClient.isValidIP).toHaveBeenCalledWith("192.168.1.1");
132+
});
133+
134+
it("should skip IP check when ip is undefined", async () => {
135+
const plugin = new DymoPlugin({
136+
apiKey: "test-key",
137+
resilience: { enabled: false },
138+
});
139+
140+
const mockClient = {
141+
isValidEmail: vi.fn().mockResolvedValue({
142+
allow: true,
143+
reasons: [],
144+
}),
145+
isValidIP: vi.fn(),
146+
};
147+
148+
plugin["client"] = mockClient;
149+
150+
const ctx = createMockHookContext({ ip: undefined });
151+
152+
await expect(plugin.onBeforeSubscribe(ctx)).resolves.not.toThrow();
153+
expect(mockClient.isValidEmail).toHaveBeenCalledWith("test@example.com");
154+
expect(mockClient.isValidIP).not.toHaveBeenCalled();
155+
});
156+
157+
it("should allow subscription when resilience is enabled and API fails", async () => {
158+
const plugin = new DymoPlugin({
159+
apiKey: "test-key",
160+
resilience: { enabled: true },
161+
});
162+
163+
const mockClient = {
164+
isValidEmail: vi.fn().mockRejectedValue(new Error("API error")),
165+
isValidIP: vi.fn().mockRejectedValue(new Error("API error")),
166+
};
167+
168+
plugin["client"] = mockClient;
169+
170+
const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
171+
172+
const ctx = createMockHookContext();
173+
174+
await expect(plugin.onBeforeSubscribe(ctx)).resolves.not.toThrow();
175+
expect(consoleWarnSpy).toHaveBeenCalledWith("[PayKit-Dymo] Resilience active: Skipping check.");
176+
177+
consoleWarnSpy.mockRestore();
178+
});
179+
180+
it("should throw when resilience is disabled and API fails", async () => {
181+
const plugin = new DymoPlugin({
182+
apiKey: "test-key",
183+
resilience: { enabled: false },
184+
});
185+
186+
const mockClient = {
187+
isValidEmail: vi.fn().mockRejectedValue(new Error("API error")),
188+
isValidIP: vi.fn().mockResolvedValue({
189+
allow: true,
190+
reasons: [],
191+
}),
192+
};
193+
194+
plugin["client"] = mockClient;
195+
196+
const ctx = createMockHookContext();
197+
198+
await expect(plugin.onBeforeSubscribe(ctx)).rejects.toThrow("Fraud check service unavailable.");
199+
});
200+
201+
it("should validate config with Zod on initialization", () => {
202+
expect(() => {
203+
new DymoPlugin({ apiKey: "", resilience: { enabled: true } });
204+
}).toThrow("Dymo API Key is required");
205+
});
206+
});

0 commit comments

Comments
 (0)