@wopr-network/platform-core 1.34.0 → 1.35.1
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/address-gen.d.ts +4 -0
- package/dist/billing/crypto/btc/address-gen.js +44 -0
- package/dist/billing/crypto/oracle/chainlink.js +2 -2
- package/dist/billing/crypto/oracle/types.d.ts +1 -1
- package/dist/billing/crypto/unified-checkout.js +33 -20
- 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/drizzle/migrations/0012_seed_popular_tokens.sql +12 -0
- package/package.json +1 -1
- package/src/billing/crypto/btc/address-gen.ts +49 -0
- package/src/billing/crypto/oracle/chainlink.ts +3 -3
- package/src/billing/crypto/oracle/types.ts +1 -1
- package/src/billing/crypto/unified-checkout.ts +34 -20
- 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,234 @@
|
|
|
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 type { IBotProfileRepository } from "./bot-profile-repository.js";
|
|
11
|
+
import { RolloutOrchestrator } from "./rollout-orchestrator.js";
|
|
12
|
+
import type { IRolloutStrategy } from "./rollout-strategy.js";
|
|
13
|
+
import type { ITenantUpdateConfigRepository } from "./tenant-update-config-repository.js";
|
|
14
|
+
import type { BotProfile } from "./types.js";
|
|
15
|
+
import type { ContainerUpdater } from "./updater.js";
|
|
16
|
+
import type { VolumeSnapshotManager } from "./volume-snapshot-manager.js";
|
|
17
|
+
|
|
18
|
+
vi.mock("../config/logger.js", () => ({
|
|
19
|
+
logger: {
|
|
20
|
+
info: vi.fn(),
|
|
21
|
+
error: vi.fn(),
|
|
22
|
+
warn: vi.fn(),
|
|
23
|
+
debug: vi.fn(),
|
|
24
|
+
},
|
|
25
|
+
}));
|
|
26
|
+
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
// Helpers
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
function makeProfileWithFields(fields: { id: string; tenantId: string; updatePolicy: "auto" | "manual" }): BotProfile {
|
|
32
|
+
return {
|
|
33
|
+
...fields,
|
|
34
|
+
image: "ghcr.io/org/img:latest",
|
|
35
|
+
} as unknown as BotProfile;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function makeProfileRepo(profiles: BotProfile[]): IBotProfileRepository {
|
|
39
|
+
return {
|
|
40
|
+
list: vi.fn().mockResolvedValue(profiles),
|
|
41
|
+
get: vi
|
|
42
|
+
.fn()
|
|
43
|
+
.mockImplementation((id: string) =>
|
|
44
|
+
Promise.resolve(profiles.find((p) => (p as unknown as { id: string }).id === id) ?? null),
|
|
45
|
+
),
|
|
46
|
+
save: vi.fn().mockResolvedValue(undefined),
|
|
47
|
+
delete: vi.fn().mockResolvedValue(false),
|
|
48
|
+
} as unknown as IBotProfileRepository;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function makeConfigRepo(
|
|
52
|
+
configs: Record<string, { mode: "auto" | "manual"; preferredHourUtc: number }> = {},
|
|
53
|
+
): ITenantUpdateConfigRepository {
|
|
54
|
+
return {
|
|
55
|
+
get: vi.fn().mockImplementation((tenantId: string) => {
|
|
56
|
+
const cfg = configs[tenantId];
|
|
57
|
+
return Promise.resolve(cfg ? { tenantId, ...cfg, updatedAt: Date.now() } : null);
|
|
58
|
+
}),
|
|
59
|
+
upsert: vi.fn().mockResolvedValue(undefined),
|
|
60
|
+
listAutoEnabled: vi.fn().mockResolvedValue([]),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function makeMockUpdater(): ContainerUpdater {
|
|
65
|
+
return {
|
|
66
|
+
updateBot: vi.fn().mockResolvedValue({
|
|
67
|
+
botId: "",
|
|
68
|
+
success: true,
|
|
69
|
+
previousImage: "",
|
|
70
|
+
newImage: "",
|
|
71
|
+
previousDigest: null,
|
|
72
|
+
newDigest: null,
|
|
73
|
+
rolledBack: false,
|
|
74
|
+
}),
|
|
75
|
+
} as unknown as ContainerUpdater;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function makeMockSnapshotManager(): VolumeSnapshotManager {
|
|
79
|
+
return {
|
|
80
|
+
snapshot: vi.fn(),
|
|
81
|
+
restore: vi.fn(),
|
|
82
|
+
delete: vi.fn(),
|
|
83
|
+
} as unknown as VolumeSnapshotManager;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function makeMockStrategy(): IRolloutStrategy {
|
|
87
|
+
return {
|
|
88
|
+
nextBatch: vi.fn().mockImplementation((remaining: BotProfile[]) => remaining),
|
|
89
|
+
pauseDuration: vi.fn().mockReturnValue(0),
|
|
90
|
+
onBotFailure: vi.fn().mockReturnValue("skip" as const),
|
|
91
|
+
maxRetries: vi.fn().mockReturnValue(0),
|
|
92
|
+
healthCheckTimeout: vi.fn().mockReturnValue(0),
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Builds the same getUpdatableProfiles closure that initFleetUpdater creates,
|
|
98
|
+
* so we can test the filtering + callback logic without importing the heavy module.
|
|
99
|
+
*/
|
|
100
|
+
function buildGetUpdatableProfiles(
|
|
101
|
+
profileRepo: IBotProfileRepository,
|
|
102
|
+
configRepo: ITenantUpdateConfigRepository | undefined,
|
|
103
|
+
onManualTenantsSkipped: ((tenantIds: string[], imageTag: string) => void) | undefined,
|
|
104
|
+
imageTag = "v1.2.3",
|
|
105
|
+
): () => Promise<BotProfile[]> {
|
|
106
|
+
return async () => {
|
|
107
|
+
const profiles = await profileRepo.list();
|
|
108
|
+
|
|
109
|
+
const manualPolicyIds: string[] = [];
|
|
110
|
+
const nonManualPolicy = profiles.filter((p) => {
|
|
111
|
+
if (p.updatePolicy === "manual") {
|
|
112
|
+
manualPolicyIds.push(p.tenantId);
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
return true;
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
if (!configRepo) {
|
|
119
|
+
if (manualPolicyIds.length > 0 && onManualTenantsSkipped) {
|
|
120
|
+
onManualTenantsSkipped([...new Set(manualPolicyIds)], imageTag);
|
|
121
|
+
}
|
|
122
|
+
return nonManualPolicy;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const configManualIds: string[] = [];
|
|
126
|
+
const results = await Promise.all(
|
|
127
|
+
nonManualPolicy.map(async (p) => {
|
|
128
|
+
const tenantCfg = await configRepo.get(p.tenantId);
|
|
129
|
+
if (tenantCfg && tenantCfg.mode === "manual") {
|
|
130
|
+
configManualIds.push(p.tenantId);
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
return p;
|
|
134
|
+
}),
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
const allManualIds = [...manualPolicyIds, ...configManualIds];
|
|
138
|
+
if (allManualIds.length > 0 && onManualTenantsSkipped) {
|
|
139
|
+
onManualTenantsSkipped([...new Set(allManualIds)], imageTag);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return results.filter((p): p is BotProfile => p !== null);
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ---------------------------------------------------------------------------
|
|
147
|
+
// Tests
|
|
148
|
+
// ---------------------------------------------------------------------------
|
|
149
|
+
|
|
150
|
+
describe("initFleetUpdater — onManualTenantsSkipped", () => {
|
|
151
|
+
it("callback fires with tenant IDs of manual-mode tenants (policy-based)", async () => {
|
|
152
|
+
const profiles = [
|
|
153
|
+
makeProfileWithFields({ id: "b1", tenantId: "t-manual", updatePolicy: "manual" }),
|
|
154
|
+
makeProfileWithFields({ id: "b2", tenantId: "t-auto", updatePolicy: "auto" }),
|
|
155
|
+
];
|
|
156
|
+
const profileRepo = makeProfileRepo(profiles);
|
|
157
|
+
const onManualTenantsSkipped = vi.fn();
|
|
158
|
+
|
|
159
|
+
const orchestrator = new RolloutOrchestrator({
|
|
160
|
+
updater: makeMockUpdater(),
|
|
161
|
+
snapshotManager: makeMockSnapshotManager(),
|
|
162
|
+
strategy: makeMockStrategy(),
|
|
163
|
+
getUpdatableProfiles: buildGetUpdatableProfiles(profileRepo, undefined, onManualTenantsSkipped),
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
await orchestrator.rollout();
|
|
167
|
+
|
|
168
|
+
expect(onManualTenantsSkipped).toHaveBeenCalledWith(["t-manual"], "v1.2.3");
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("callback deduplicates tenant IDs", async () => {
|
|
172
|
+
const profiles = [
|
|
173
|
+
makeProfileWithFields({ id: "b1", tenantId: "t-dup", updatePolicy: "manual" }),
|
|
174
|
+
makeProfileWithFields({ id: "b2", tenantId: "t-dup", updatePolicy: "manual" }),
|
|
175
|
+
makeProfileWithFields({ id: "b3", tenantId: "t-auto", updatePolicy: "auto" }),
|
|
176
|
+
];
|
|
177
|
+
const profileRepo = makeProfileRepo(profiles);
|
|
178
|
+
const onManualTenantsSkipped = vi.fn();
|
|
179
|
+
|
|
180
|
+
const orchestrator = new RolloutOrchestrator({
|
|
181
|
+
updater: makeMockUpdater(),
|
|
182
|
+
snapshotManager: makeMockSnapshotManager(),
|
|
183
|
+
strategy: makeMockStrategy(),
|
|
184
|
+
getUpdatableProfiles: buildGetUpdatableProfiles(profileRepo, undefined, onManualTenantsSkipped),
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
await orchestrator.rollout();
|
|
188
|
+
|
|
189
|
+
expect(onManualTenantsSkipped).toHaveBeenCalledWith(["t-dup"], "v1.2.3");
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it("callback not called when no manual tenants exist", async () => {
|
|
193
|
+
const profiles = [
|
|
194
|
+
makeProfileWithFields({ id: "b1", tenantId: "t1", updatePolicy: "auto" }),
|
|
195
|
+
makeProfileWithFields({ id: "b2", tenantId: "t2", updatePolicy: "auto" }),
|
|
196
|
+
];
|
|
197
|
+
const profileRepo = makeProfileRepo(profiles);
|
|
198
|
+
const onManualTenantsSkipped = vi.fn();
|
|
199
|
+
|
|
200
|
+
const orchestrator = new RolloutOrchestrator({
|
|
201
|
+
updater: makeMockUpdater(),
|
|
202
|
+
snapshotManager: makeMockSnapshotManager(),
|
|
203
|
+
strategy: makeMockStrategy(),
|
|
204
|
+
getUpdatableProfiles: buildGetUpdatableProfiles(profileRepo, undefined, onManualTenantsSkipped),
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
await orchestrator.rollout();
|
|
208
|
+
|
|
209
|
+
expect(onManualTenantsSkipped).not.toHaveBeenCalled();
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it("callback fires with config-repo manual tenants when configRepo is provided", async () => {
|
|
213
|
+
const profiles = [
|
|
214
|
+
makeProfileWithFields({ id: "b1", tenantId: "t-cfg-manual", updatePolicy: "auto" }),
|
|
215
|
+
makeProfileWithFields({ id: "b2", tenantId: "t-cfg-auto", updatePolicy: "auto" }),
|
|
216
|
+
];
|
|
217
|
+
const profileRepo = makeProfileRepo(profiles);
|
|
218
|
+
const configRepo = makeConfigRepo({
|
|
219
|
+
"t-cfg-manual": { mode: "manual", preferredHourUtc: 3 },
|
|
220
|
+
});
|
|
221
|
+
const onManualTenantsSkipped = vi.fn();
|
|
222
|
+
|
|
223
|
+
const orchestrator = new RolloutOrchestrator({
|
|
224
|
+
updater: makeMockUpdater(),
|
|
225
|
+
snapshotManager: makeMockSnapshotManager(),
|
|
226
|
+
strategy: makeMockStrategy(),
|
|
227
|
+
getUpdatableProfiles: buildGetUpdatableProfiles(profileRepo, configRepo, onManualTenantsSkipped),
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
await orchestrator.rollout();
|
|
231
|
+
|
|
232
|
+
expect(onManualTenantsSkipped).toHaveBeenCalledWith(["t-cfg-manual"], "v1.2.3");
|
|
233
|
+
});
|
|
234
|
+
});
|
|
@@ -40,7 +40,7 @@ export interface FleetUpdaterConfig {
|
|
|
40
40
|
/** Optional fleet event emitter. When provided, bot.updated / bot.update_failed events are emitted. */
|
|
41
41
|
eventEmitter?: FleetEventEmitter;
|
|
42
42
|
/** Called with manual-mode tenant IDs when a new image is available but they are excluded from rollout. */
|
|
43
|
-
onManualTenantsSkipped?: (tenantIds: string[]) => void;
|
|
43
|
+
onManualTenantsSkipped?: (tenantIds: string[], imageTag: string) => void;
|
|
44
44
|
}
|
|
45
45
|
|
|
46
46
|
export interface FleetUpdaterHandle {
|
|
@@ -91,6 +91,11 @@ export function initFleetUpdater(
|
|
|
91
91
|
const snapshotManager = new VolumeSnapshotManager(docker, snapshotDir);
|
|
92
92
|
const strategy = createRolloutStrategy(strategyType, strategyOptions);
|
|
93
93
|
|
|
94
|
+
// Captured by the onUpdateAvailable handler and read by getUpdatableProfiles.
|
|
95
|
+
// Set before each orchestrator.rollout() call so the callback receives the
|
|
96
|
+
// image tag that triggered this rollout rather than a stale "latest" placeholder.
|
|
97
|
+
let currentImageTag = "latest";
|
|
98
|
+
|
|
94
99
|
const orchestrator = new RolloutOrchestrator({
|
|
95
100
|
updater,
|
|
96
101
|
snapshotManager,
|
|
@@ -110,7 +115,7 @@ export function initFleetUpdater(
|
|
|
110
115
|
|
|
111
116
|
if (!configRepo) {
|
|
112
117
|
if (manualPolicyIds.length > 0 && onManualTenantsSkipped) {
|
|
113
|
-
onManualTenantsSkipped([...new Set(manualPolicyIds)]);
|
|
118
|
+
onManualTenantsSkipped([...new Set(manualPolicyIds)], currentImageTag);
|
|
114
119
|
}
|
|
115
120
|
return nonManualPolicy;
|
|
116
121
|
}
|
|
@@ -131,7 +136,7 @@ export function initFleetUpdater(
|
|
|
131
136
|
|
|
132
137
|
const allManualIds = [...manualPolicyIds, ...configManualIds];
|
|
133
138
|
if (allManualIds.length > 0 && onManualTenantsSkipped) {
|
|
134
|
-
onManualTenantsSkipped([...new Set(allManualIds)]);
|
|
139
|
+
onManualTenantsSkipped([...new Set(allManualIds)], currentImageTag);
|
|
135
140
|
}
|
|
136
141
|
|
|
137
142
|
return results.filter((p) => p !== null);
|
|
@@ -169,11 +174,24 @@ export function initFleetUpdater(
|
|
|
169
174
|
// Wire the detection → orchestration pipeline.
|
|
170
175
|
// Any digest change triggers a fleet-wide rollout because the managed image
|
|
171
176
|
// is shared across all tenants — one new digest means all bots need updating.
|
|
172
|
-
poller.onUpdateAvailable = async (
|
|
177
|
+
poller.onUpdateAvailable = async (botId: string, _newDigest: string) => {
|
|
173
178
|
if (orchestrator.isRolling) {
|
|
174
179
|
logger.debug("Skipping update trigger — rollout already in progress");
|
|
175
180
|
return;
|
|
176
181
|
}
|
|
182
|
+
|
|
183
|
+
// Resolve the image tag from the bot that triggered the update so that
|
|
184
|
+
// onManualTenantsSkipped receives the real version instead of "latest".
|
|
185
|
+
try {
|
|
186
|
+
const triggeringProfile = await profileStore.get(botId);
|
|
187
|
+
if (triggeringProfile) {
|
|
188
|
+
const img = triggeringProfile.image;
|
|
189
|
+
currentImageTag = img.includes(":") ? (img.split(":").pop() ?? "latest") : "latest";
|
|
190
|
+
}
|
|
191
|
+
} catch {
|
|
192
|
+
// Best-effort — currentImageTag stays at previous value
|
|
193
|
+
}
|
|
194
|
+
|
|
177
195
|
logger.info("New image digest detected — starting fleet-wide rollout");
|
|
178
196
|
await orchestrator.rollout().catch((err) => {
|
|
179
197
|
logger.error("Rollout failed", { err });
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import type { RolloutOrchestrator } from "../fleet/rollout-orchestrator.js";
|
|
3
|
+
import type { ITenantUpdateConfigRepository, TenantUpdateConfig } from "../fleet/tenant-update-config-repository.js";
|
|
4
|
+
import type { IOrgMemberRepository } from "../tenancy/org-member-repository.js";
|
|
5
|
+
import { createAdminFleetUpdateRouter } from "./admin-fleet-update-router.js";
|
|
6
|
+
import { createCallerFactory, router, setTrpcOrgMemberRepo } from "./init.js";
|
|
7
|
+
|
|
8
|
+
vi.mock("../config/logger.js", () => ({
|
|
9
|
+
logger: {
|
|
10
|
+
info: vi.fn(),
|
|
11
|
+
error: vi.fn(),
|
|
12
|
+
warn: vi.fn(),
|
|
13
|
+
debug: vi.fn(),
|
|
14
|
+
},
|
|
15
|
+
}));
|
|
16
|
+
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
// Helpers
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
function makeMockOrgRepo(): IOrgMemberRepository {
|
|
22
|
+
return {
|
|
23
|
+
listMembers: vi.fn().mockResolvedValue([]),
|
|
24
|
+
addMember: vi.fn().mockResolvedValue(undefined),
|
|
25
|
+
updateMemberRole: vi.fn().mockResolvedValue(undefined),
|
|
26
|
+
removeMember: vi.fn().mockResolvedValue(undefined),
|
|
27
|
+
findMember: vi.fn().mockResolvedValue(null),
|
|
28
|
+
countAdminsAndOwners: vi.fn().mockResolvedValue(0),
|
|
29
|
+
listInvites: vi.fn().mockResolvedValue([]),
|
|
30
|
+
createInvite: vi.fn().mockResolvedValue(undefined),
|
|
31
|
+
findInviteById: vi.fn().mockResolvedValue(null),
|
|
32
|
+
findInviteByToken: vi.fn().mockResolvedValue(null),
|
|
33
|
+
deleteInvite: vi.fn().mockResolvedValue(undefined),
|
|
34
|
+
deleteAllMembers: vi.fn().mockResolvedValue(undefined),
|
|
35
|
+
deleteAllInvites: vi.fn().mockResolvedValue(undefined),
|
|
36
|
+
listOrgsByUser: vi.fn().mockResolvedValue([]),
|
|
37
|
+
markInviteAccepted: vi.fn().mockResolvedValue(undefined),
|
|
38
|
+
} as unknown as IOrgMemberRepository;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function makeOrchestrator(overrides: Partial<RolloutOrchestrator> = {}): RolloutOrchestrator {
|
|
42
|
+
return {
|
|
43
|
+
isRolling: false,
|
|
44
|
+
rollout: vi.fn().mockResolvedValue({
|
|
45
|
+
totalBots: 0,
|
|
46
|
+
succeeded: 0,
|
|
47
|
+
failed: 0,
|
|
48
|
+
skipped: 0,
|
|
49
|
+
aborted: false,
|
|
50
|
+
alreadyRunning: false,
|
|
51
|
+
results: [],
|
|
52
|
+
}),
|
|
53
|
+
...overrides,
|
|
54
|
+
} as unknown as RolloutOrchestrator;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function makeConfigRepo(configs: Record<string, TenantUpdateConfig> = {}): ITenantUpdateConfigRepository {
|
|
58
|
+
return {
|
|
59
|
+
get: vi.fn().mockImplementation((tenantId: string) => Promise.resolve(configs[tenantId] ?? null)),
|
|
60
|
+
upsert: vi.fn().mockResolvedValue(undefined),
|
|
61
|
+
listAutoEnabled: vi.fn().mockResolvedValue(Object.values(configs)),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const adminCtx = {
|
|
66
|
+
user: { id: "admin-1", roles: ["platform_admin"] },
|
|
67
|
+
tenantId: undefined,
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
// ---------------------------------------------------------------------------
|
|
71
|
+
// Tests
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
|
|
74
|
+
describe("createAdminFleetUpdateRouter", () => {
|
|
75
|
+
let orchestrator: RolloutOrchestrator;
|
|
76
|
+
let configRepo: ITenantUpdateConfigRepository;
|
|
77
|
+
|
|
78
|
+
beforeEach(() => {
|
|
79
|
+
setTrpcOrgMemberRepo(makeMockOrgRepo());
|
|
80
|
+
orchestrator = makeOrchestrator();
|
|
81
|
+
configRepo = makeConfigRepo();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
function makeCaller() {
|
|
85
|
+
const fleetRouter = createAdminFleetUpdateRouter(
|
|
86
|
+
() => orchestrator,
|
|
87
|
+
() => configRepo,
|
|
88
|
+
);
|
|
89
|
+
const appRouter = router({ fleet: fleetRouter });
|
|
90
|
+
const createCaller = createCallerFactory(appRouter);
|
|
91
|
+
return createCaller(adminCtx);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
describe("rolloutStatus", () => {
|
|
95
|
+
it("returns isRolling from orchestrator (false)", async () => {
|
|
96
|
+
const caller = makeCaller();
|
|
97
|
+
const status = await caller.fleet.rolloutStatus();
|
|
98
|
+
expect(status).toEqual({ isRolling: false });
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("returns isRolling from orchestrator (true)", async () => {
|
|
102
|
+
orchestrator = makeOrchestrator({ isRolling: true } as Partial<RolloutOrchestrator>);
|
|
103
|
+
const caller = makeCaller();
|
|
104
|
+
const status = await caller.fleet.rolloutStatus();
|
|
105
|
+
expect(status).toEqual({ isRolling: true });
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
describe("forceRollout", () => {
|
|
110
|
+
it("calls orchestrator.rollout()", async () => {
|
|
111
|
+
const caller = makeCaller();
|
|
112
|
+
const result = await caller.fleet.forceRollout();
|
|
113
|
+
|
|
114
|
+
expect(result).toEqual({ triggered: true });
|
|
115
|
+
expect(orchestrator.rollout).toHaveBeenCalledOnce();
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
describe("listTenantConfigs", () => {
|
|
120
|
+
it("delegates to repo.listAutoEnabled()", async () => {
|
|
121
|
+
const configs: TenantUpdateConfig[] = [
|
|
122
|
+
{ tenantId: "t1", mode: "auto", preferredHourUtc: 3, updatedAt: Date.now() },
|
|
123
|
+
{ tenantId: "t2", mode: "auto", preferredHourUtc: 12, updatedAt: Date.now() },
|
|
124
|
+
];
|
|
125
|
+
vi.mocked(configRepo.listAutoEnabled).mockResolvedValue(configs);
|
|
126
|
+
|
|
127
|
+
const caller = makeCaller();
|
|
128
|
+
const result = await caller.fleet.listTenantConfigs();
|
|
129
|
+
|
|
130
|
+
expect(configRepo.listAutoEnabled).toHaveBeenCalledOnce();
|
|
131
|
+
expect(result).toEqual(configs);
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
describe("setTenantConfig", () => {
|
|
136
|
+
it("preserves existing preferredHourUtc when not provided", async () => {
|
|
137
|
+
const existing: TenantUpdateConfig = {
|
|
138
|
+
tenantId: "t1",
|
|
139
|
+
mode: "auto",
|
|
140
|
+
preferredHourUtc: 17,
|
|
141
|
+
updatedAt: Date.now(),
|
|
142
|
+
};
|
|
143
|
+
vi.mocked(configRepo.get).mockResolvedValue(existing);
|
|
144
|
+
|
|
145
|
+
const caller = makeCaller();
|
|
146
|
+
await caller.fleet.setTenantConfig({ tenantId: "t1", mode: "manual" });
|
|
147
|
+
|
|
148
|
+
expect(configRepo.get).toHaveBeenCalledWith("t1");
|
|
149
|
+
expect(configRepo.upsert).toHaveBeenCalledWith("t1", {
|
|
150
|
+
mode: "manual",
|
|
151
|
+
preferredHourUtc: 17, // preserved from existing
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("uses provided preferredHourUtc when given", async () => {
|
|
156
|
+
const existing: TenantUpdateConfig = {
|
|
157
|
+
tenantId: "t1",
|
|
158
|
+
mode: "auto",
|
|
159
|
+
preferredHourUtc: 17,
|
|
160
|
+
updatedAt: Date.now(),
|
|
161
|
+
};
|
|
162
|
+
vi.mocked(configRepo.get).mockResolvedValue(existing);
|
|
163
|
+
|
|
164
|
+
const caller = makeCaller();
|
|
165
|
+
await caller.fleet.setTenantConfig({
|
|
166
|
+
tenantId: "t1",
|
|
167
|
+
mode: "auto",
|
|
168
|
+
preferredHourUtc: 9,
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
expect(configRepo.upsert).toHaveBeenCalledWith("t1", {
|
|
172
|
+
mode: "auto",
|
|
173
|
+
preferredHourUtc: 9,
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("defaults to 3 when no existing config and preferredHourUtc not provided", async () => {
|
|
178
|
+
vi.mocked(configRepo.get).mockResolvedValue(null);
|
|
179
|
+
|
|
180
|
+
const caller = makeCaller();
|
|
181
|
+
await caller.fleet.setTenantConfig({ tenantId: "t-new", mode: "auto" });
|
|
182
|
+
|
|
183
|
+
expect(configRepo.upsert).toHaveBeenCalledWith("t-new", {
|
|
184
|
+
mode: "auto",
|
|
185
|
+
preferredHourUtc: 3, // default
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("rejects non-admin users", async () => {
|
|
191
|
+
const fleetRouter = createAdminFleetUpdateRouter(
|
|
192
|
+
() => orchestrator,
|
|
193
|
+
() => configRepo,
|
|
194
|
+
);
|
|
195
|
+
const appRouter = router({ fleet: fleetRouter });
|
|
196
|
+
const createCaller = createCallerFactory(appRouter);
|
|
197
|
+
const caller = createCaller({ user: { id: "u1", roles: ["user"] }, tenantId: undefined });
|
|
198
|
+
|
|
199
|
+
await expect(caller.fleet.rolloutStatus()).rejects.toThrow("Platform admin role required");
|
|
200
|
+
});
|
|
201
|
+
});
|