Skip to content

Commit fdb1132

Browse files
authored
Merge pull request #833 from abayomicornelius/fix/issues-752-753-754-755
test: issues #752, #753, #754, #755 — test runner, webhook/badge unit tests, Playwright scaffold
2 parents 570bc23 + 3fc8006 commit fdb1132

11 files changed

Lines changed: 454 additions & 14 deletions

File tree

.github/workflows/e2e.yml

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
name: E2E (Playwright)
2+
3+
on:
4+
pull_request_target:
5+
paths:
6+
- "frontend/**"
7+
- ".github/workflows/e2e.yml"
8+
push:
9+
branches:
10+
- main
11+
paths:
12+
- "frontend/**"
13+
- ".github/workflows/e2e.yml"
14+
15+
jobs:
16+
e2e:
17+
name: Playwright E2E
18+
runs-on: ubuntu-latest
19+
defaults:
20+
run:
21+
working-directory: frontend
22+
steps:
23+
- uses: actions/checkout@v4
24+
with:
25+
ref: ${{ github.event.pull_request.head.sha || github.sha }}
26+
- uses: actions/setup-node@v4
27+
with:
28+
node-version: 20.x
29+
cache: npm
30+
cache-dependency-path: frontend/package-lock.json
31+
- run: npm ci
32+
- run: npx playwright install --with-deps chromium
33+
- run: npm run build
34+
- name: Run Playwright tests
35+
run: npm run test:e2e
36+
env:
37+
CI: true
38+
- name: Upload Playwright report
39+
uses: actions/upload-artifact@v4
40+
if: always()
41+
with:
42+
name: playwright-report-${{ github.sha }}
43+
path: frontend/playwright-report/
44+
retention-days: 14

