@wopr-network/platform-core 1.35.0 → 1.35.2
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/billing/crypto/btc/settler.js +3 -2
- package/dist/billing/crypto/evm/eth-settler.js +3 -1
- package/dist/billing/crypto/unified-checkout.js +1 -1
- package/dist/email/handlebars-renderer.test.d.ts +1 -0
- package/dist/email/handlebars-renderer.test.js +174 -0
- package/dist/fleet/fleet-notification-listener.test.d.ts +1 -0
- package/dist/fleet/fleet-notification-listener.test.js +244 -0
- package/dist/fleet/init-fleet-updater.d.ts +1 -1
- package/dist/fleet/init-fleet-updater.js +19 -3
- package/dist/fleet/init-fleet-updater.test.d.ts +1 -0
- package/dist/fleet/init-fleet-updater.test.js +186 -0
- package/dist/trpc/admin-fleet-update-router.test.d.ts +1 -0
- package/dist/trpc/admin-fleet-update-router.test.js +164 -0
- package/package.json +1 -1
- package/src/billing/crypto/btc/settler.ts +3 -2
- package/src/billing/crypto/evm/eth-settler.ts +3 -1
- package/src/billing/crypto/unified-checkout.ts +1 -1
- package/src/email/handlebars-renderer.test.ts +209 -0
- package/src/fleet/fleet-notification-listener.test.ts +322 -0
- package/src/fleet/init-fleet-updater.test.ts +234 -0
- package/src/fleet/init-fleet-updater.ts +22 -4
- package/src/trpc/admin-fleet-update-router.test.ts +201 -0
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests the profile-filtering + onManualTenantsSkipped logic used inside initFleetUpdater.
|
|
3
|
+
*
|
|
4
|
+
* We can't import initFleetUpdater directly because it constructs ImagePoller,
|
|
5
|
+
* ContainerUpdater, etc. which pull in heavy dependencies that cause hangs.
|
|
6
|
+
* Instead, we replicate the getUpdatableProfiles closure from initFleetUpdater
|
|
7
|
+
* and test it in isolation via a RolloutOrchestrator with mock deps.
|
|
8
|
+
*/
|
|
9
|
+
import { describe, expect, it, vi } from "vitest";
|
|
10
|
+
import { RolloutOrchestrator } from "./rollout-orchestrator.js";
|
|
11
|
+
vi.mock("../config/logger.js", () => ({
|
|
12
|
+
logger: {
|
|
13
|
+
info: vi.fn(),
|
|
14
|
+
error: vi.fn(),
|
|
15
|
+
warn: vi.fn(),
|
|
16
|
+
debug: vi.fn(),
|
|
17
|
+
},
|
|
18
|
+
}));
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
// Helpers
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
function makeProfileWithFields(fields) {
|
|
23
|
+
return {
|
|
24
|
+
...fields,
|
|
25
|
+
image: "ghcr.io/org/img:latest",
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
function makeProfileRepo(profiles) {
|
|
29
|
+
return {
|
|
30
|
+
list: vi.fn().mockResolvedValue(profiles),
|
|
31
|
+
get: vi
|
|
32
|
+
.fn()
|
|
33
|
+
.mockImplementation((id) => Promise.resolve(profiles.find((p) => p.id === id) ?? null)),
|
|
34
|
+
save: vi.fn().mockResolvedValue(undefined),
|
|
35
|
+
delete: vi.fn().mockResolvedValue(false),
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
function makeConfigRepo(configs = {}) {
|
|
39
|
+
return {
|
|
40
|
+
get: vi.fn().mockImplementation((tenantId) => {
|
|
41
|
+
const cfg = configs[tenantId];
|
|
42
|
+
return Promise.resolve(cfg ? { tenantId, ...cfg, updatedAt: Date.now() } : null);
|
|
43
|
+
}),
|
|
44
|
+
upsert: vi.fn().mockResolvedValue(undefined),
|
|
45
|
+
listAutoEnabled: vi.fn().mockResolvedValue([]),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
function makeMockUpdater() {
|
|
49
|
+
return {
|
|
50
|
+
updateBot: vi.fn().mockResolvedValue({
|
|
51
|
+
botId: "",
|
|
52
|
+
success: true,
|
|
53
|
+
previousImage: "",
|
|
54
|
+
newImage: "",
|
|
55
|
+
previousDigest: null,
|
|
56
|
+
newDigest: null,
|
|
57
|
+
rolledBack: false,
|
|
58
|
+
}),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
function makeMockSnapshotManager() {
|
|
62
|
+
return {
|
|
63
|
+
snapshot: vi.fn(),
|
|
64
|
+
restore: vi.fn(),
|
|
65
|
+
delete: vi.fn(),
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
function makeMockStrategy() {
|
|
69
|
+
return {
|
|
70
|
+
nextBatch: vi.fn().mockImplementation((remaining) => remaining),
|
|
71
|
+
pauseDuration: vi.fn().mockReturnValue(0),
|
|
72
|
+
onBotFailure: vi.fn().mockReturnValue("skip"),
|
|
73
|
+
maxRetries: vi.fn().mockReturnValue(0),
|
|
74
|
+
healthCheckTimeout: vi.fn().mockReturnValue(0),
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Builds the same getUpdatableProfiles closure that initFleetUpdater creates,
|
|
79
|
+
* so we can test the filtering + callback logic without importing the heavy module.
|
|
80
|
+
*/
|
|
81
|
+
function buildGetUpdatableProfiles(profileRepo, configRepo, onManualTenantsSkipped, imageTag = "v1.2.3") {
|
|
82
|
+
return async () => {
|
|
83
|
+
const profiles = await profileRepo.list();
|
|
84
|
+
const manualPolicyIds = [];
|
|
85
|
+
const nonManualPolicy = profiles.filter((p) => {
|
|
86
|
+
if (p.updatePolicy === "manual") {
|
|
87
|
+
manualPolicyIds.push(p.tenantId);
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
return true;
|
|
91
|
+
});
|
|
92
|
+
if (!configRepo) {
|
|
93
|
+
if (manualPolicyIds.length > 0 && onManualTenantsSkipped) {
|
|
94
|
+
onManualTenantsSkipped([...new Set(manualPolicyIds)], imageTag);
|
|
95
|
+
}
|
|
96
|
+
return nonManualPolicy;
|
|
97
|
+
}
|
|
98
|
+
const configManualIds = [];
|
|
99
|
+
const results = await Promise.all(nonManualPolicy.map(async (p) => {
|
|
100
|
+
const tenantCfg = await configRepo.get(p.tenantId);
|
|
101
|
+
if (tenantCfg && tenantCfg.mode === "manual") {
|
|
102
|
+
configManualIds.push(p.tenantId);
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
return p;
|
|
106
|
+
}));
|
|
107
|
+
const allManualIds = [...manualPolicyIds, ...configManualIds];
|
|
108
|
+
if (allManualIds.length > 0 && onManualTenantsSkipped) {
|
|
109
|
+
onManualTenantsSkipped([...new Set(allManualIds)], imageTag);
|
|
110
|
+
}
|
|
111
|
+
return results.filter((p) => p !== null);
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
// ---------------------------------------------------------------------------
|
|
115
|
+
// Tests
|
|
116
|
+
// ---------------------------------------------------------------------------
|
|
117
|
+
describe("initFleetUpdater — onManualTenantsSkipped", () => {
|
|
118
|
+
it("callback fires with tenant IDs of manual-mode tenants (policy-based)", async () => {
|
|
119
|
+
const profiles = [
|
|
120
|
+
makeProfileWithFields({ id: "b1", tenantId: "t-manual", updatePolicy: "manual" }),
|
|
121
|
+
makeProfileWithFields({ id: "b2", tenantId: "t-auto", updatePolicy: "auto" }),
|
|
122
|
+
];
|
|
123
|
+
const profileRepo = makeProfileRepo(profiles);
|
|
124
|
+
const onManualTenantsSkipped = vi.fn();
|
|
125
|
+
const orchestrator = new RolloutOrchestrator({
|
|
126
|
+
updater: makeMockUpdater(),
|
|
127
|
+
snapshotManager: makeMockSnapshotManager(),
|
|
128
|
+
strategy: makeMockStrategy(),
|
|
129
|
+
getUpdatableProfiles: buildGetUpdatableProfiles(profileRepo, undefined, onManualTenantsSkipped),
|
|
130
|
+
});
|
|
131
|
+
await orchestrator.rollout();
|
|
132
|
+
expect(onManualTenantsSkipped).toHaveBeenCalledWith(["t-manual"], "v1.2.3");
|
|
133
|
+
});
|
|
134
|
+
it("callback deduplicates tenant IDs", async () => {
|
|
135
|
+
const profiles = [
|
|
136
|
+
makeProfileWithFields({ id: "b1", tenantId: "t-dup", updatePolicy: "manual" }),
|
|
137
|
+
makeProfileWithFields({ id: "b2", tenantId: "t-dup", updatePolicy: "manual" }),
|
|
138
|
+
makeProfileWithFields({ id: "b3", tenantId: "t-auto", updatePolicy: "auto" }),
|
|
139
|
+
];
|
|
140
|
+
const profileRepo = makeProfileRepo(profiles);
|
|
141
|
+
const onManualTenantsSkipped = vi.fn();
|
|
142
|
+
const orchestrator = new RolloutOrchestrator({
|
|
143
|
+
updater: makeMockUpdater(),
|
|
144
|
+
snapshotManager: makeMockSnapshotManager(),
|
|
145
|
+
strategy: makeMockStrategy(),
|
|
146
|
+
getUpdatableProfiles: buildGetUpdatableProfiles(profileRepo, undefined, onManualTenantsSkipped),
|
|
147
|
+
});
|
|
148
|
+
await orchestrator.rollout();
|
|
149
|
+
expect(onManualTenantsSkipped).toHaveBeenCalledWith(["t-dup"], "v1.2.3");
|
|
150
|
+
});
|
|
151
|
+
it("callback not called when no manual tenants exist", async () => {
|
|
152
|
+
const profiles = [
|
|
153
|
+
makeProfileWithFields({ id: "b1", tenantId: "t1", updatePolicy: "auto" }),
|
|
154
|
+
makeProfileWithFields({ id: "b2", tenantId: "t2", updatePolicy: "auto" }),
|
|
155
|
+
];
|
|
156
|
+
const profileRepo = makeProfileRepo(profiles);
|
|
157
|
+
const onManualTenantsSkipped = vi.fn();
|
|
158
|
+
const orchestrator = new RolloutOrchestrator({
|
|
159
|
+
updater: makeMockUpdater(),
|
|
160
|
+
snapshotManager: makeMockSnapshotManager(),
|
|
161
|
+
strategy: makeMockStrategy(),
|
|
162
|
+
getUpdatableProfiles: buildGetUpdatableProfiles(profileRepo, undefined, onManualTenantsSkipped),
|
|
163
|
+
});
|
|
164
|
+
await orchestrator.rollout();
|
|
165
|
+
expect(onManualTenantsSkipped).not.toHaveBeenCalled();
|
|
166
|
+
});
|
|
167
|
+
it("callback fires with config-repo manual tenants when configRepo is provided", async () => {
|
|
168
|
+
const profiles = [
|
|
169
|
+
makeProfileWithFields({ id: "b1", tenantId: "t-cfg-manual", updatePolicy: "auto" }),
|
|
170
|
+
makeProfileWithFields({ id: "b2", tenantId: "t-cfg-auto", updatePolicy: "auto" }),
|
|
171
|
+
];
|
|
172
|
+
const profileRepo = makeProfileRepo(profiles);
|
|
173
|
+
const configRepo = makeConfigRepo({
|
|
174
|
+
"t-cfg-manual": { mode: "manual", preferredHourUtc: 3 },
|
|
175
|
+
});
|
|
176
|
+
const onManualTenantsSkipped = vi.fn();
|
|
177
|
+
const orchestrator = new RolloutOrchestrator({
|
|
178
|
+
updater: makeMockUpdater(),
|
|
179
|
+
snapshotManager: makeMockSnapshotManager(),
|
|
180
|
+
strategy: makeMockStrategy(),
|
|
181
|
+
getUpdatableProfiles: buildGetUpdatableProfiles(profileRepo, configRepo, onManualTenantsSkipped),
|
|
182
|
+
});
|
|
183
|
+
await orchestrator.rollout();
|
|
184
|
+
expect(onManualTenantsSkipped).toHaveBeenCalledWith(["t-cfg-manual"], "v1.2.3");
|
|
185
|
+
});
|
|
186
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { createAdminFleetUpdateRouter } from "./admin-fleet-update-router.js";
|
|
3
|
+
import { createCallerFactory, router, setTrpcOrgMemberRepo } from "./init.js";
|
|
4
|
+
vi.mock("../config/logger.js", () => ({
|
|
5
|
+
logger: {
|
|
6
|
+
info: vi.fn(),
|
|
7
|
+
error: vi.fn(),
|
|
8
|
+
warn: vi.fn(),
|
|
9
|
+
debug: vi.fn(),
|
|
10
|
+
},
|
|
11
|
+
}));
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
// Helpers
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
function makeMockOrgRepo() {
|
|
16
|
+
return {
|
|
17
|
+
listMembers: vi.fn().mockResolvedValue([]),
|
|
18
|
+
addMember: vi.fn().mockResolvedValue(undefined),
|
|
19
|
+
updateMemberRole: vi.fn().mockResolvedValue(undefined),
|
|
20
|
+
removeMember: vi.fn().mockResolvedValue(undefined),
|
|
21
|
+
findMember: vi.fn().mockResolvedValue(null),
|
|
22
|
+
countAdminsAndOwners: vi.fn().mockResolvedValue(0),
|
|
23
|
+
listInvites: vi.fn().mockResolvedValue([]),
|
|
24
|
+
createInvite: vi.fn().mockResolvedValue(undefined),
|
|
25
|
+
findInviteById: vi.fn().mockResolvedValue(null),
|
|
26
|
+
findInviteByToken: vi.fn().mockResolvedValue(null),
|
|
27
|
+
deleteInvite: vi.fn().mockResolvedValue(undefined),
|
|
28
|
+
deleteAllMembers: vi.fn().mockResolvedValue(undefined),
|
|
29
|
+
deleteAllInvites: vi.fn().mockResolvedValue(undefined),
|
|
30
|
+
listOrgsByUser: vi.fn().mockResolvedValue([]),
|
|
31
|
+
markInviteAccepted: vi.fn().mockResolvedValue(undefined),
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
function makeOrchestrator(overrides = {}) {
|
|
35
|
+
return {
|
|
36
|
+
isRolling: false,
|
|
37
|
+
rollout: vi.fn().mockResolvedValue({
|
|
38
|
+
totalBots: 0,
|
|
39
|
+
succeeded: 0,
|
|
40
|
+
failed: 0,
|
|
41
|
+
skipped: 0,
|
|
42
|
+
aborted: false,
|
|
43
|
+
alreadyRunning: false,
|
|
44
|
+
results: [],
|
|
45
|
+
}),
|
|
46
|
+
...overrides,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
function makeConfigRepo(configs = {}) {
|
|
50
|
+
return {
|
|
51
|
+
get: vi.fn().mockImplementation((tenantId) => Promise.resolve(configs[tenantId] ?? null)),
|
|
52
|
+
upsert: vi.fn().mockResolvedValue(undefined),
|
|
53
|
+
listAutoEnabled: vi.fn().mockResolvedValue(Object.values(configs)),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
const adminCtx = {
|
|
57
|
+
user: { id: "admin-1", roles: ["platform_admin"] },
|
|
58
|
+
tenantId: undefined,
|
|
59
|
+
};
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
// Tests
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
describe("createAdminFleetUpdateRouter", () => {
|
|
64
|
+
let orchestrator;
|
|
65
|
+
let configRepo;
|
|
66
|
+
beforeEach(() => {
|
|
67
|
+
setTrpcOrgMemberRepo(makeMockOrgRepo());
|
|
68
|
+
orchestrator = makeOrchestrator();
|
|
69
|
+
configRepo = makeConfigRepo();
|
|
70
|
+
});
|
|
71
|
+
function makeCaller() {
|
|
72
|
+
const fleetRouter = createAdminFleetUpdateRouter(() => orchestrator, () => configRepo);
|
|
73
|
+
const appRouter = router({ fleet: fleetRouter });
|
|
74
|
+
const createCaller = createCallerFactory(appRouter);
|
|
75
|
+
return createCaller(adminCtx);
|
|
76
|
+
}
|
|
77
|
+
describe("rolloutStatus", () => {
|
|
78
|
+
it("returns isRolling from orchestrator (false)", async () => {
|
|
79
|
+
const caller = makeCaller();
|
|
80
|
+
const status = await caller.fleet.rolloutStatus();
|
|
81
|
+
expect(status).toEqual({ isRolling: false });
|
|
82
|
+
});
|
|
83
|
+
it("returns isRolling from orchestrator (true)", async () => {
|
|
84
|
+
orchestrator = makeOrchestrator({ isRolling: true });
|
|
85
|
+
const caller = makeCaller();
|
|
86
|
+
const status = await caller.fleet.rolloutStatus();
|
|
87
|
+
expect(status).toEqual({ isRolling: true });
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
describe("forceRollout", () => {
|
|
91
|
+
it("calls orchestrator.rollout()", async () => {
|
|
92
|
+
const caller = makeCaller();
|
|
93
|
+
const result = await caller.fleet.forceRollout();
|
|
94
|
+
expect(result).toEqual({ triggered: true });
|
|
95
|
+
expect(orchestrator.rollout).toHaveBeenCalledOnce();
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
describe("listTenantConfigs", () => {
|
|
99
|
+
it("delegates to repo.listAutoEnabled()", async () => {
|
|
100
|
+
const configs = [
|
|
101
|
+
{ tenantId: "t1", mode: "auto", preferredHourUtc: 3, updatedAt: Date.now() },
|
|
102
|
+
{ tenantId: "t2", mode: "auto", preferredHourUtc: 12, updatedAt: Date.now() },
|
|
103
|
+
];
|
|
104
|
+
vi.mocked(configRepo.listAutoEnabled).mockResolvedValue(configs);
|
|
105
|
+
const caller = makeCaller();
|
|
106
|
+
const result = await caller.fleet.listTenantConfigs();
|
|
107
|
+
expect(configRepo.listAutoEnabled).toHaveBeenCalledOnce();
|
|
108
|
+
expect(result).toEqual(configs);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
describe("setTenantConfig", () => {
|
|
112
|
+
it("preserves existing preferredHourUtc when not provided", async () => {
|
|
113
|
+
const existing = {
|
|
114
|
+
tenantId: "t1",
|
|
115
|
+
mode: "auto",
|
|
116
|
+
preferredHourUtc: 17,
|
|
117
|
+
updatedAt: Date.now(),
|
|
118
|
+
};
|
|
119
|
+
vi.mocked(configRepo.get).mockResolvedValue(existing);
|
|
120
|
+
const caller = makeCaller();
|
|
121
|
+
await caller.fleet.setTenantConfig({ tenantId: "t1", mode: "manual" });
|
|
122
|
+
expect(configRepo.get).toHaveBeenCalledWith("t1");
|
|
123
|
+
expect(configRepo.upsert).toHaveBeenCalledWith("t1", {
|
|
124
|
+
mode: "manual",
|
|
125
|
+
preferredHourUtc: 17, // preserved from existing
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
it("uses provided preferredHourUtc when given", async () => {
|
|
129
|
+
const existing = {
|
|
130
|
+
tenantId: "t1",
|
|
131
|
+
mode: "auto",
|
|
132
|
+
preferredHourUtc: 17,
|
|
133
|
+
updatedAt: Date.now(),
|
|
134
|
+
};
|
|
135
|
+
vi.mocked(configRepo.get).mockResolvedValue(existing);
|
|
136
|
+
const caller = makeCaller();
|
|
137
|
+
await caller.fleet.setTenantConfig({
|
|
138
|
+
tenantId: "t1",
|
|
139
|
+
mode: "auto",
|
|
140
|
+
preferredHourUtc: 9,
|
|
141
|
+
});
|
|
142
|
+
expect(configRepo.upsert).toHaveBeenCalledWith("t1", {
|
|
143
|
+
mode: "auto",
|
|
144
|
+
preferredHourUtc: 9,
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
it("defaults to 3 when no existing config and preferredHourUtc not provided", async () => {
|
|
148
|
+
vi.mocked(configRepo.get).mockResolvedValue(null);
|
|
149
|
+
const caller = makeCaller();
|
|
150
|
+
await caller.fleet.setTenantConfig({ tenantId: "t-new", mode: "auto" });
|
|
151
|
+
expect(configRepo.upsert).toHaveBeenCalledWith("t-new", {
|
|
152
|
+
mode: "auto",
|
|
153
|
+
preferredHourUtc: 3, // default
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
it("rejects non-admin users", async () => {
|
|
158
|
+
const fleetRouter = createAdminFleetUpdateRouter(() => orchestrator, () => configRepo);
|
|
159
|
+
const appRouter = router({ fleet: fleetRouter });
|
|
160
|
+
const createCaller = createCallerFactory(appRouter);
|
|
161
|
+
const caller = createCaller({ user: { id: "u1", roles: ["user"] }, tenantId: undefined });
|
|
162
|
+
await expect(caller.fleet.rolloutStatus()).rejects.toThrow("Platform admin role required");
|
|
163
|
+
});
|
|
164
|
+
});
|
package/package.json
CHANGED
|
@@ -44,8 +44,9 @@ export async function settleBtcPayment(deps: BtcSettlerDeps, event: BtcPaymentEv
|
|
|
44
44
|
return { handled: true, status: "Settled", tenant: charge.tenantId, creditedCents: 0 };
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
-
//
|
|
48
|
-
|
|
47
|
+
// 2% underpayment tolerance for oracle price drift between checkout and settlement.
|
|
48
|
+
const UNDERPAYMENT_TOLERANCE = 0.98;
|
|
49
|
+
if (event.amountUsdCents < charge.amountUsdCents * UNDERPAYMENT_TOLERANCE) {
|
|
49
50
|
return { handled: true, status: "Settled", tenant: charge.tenantId, creditedCents: 0 };
|
|
50
51
|
}
|
|
51
52
|
|
|
@@ -42,7 +42,9 @@ export async function settleEthPayment(deps: EthSettlerDeps, event: EthPaymentEv
|
|
|
42
42
|
return { handled: true, status: "Settled", tenant: charge.tenantId, creditedCents: 0 };
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
-
|
|
45
|
+
// 2% underpayment tolerance for oracle price drift between checkout and settlement.
|
|
46
|
+
const UNDERPAYMENT_TOLERANCE = 0.98;
|
|
47
|
+
if (event.amountUsdCents < charge.amountUsdCents * UNDERPAYMENT_TOLERANCE) {
|
|
46
48
|
return { handled: true, status: "Settled", tenant: charge.tenantId, creditedCents: 0 };
|
|
47
49
|
}
|
|
48
50
|
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { HandlebarsRenderer } from "./handlebars-renderer.js";
|
|
3
|
+
import type { INotificationTemplateRepository, NotificationTemplateRow } from "./notification-repository-types.js";
|
|
4
|
+
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// Helpers
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
|
|
9
|
+
function makeTemplate(overrides: Partial<NotificationTemplateRow> = {}): NotificationTemplateRow {
|
|
10
|
+
return {
|
|
11
|
+
id: "tpl-1",
|
|
12
|
+
name: "test-template",
|
|
13
|
+
description: "A test template",
|
|
14
|
+
subject: "Hello {{name}}",
|
|
15
|
+
htmlBody: "<h1>Hello {{name}}</h1>",
|
|
16
|
+
textBody: "Hello {{name}}",
|
|
17
|
+
active: true,
|
|
18
|
+
createdAt: Date.now(),
|
|
19
|
+
updatedAt: Date.now(),
|
|
20
|
+
...overrides,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function makeRepo(templates: Record<string, NotificationTemplateRow | null> = {}): INotificationTemplateRepository {
|
|
25
|
+
return {
|
|
26
|
+
getByName: vi.fn().mockImplementation((name: string) => Promise.resolve(templates[name] ?? null)),
|
|
27
|
+
list: vi.fn().mockResolvedValue(Object.values(templates).filter(Boolean)),
|
|
28
|
+
upsert: vi.fn().mockResolvedValue(undefined),
|
|
29
|
+
} as unknown as INotificationTemplateRepository;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
// Tests
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
describe("HandlebarsRenderer", () => {
|
|
37
|
+
it("returns null for unknown template", async () => {
|
|
38
|
+
const repo = makeRepo({});
|
|
39
|
+
const renderer = new HandlebarsRenderer(repo);
|
|
40
|
+
|
|
41
|
+
const result = await renderer.render("nonexistent", { name: "World" });
|
|
42
|
+
|
|
43
|
+
expect(result).toBeNull();
|
|
44
|
+
expect(repo.getByName).toHaveBeenCalledWith("nonexistent");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("returns null for inactive template", async () => {
|
|
48
|
+
const repo = makeRepo({
|
|
49
|
+
"inactive-tpl": makeTemplate({ name: "inactive-tpl", active: false }),
|
|
50
|
+
});
|
|
51
|
+
const renderer = new HandlebarsRenderer(repo);
|
|
52
|
+
|
|
53
|
+
const result = await renderer.render("inactive-tpl", { name: "World" });
|
|
54
|
+
|
|
55
|
+
expect(result).toBeNull();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("compiles Handlebars and returns subject/html/text", async () => {
|
|
59
|
+
const repo = makeRepo({
|
|
60
|
+
greeting: makeTemplate({
|
|
61
|
+
name: "greeting",
|
|
62
|
+
subject: "Hi {{name}}!",
|
|
63
|
+
htmlBody: "<p>Welcome, {{name}}!</p>",
|
|
64
|
+
textBody: "Welcome, {{name}}!",
|
|
65
|
+
}),
|
|
66
|
+
});
|
|
67
|
+
const renderer = new HandlebarsRenderer(repo);
|
|
68
|
+
|
|
69
|
+
const result = await renderer.render("greeting", { name: "Alice" });
|
|
70
|
+
|
|
71
|
+
expect(result).not.toBeNull();
|
|
72
|
+
expect(result?.subject).toBe("Hi Alice!");
|
|
73
|
+
expect(result?.html).toBe("<p>Welcome, Alice!</p>");
|
|
74
|
+
expect(result?.text).toBe("Welcome, Alice!");
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("injects currentYear automatically", async () => {
|
|
78
|
+
const repo = makeRepo({
|
|
79
|
+
footer: makeTemplate({
|
|
80
|
+
name: "footer",
|
|
81
|
+
subject: "Year: {{currentYear}}",
|
|
82
|
+
htmlBody: "<p>© {{currentYear}}</p>",
|
|
83
|
+
textBody: "(c) {{currentYear}}",
|
|
84
|
+
}),
|
|
85
|
+
});
|
|
86
|
+
const renderer = new HandlebarsRenderer(repo);
|
|
87
|
+
|
|
88
|
+
const result = await renderer.render("footer", {});
|
|
89
|
+
|
|
90
|
+
const year = new Date().getFullYear();
|
|
91
|
+
expect(result?.subject).toBe(`Year: ${year}`);
|
|
92
|
+
expect(result?.html).toBe(`<p>© ${year}</p>`);
|
|
93
|
+
expect(result?.text).toBe(`(c) ${year}`);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("does not override explicit currentYear from data", async () => {
|
|
97
|
+
const repo = makeRepo({
|
|
98
|
+
footer: makeTemplate({
|
|
99
|
+
name: "footer",
|
|
100
|
+
subject: "Year: {{currentYear}}",
|
|
101
|
+
htmlBody: "<p>{{currentYear}}</p>",
|
|
102
|
+
textBody: "{{currentYear}}",
|
|
103
|
+
}),
|
|
104
|
+
});
|
|
105
|
+
const renderer = new HandlebarsRenderer(repo);
|
|
106
|
+
|
|
107
|
+
const result = await renderer.render("footer", { currentYear: 2099 });
|
|
108
|
+
|
|
109
|
+
expect(result?.subject).toBe("Year: 2099");
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
describe("helpers", () => {
|
|
113
|
+
it("eq helper returns true for equal values", async () => {
|
|
114
|
+
const repo = makeRepo({
|
|
115
|
+
cond: makeTemplate({
|
|
116
|
+
name: "cond",
|
|
117
|
+
subject: '{{#if (eq status "active")}}Active{{else}}Inactive{{/if}}',
|
|
118
|
+
htmlBody: "ok",
|
|
119
|
+
textBody: "ok",
|
|
120
|
+
}),
|
|
121
|
+
});
|
|
122
|
+
const renderer = new HandlebarsRenderer(repo);
|
|
123
|
+
|
|
124
|
+
const active = await renderer.render("cond", { status: "active" });
|
|
125
|
+
expect(active?.subject).toBe("Active");
|
|
126
|
+
|
|
127
|
+
const inactive = await renderer.render("cond", { status: "disabled" });
|
|
128
|
+
expect(inactive?.subject).toBe("Inactive");
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("gt helper compares numbers", async () => {
|
|
132
|
+
const repo = makeRepo({
|
|
133
|
+
gt: makeTemplate({
|
|
134
|
+
name: "gt",
|
|
135
|
+
subject: "{{#if (gt count 5)}}Many{{else}}Few{{/if}}",
|
|
136
|
+
htmlBody: "ok",
|
|
137
|
+
textBody: "ok",
|
|
138
|
+
}),
|
|
139
|
+
});
|
|
140
|
+
const renderer = new HandlebarsRenderer(repo);
|
|
141
|
+
|
|
142
|
+
const many = await renderer.render("gt", { count: 10 });
|
|
143
|
+
expect(many?.subject).toBe("Many");
|
|
144
|
+
|
|
145
|
+
const few = await renderer.render("gt", { count: 3 });
|
|
146
|
+
expect(few?.subject).toBe("Few");
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("formatDate helper formats timestamps", async () => {
|
|
150
|
+
const repo = makeRepo({
|
|
151
|
+
dated: makeTemplate({
|
|
152
|
+
name: "dated",
|
|
153
|
+
subject: "Date: {{formatDate ts}}",
|
|
154
|
+
htmlBody: "ok",
|
|
155
|
+
textBody: "ok",
|
|
156
|
+
}),
|
|
157
|
+
});
|
|
158
|
+
const renderer = new HandlebarsRenderer(repo);
|
|
159
|
+
|
|
160
|
+
// Use noon UTC to avoid timezone date-boundary shifts
|
|
161
|
+
const ts = new Date("2025-01-15T12:00:00Z").getTime();
|
|
162
|
+
const result = await renderer.render("dated", { ts });
|
|
163
|
+
|
|
164
|
+
// The formatted string uses en-US locale with year/month/day
|
|
165
|
+
const expected = new Date(ts).toLocaleDateString("en-US", {
|
|
166
|
+
year: "numeric",
|
|
167
|
+
month: "long",
|
|
168
|
+
day: "numeric",
|
|
169
|
+
});
|
|
170
|
+
expect(result?.subject).toBe(`Date: ${expected}`);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("formatDate helper passes through non-numeric values", async () => {
|
|
174
|
+
const repo = makeRepo({
|
|
175
|
+
dated: makeTemplate({
|
|
176
|
+
name: "dated",
|
|
177
|
+
subject: "Date: {{formatDate ts}}",
|
|
178
|
+
htmlBody: "ok",
|
|
179
|
+
textBody: "ok",
|
|
180
|
+
}),
|
|
181
|
+
});
|
|
182
|
+
const renderer = new HandlebarsRenderer(repo);
|
|
183
|
+
|
|
184
|
+
const result = await renderer.render("dated", { ts: "not-a-number" });
|
|
185
|
+
|
|
186
|
+
expect(result?.subject).toBe("Date: not-a-number");
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it("escapeHtml helper escapes special characters", async () => {
|
|
190
|
+
const repo = makeRepo({
|
|
191
|
+
esc: makeTemplate({
|
|
192
|
+
name: "esc",
|
|
193
|
+
subject: "Safe: {{escapeHtml input}}",
|
|
194
|
+
htmlBody: "ok",
|
|
195
|
+
textBody: "ok",
|
|
196
|
+
}),
|
|
197
|
+
});
|
|
198
|
+
const renderer = new HandlebarsRenderer(repo);
|
|
199
|
+
|
|
200
|
+
const result = await renderer.render("esc", {
|
|
201
|
+
input: '<script>alert("xss")</script>',
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
expect(result?.subject).toContain("<script>");
|
|
205
|
+
expect(result?.subject).toContain(""xss"");
|
|
206
|
+
expect(result?.subject).not.toContain("<script>");
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
});
|