backend/package.json

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,9 @@
77
"dev": "tsx watch src/index.ts",
88
"start": "tsx src/index.ts",
99
"build": "tsc -p tsconfig.json",
10-
"test": "NODE_ENV=test JWT_SECRET=test-secret tsx src/auth.test.ts && NODE_ENV=test JWT_SECRET=test-secret tsx src/index.test.ts && NODE_ENV=test tsx src/services/circuit-breaker.test.ts && NODE_ENV=test tsx src/services/drip-scheduler.test.ts",
11-
"test:auth": "NODE_ENV=test JWT_SECRET=test-secret tsx src/auth.test.ts",
12-
"test:circuit-breaker": "NODE_ENV=test tsx src/services/circuit-breaker.test.ts",
13-
"test:drip-scheduler": "NODE_ENV=test tsx src/services/drip-scheduler.test.ts",
14-
"test:pbt": "tsx src/rate-limit.test.ts",
10+
"test": "NODE_ENV=test JWT_SECRET=test-secret node --import tsx --test \"src/**/*.test.ts\"",
11+
"test:watch": "NODE_ENV=test JWT_SECRET=test-secret node --import tsx --test --watch \"src/**/*.test.ts\"",
12+
"test:coverage": "NODE_ENV=test JWT_SECRET=test-secret node --import tsx --test --experimental-test-coverage \"src/**/*.test.ts\"",
1513
"db:generate": "prisma generate",
1614
"db:migrate": "prisma migrate dev",
1715
"db:migrate:deploy": "prisma migrate deploy",
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
import { test, mock } from "node:test";
2+
import assert from "node:assert/strict";
3+
import { checkAndAwardBadges } from "./badge-awarder.js";
4+
5+
const ALL_BADGES = [
6+
{ id: "b-first", criteria: "first_support", name: "First Supporter" },
7+
{ id: "b-ten", criteria: "ten_supporters", name: "10 Supporters" },
8+
{ id: "b-xlm", criteria: "total_100_xlm", name: "100 XLM Club" },
9+
{ id: "b-milestone", criteria: "milestone_reached", name: "Milestone Maker" },
10+
];
11+
12+
function buildPrismaMock(overrides: {
13+
badges?: unknown[];
14+
existingAwards?: { badgeId: string }[];
15+
txCount?: number;
16+
uniqueSupporters?: { supporterAddress: string }[];
17+
totalsByAsset?: { assetCode: string; assetIssuer: string | null; _sum: { amount: unknown } }[];
18+
milestonesReached?: number;
19+
createImpl?: () => Promise<unknown>;
20+
} = {}) {
21+
const profileBadgeCreate = mock.fn(overrides.createImpl ?? (() => Promise.resolve({})));
22+
23+
const tx = {
24+
$executeRaw: mock.fn(() => Promise.resolve()),
25+
badge: {
26+
findMany: mock.fn(() => Promise.resolve(overrides.badges ?? ALL_BADGES)),
27+
},
28+
profileBadge: {
29+
findMany: mock.fn(() => Promise.resolve(overrides.existingAwards ?? [])),
30+
create: profileBadgeCreate,
31+
},
32+
supportTransaction: {
33+
count: mock.fn(() => Promise.resolve(overrides.txCount ?? 0)),
34+
findMany: mock.fn(() => Promise.resolve(overrides.uniqueSupporters ?? [])),
35+
groupBy: mock.fn(() => Promise.resolve(overrides.totalsByAsset ?? [])),
36+
},
37+
milestone: {
38+
count: mock.fn(() => Promise.resolve(overrides.milestonesReached ?? 0)),
39+
},
40+
};
41+
42+
const $transaction = mock.fn((cb: (tx: unknown) => Promise<void>) => cb(tx));
43+
44+
return { $transaction, tx, profileBadgeCreate };
45+
}
46+
47+
function awardedCriteria(profileBadgeCreate: ReturnType<typeof mock.fn>): string[] {
48+
return profileBadgeCreate.mock.calls.map((c) => {
49+
const badgeId = (c.arguments[0] as { data: { badgeId: string } }).data.badgeId;
50+
return ALL_BADGES.find((b) => b.id === badgeId)?.criteria ?? badgeId;
51+
});
52+
}
53+
54+
test("first_support is awarded when txCount >= 1", async () => {
55+
const { $transaction, profileBadgeCreate } = buildPrismaMock({
56+
badges: [ALL_BADGES[0]],
57+
txCount: 1,
58+
});
59+
await checkAndAwardBadges("profile-1", { $transaction } as any);
60+
assert.deepEqual(awardedCriteria(profileBadgeCreate), ["first_support"]);
61+
});
62+
63+
test("first_support is NOT awarded when txCount is 0", async () => {
64+
const { $transaction, profileBadgeCreate } = buildPrismaMock({
65+
badges: [ALL_BADGES[0]],
66+
txCount: 0,
67+
});
68+
await checkAndAwardBadges("profile-1", { $transaction } as any);
69+
assert.equal(profileBadgeCreate.mock.calls.length, 0);
70+
});
71+
72+
test("ten_supporters is awarded when uniqueSupporters.length >= 10", async () => {
73+
const supporters = Array.from({ length: 10 }, (_, i) => ({ supporterAddress: `G${i}` }));
74+
const { $transaction, profileBadgeCreate } = buildPrismaMock({
75+
badges: [ALL_BADGES[1]],
76+
uniqueSupporters: supporters,
77+
});
78+
await checkAndAwardBadges("profile-1", { $transaction } as any);
79+
assert.deepEqual(awardedCriteria(profileBadgeCreate), ["ten_supporters"]);
80+
});
81+
82+
test("ten_supporters is NOT awarded when uniqueSupporters.length < 10", async () => {
83+
const supporters = Array.from({ length: 9 }, (_, i) => ({ supporterAddress: `G${i}` }));
84+
const { $transaction, profileBadgeCreate } = buildPrismaMock({
85+
badges: [ALL_BADGES[1]],
86+
uniqueSupporters: supporters,
87+
});
88+
await checkAndAwardBadges("profile-1", { $transaction } as any);
89+
assert.equal(profileBadgeCreate.mock.calls.length, 0);
90+
});
91+
92+
test("total_100_xlm is awarded for XLM totals >= 100, ignoring USDC", async () => {
93+
const { $transaction, profileBadgeCreate } = buildPrismaMock({
94+
badges: [ALL_BADGES[2]],
95+
totalsByAsset: [
96+
{ assetCode: "XLM", assetIssuer: null, _sum: { amount: 150 } },
97+
{ assetCode: "USDC", assetIssuer: "GISSUER", _sum: { amount: 100000 } },
98+
],
99+
});
100+
await checkAndAwardBadges("profile-1", { $transaction } as any);
101+
assert.deepEqual(awardedCriteria(profileBadgeCreate), ["total_100_xlm"]);
102+
});
103+
104+
test("total_100_xlm is NOT awarded from USDC totals alone", async () => {
105+
const { $transaction, profileBadgeCreate } = buildPrismaMock({
106+
badges: [ALL_BADGES[2]],
107+
totalsByAsset: [
108+
{ assetCode: "USDC", assetIssuer: "GISSUER", _sum: { amount: 100000 } },
109+
],
110+
});
111+
await checkAndAwardBadges("profile-1", { $transaction } as any);
112+
assert.equal(profileBadgeCreate.mock.calls.length, 0);
113+
});
114+
115+
test("milestone_reached is awarded when at least one milestone is reached", async () => {
116+
const { $transaction, profileBadgeCreate } = buildPrismaMock({
117+
badges: [ALL_BADGES[3]],
118+
milestonesReached: 1,
119+
});
120+
await checkAndAwardBadges("profile-1", { $transaction } as any);
121+
assert.deepEqual(awardedCriteria(profileBadgeCreate), ["milestone_reached"]);
122+
});
123+
124+
test("already-awarded badge is not re-awarded, and a P2002 race is silently ignored", async () => {
125+
const { $transaction, profileBadgeCreate } = buildPrismaMock({
126+
badges: [ALL_BADGES[0]],
127+
existingAwards: [{ badgeId: "b-first" }],
128+
txCount: 5,
129+
});
130+
await checkAndAwardBadges("profile-1", { $transaction } as any);
131+
assert.equal(profileBadgeCreate.mock.calls.length, 0);
132+
133+
const p2002Error = Object.assign(new Error("Unique constraint failed"), { code: "P2002" });
134+
const { $transaction: tx2, profileBadgeCreate: create2 } = buildPrismaMock({
135+
badges: [ALL_BADGES[0]],
136+
txCount: 1,
137+
createImpl: () => Promise.reject(p2002Error),
138+
});
139+
await assert.doesNotReject(() => checkAndAwardBadges("profile-1", { $transaction: tx2 } as any));
140+
assert.equal(create2.mock.calls.length, 1);
141+
});
142+
143+
test("all badges already awarded returns early without checking any criteria", async () => {
144+
const { $transaction, tx, profileBadgeCreate } = buildPrismaMock({
145+
badges: ALL_BADGES,
146+
existingAwards: ALL_BADGES.map((b) => ({ badgeId: b.id })),
147+
});
148+
await checkAndAwardBadges("profile-1", { $transaction } as any);
149+
assert.equal(profileBadgeCreate.mock.calls.length, 0);
150+
assert.equal((tx.supportTransaction.count as ReturnType<typeof mock.fn>).mock.calls.length, 0);
151+
});

backend/src/services/badge-awarder.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,11 @@ function profileLockKey(profileId: string): bigint {
1919
return h;
2020
}
2121

22-
export async function checkAndAwardBadges(profileId: string): Promise<void> {
22+
export async function checkAndAwardBadges(profileId: string, prismaClient = prisma): Promise<void> {
2323
const lockKey = profileLockKey(profileId);
2424

2525
try {
26-
await prisma.$transaction(async (tx) => {
26+
await prismaClient.$transaction(async (tx) => {
2727
// Serialize concurrent badge checks for the same profile using a
2828
// transaction-scoped advisory lock. Concurrent callers block until the
2929
// current check commits, then each runs with the latest DB state rather
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
import { test, mock } from "node:test";
2+
import assert from "node:assert/strict";
3+
import { processPendingWebhookDeliveries } from "./webhook-processor.js";
4+
5+
function makeDelivery(overrides: Record<string, unknown> = {}) {
6+
return {
7+
id: "delivery-1",
8+
webhookId: "webhook-1",
9+
payload: { event: "support.created" },
10+
status: "pending",
11+
attemptCount: 0,
12+
nextRetryAt: new Date(),
13+
webhook: { url: "https://example.com/hook", secret: "s3cret" },
14+
...overrides,
15+
};
16+
}
17+
18+
function buildPrismaMock(overrides: {
19+
deliveries?: unknown[];
20+
claimCount?: number;
21+
} = {}) {
22+
const findMany = mock.fn(() => Promise.resolve(overrides.deliveries ?? [makeDelivery()]));
23+
const updateMany = mock.fn(() => Promise.resolve({ count: overrides.claimCount ?? 1 }));
24+
const update = mock.fn(() => Promise.resolve({}));
25+
26+
return {
27+
prismaClient: {
28+
webhookDelivery: { findMany, update, updateMany },
29+
},
30+
findMany,
31+
updateMany,
32+
update,
33+
};
34+
}
35+
36+
function getUpdateData(update: ReturnType<typeof mock.fn>, callIndex = 0): Record<string, unknown> {
37+
return (update.mock.calls[callIndex]!.arguments[0] as { data: Record<string, unknown> }).data;
38+
}
39+
40+
test("successful delivery updates the record to success and increments attemptCount", async () => {
41+
const { prismaClient, update } = buildPrismaMock({
42+
deliveries: [makeDelivery({ attemptCount: 0 })],
43+
});
44+
const deliver = mock.fn(() => Promise.resolve({ status: "success", statusCode: 200 }));
45+
46+
await processPendingWebhookDeliveries(prismaClient as any, deliver as any);
47+
48+
assert.equal(update.mock.calls.length, 1);
49+
const data = getUpdateData(update);
50+
assert.equal(data.status, "success");
51+
assert.equal(data.attemptCount, 1);
52+
assert.equal(data.lastError, null);
53+
});
54+
55+
test("HTTP 500 keeps status pending and sets nextRetryAt to the backoff schedule", async () => {
56+
const { prismaClient, update } = buildPrismaMock({
57+
deliveries: [makeDelivery({ attemptCount: 0 })],
58+
});
59+
const deliver = mock.fn(() =>
60+
Promise.resolve({ status: "failed", error: "HTTP 500", willRetry: true }),
61+
);
62+
63+
await processPendingWebhookDeliveries(prismaClient as any, deliver as any);
64+
65+
const data = getUpdateData(update);
66+
assert.equal(data.status, "pending");
67+
assert.equal(data.attemptCount, 1);
68+
assert.ok(data.nextRetryAt instanceof Date);
69+
assert.ok((data.nextRetryAt as Date).getTime() > Date.now());
70+
});
71+
72+
test("HTTP 4xx permanent failure sets status failed with no further retry scheduled", async () => {
73+
const { prismaClient, update } = buildPrismaMock({
74+
deliveries: [makeDelivery({ attemptCount: 0 })],
75+
});
76+
const deliver = mock.fn(() =>
77+
Promise.resolve({ status: "failed", error: "HTTP 404", willRetry: false }),
78+
);
79+
80+
await processPendingWebhookDeliveries(prismaClient as any, deliver as any);
81+
82+
const data = getUpdateData(update);
83+
assert.equal(data.status, "failed");
84+
assert.equal(data.nextRetryAt, null);
85+
});
86+
87+
test("reaching max attempts sets status to failed", async () => {
88+
// attemptCount is already at MAX_DELIVERY_ATTEMPTS - 1 (2); the query itself
89+
// filters attemptCount < MAX_DELIVERY_ATTEMPTS (3), so this is the last try.
90+
const { prismaClient, update } = buildPrismaMock({
91+
deliveries: [makeDelivery({ attemptCount: 2 })],
92+
});
93+
const deliver = mock.fn(() =>
94+
Promise.resolve({ status: "failed", error: "HTTP 500", willRetry: true }),
95+
);
96+
97+
await processPendingWebhookDeliveries(prismaClient as any, deliver as any);
98+
99+
const data = getUpdateData(update);
100+
// nextAttempt = 3, shouldRetry(3) is expected to be false at the max attempt.
101+
assert.equal(data.status, "failed");
102+
assert.equal(data.attemptCount, 3);
103+
});
104+
105+
test("a network timeout is treated as a transient failure and scheduled for retry", async () => {
106+
const { prismaClient, update } = buildPrismaMock({
107+
deliveries: [makeDelivery({ attemptCount: 0 })],
108+
});
109+
const deliver = mock.fn(() =>
110+
Promise.resolve({ status: "failed", error: "The operation was aborted due to timeout", willRetry: true }),
111+
);
112+
113+
await processPendingWebhookDeliveries(prismaClient as any, deliver as any);
114+
115+
const data = getUpdateData(update);
116+
assert.equal(data.status, "pending");
117+
assert.ok(data.nextRetryAt instanceof Date);
118+
});
119+
120+
test("a row already claimed by a concurrent run is skipped (no delivery attempted)", async () => {
121+
const { prismaClient, update } = buildPrismaMock({
122+
deliveries: [makeDelivery()],
123+
claimCount: 0,
124+
});
125+
const deliver = mock.fn(() => Promise.resolve({ status: "success", statusCode: 200 }));
126+
127+
await processPendingWebhookDeliveries(prismaClient as any, deliver as any);
128+
129+
assert.equal(deliver.mock.calls.length, 0);
130+
assert.equal(update.mock.calls.length, 0);
131+
});

0 commit comments

Comments
 (0)