WIP! A BB-style forum, on the ATmosphere!
We're still working... we'll be back soon when we have something to show off!
node
typescript
hono
htmx
atproto
1import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2import { Hono } from "hono";
3import { createAdminRoutes } from "../admin.js";
4import { createTestContext, type TestContext } from "../../lib/__tests__/test-context.js";
5import { roles, rolePermissions, memberships, users, backfillProgress, backfillErrors } from "@atbb/db";
6import { BackfillStatus } from "../../lib/backfill-manager.js";
7
8// Mock restoreOAuthSession so tests control auth without real OAuth
9vi.mock("../../lib/session.js", () => ({
10 restoreOAuthSession: vi.fn(),
11}));
12
13// Mock Agent construction so auth middleware doesn't fail
14vi.mock("@atproto/api", () => ({
15 Agent: vi.fn().mockImplementation(() => ({})),
16 AtpAgent: vi.fn().mockImplementation(() => ({
17 com: { atproto: { repo: { listRecords: vi.fn() } } },
18 })),
19}));
20
21import { restoreOAuthSession } from "../../lib/session.js";
22
23const ADMIN_DID = "did:plc:test-admin-backfill";
24const ROLE_RKEY = "admin-backfill-owner-role";
25
26describe("Admin Backfill Routes", () => {
27 let ctx: TestContext;
28 let app: Hono;
29 let mockBackfillManager: any;
30
31 const authHeaders = { Cookie: "atbb_session=test-session" };
32
33 beforeEach(async () => {
34 ctx = await createTestContext();
35
36 mockBackfillManager = {
37 getIsRunning: vi.fn().mockReturnValue(false),
38 checkIfNeeded: vi.fn().mockResolvedValue(BackfillStatus.NotNeeded),
39 prepareBackfillRow: vi.fn().mockResolvedValue(42n),
40 performBackfill: vi.fn().mockResolvedValue({
41 backfillId: 1n,
42 type: BackfillStatus.CatchUp,
43 didsProcessed: 0,
44 recordsIndexed: 0,
45 errors: 0,
46 durationMs: 100,
47 }),
48 checkForInterruptedBackfill: vi.fn().mockResolvedValue(null),
49 };
50
51 // Inject mock backfillManager into context
52 (ctx as any).backfillManager = mockBackfillManager;
53
54 app = new Hono();
55 app.route("/api/admin", createAdminRoutes(ctx));
56
57 // Default: mock auth to return valid session for "test-session"
58 vi.mocked(restoreOAuthSession).mockResolvedValue({
59 oauthSession: {
60 did: ADMIN_DID,
61 serverMetadata: { issuer: "https://pds.example.com" },
62 } as any,
63 cookieSession: {
64 did: ADMIN_DID,
65 handle: "admin.test",
66 expiresAt: new Date(Date.now() + 3600_000),
67 createdAt: new Date(),
68 },
69 });
70 });
71
72 afterEach(async () => {
73 await ctx.cleanup();
74 vi.clearAllMocks();
75 });
76
77 // Helper: insert admin user with manageForum permission in DB
78 async function setupAdminUser() {
79 const [ownerRole] = await ctx.db.insert(roles).values({
80 did: ctx.config.forumDid,
81 rkey: ROLE_RKEY,
82 cid: "test-cid",
83 name: "Owner",
84 description: "Forum owner",
85 priority: 0,
86 createdAt: new Date(),
87 indexedAt: new Date(),
88 }).returning({ id: roles.id });
89 await ctx.db.insert(rolePermissions).values([{ roleId: ownerRole.id, permission: "*" }]);
90
91 await ctx.db.insert(users).values({
92 did: ADMIN_DID,
93 handle: "admin.test",
94 indexedAt: new Date(),
95 });
96
97 await ctx.db.insert(memberships).values({
98 did: ADMIN_DID,
99 rkey: "admin-backfill-membership",
100 cid: "test-cid",
101 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
102 roleUri: `at://${ctx.config.forumDid}/space.atbb.forum.role/${ROLE_RKEY}`,
103 createdAt: new Date(),
104 indexedAt: new Date(),
105 });
106 }
107
108 describe("POST /api/admin/backfill", () => {
109 it("returns 401 without authentication", async () => {
110 const res = await app.request("/api/admin/backfill", { method: "POST" });
111 expect(res.status).toBe(401);
112 });
113
114 it("returns 403 when user lacks manageForum permission", async () => {
115 // Auth but no membership/role in DB → permission check fails
116 await ctx.db.insert(users).values({
117 did: ADMIN_DID,
118 handle: "admin.test",
119 indexedAt: new Date(),
120 });
121
122 const res = await app.request("/api/admin/backfill", {
123 method: "POST",
124 headers: authHeaders,
125 });
126 expect(res.status).toBe(403);
127 });
128
129 it("returns 503 when backfillManager is not available", async () => {
130 await setupAdminUser();
131 (ctx as any).backfillManager = null;
132
133 const res = await app.request("/api/admin/backfill", {
134 method: "POST",
135 headers: authHeaders,
136 });
137 expect(res.status).toBe(503);
138 const data = await res.json();
139 expect(data.error).toContain("not available");
140 });
141
142 it("returns 409 when backfill is already running", async () => {
143 await setupAdminUser();
144 mockBackfillManager.getIsRunning.mockReturnValue(true);
145
146 const res = await app.request("/api/admin/backfill", {
147 method: "POST",
148 headers: authHeaders,
149 });
150 expect(res.status).toBe(409);
151 const data = await res.json();
152 expect(data.error).toContain("already in progress");
153 });
154
155 it("returns 200 with helpful message when backfill is not needed", async () => {
156 await setupAdminUser();
157 mockBackfillManager.checkIfNeeded.mockResolvedValue(BackfillStatus.NotNeeded);
158
159 const res = await app.request("/api/admin/backfill", {
160 method: "POST",
161 headers: authHeaders,
162 });
163 expect(res.status).toBe(200);
164 const data = await res.json();
165 expect(data.message).toContain("No backfill needed");
166 expect(mockBackfillManager.performBackfill).not.toHaveBeenCalled();
167 });
168
169 it("returns 202 and triggers backfill when gap is detected", async () => {
170 await setupAdminUser();
171 mockBackfillManager.checkIfNeeded.mockResolvedValue(BackfillStatus.CatchUp);
172
173 const res = await app.request("/api/admin/backfill", {
174 method: "POST",
175 headers: authHeaders,
176 });
177 expect(res.status).toBe(202);
178 const data = await res.json();
179 expect(data.message).toContain("started");
180 expect(data.type).toBe(BackfillStatus.CatchUp);
181 expect(data.status).toBe("in_progress");
182 expect(data.id).toBe("42"); // returned by prepareBackfillRow mock
183 expect(mockBackfillManager.prepareBackfillRow).toHaveBeenCalledWith(BackfillStatus.CatchUp);
184 expect(mockBackfillManager.performBackfill).toHaveBeenCalledWith(BackfillStatus.CatchUp, 42n);
185 });
186
187 it("forces catch_up backfill when ?force=catch_up is specified", async () => {
188 await setupAdminUser();
189 // Even if checkIfNeeded says NotNeeded, force overrides
190 mockBackfillManager.checkIfNeeded.mockResolvedValue(BackfillStatus.NotNeeded);
191
192 const res = await app.request("/api/admin/backfill?force=catch_up", {
193 method: "POST",
194 headers: authHeaders,
195 });
196 expect(res.status).toBe(202);
197 const data = await res.json();
198 expect(data.id).toBe("42");
199 expect(mockBackfillManager.prepareBackfillRow).toHaveBeenCalledWith(BackfillStatus.CatchUp);
200 expect(mockBackfillManager.performBackfill).toHaveBeenCalledWith(BackfillStatus.CatchUp, 42n);
201 // checkIfNeeded should NOT be called when force is specified
202 expect(mockBackfillManager.checkIfNeeded).not.toHaveBeenCalled();
203 });
204
205 it("forces full_sync backfill when ?force=full_sync is specified", async () => {
206 await setupAdminUser();
207
208 const res = await app.request("/api/admin/backfill?force=full_sync", {
209 method: "POST",
210 headers: authHeaders,
211 });
212 expect(res.status).toBe(202);
213 const data = await res.json();
214 expect(data.id).toBe("42");
215 expect(mockBackfillManager.prepareBackfillRow).toHaveBeenCalledWith(BackfillStatus.FullSync);
216 expect(mockBackfillManager.performBackfill).toHaveBeenCalledWith(BackfillStatus.FullSync, 42n);
217 });
218
219 it("falls through to gap detection when ?force is an unrecognized value", async () => {
220 await setupAdminUser();
221 mockBackfillManager.checkIfNeeded.mockResolvedValue(BackfillStatus.CatchUp);
222
223 const res = await app.request("/api/admin/backfill?force=garbage", {
224 method: "POST",
225 headers: authHeaders,
226 });
227 // Unrecognized force value is ignored — gap detection runs
228 expect(res.status).toBe(202);
229 expect(mockBackfillManager.checkIfNeeded).toHaveBeenCalled();
230 });
231 });
232
233 describe("GET /api/admin/backfill/:id", () => {
234 it("returns 401 without authentication", async () => {
235 const res = await app.request("/api/admin/backfill/1");
236 expect(res.status).toBe(401);
237 });
238
239 it("returns 403 when user lacks manageForum permission", async () => {
240 // Auth works (ADMIN_DID) but no role/membership in DB
241 await ctx.db.insert(users).values({
242 did: ADMIN_DID,
243 handle: "admin.test",
244 indexedAt: new Date(),
245 });
246
247 const res = await app.request("/api/admin/backfill/1", {
248 headers: authHeaders,
249 });
250 expect(res.status).toBe(403);
251 });
252
253 it("returns 400 for non-numeric backfill ID", async () => {
254 await setupAdminUser();
255 const res = await app.request("/api/admin/backfill/notanumber", {
256 headers: authHeaders,
257 });
258 expect(res.status).toBe(400);
259 const data = await res.json();
260 expect(data.error).toContain("Invalid backfill ID");
261 });
262
263 it("returns 400 for decimal backfill ID", async () => {
264 await setupAdminUser();
265 const res = await app.request("/api/admin/backfill/5.9", {
266 headers: authHeaders,
267 });
268 expect(res.status).toBe(400);
269 });
270
271 it("returns 500 when database query fails", async () => {
272 await setupAdminUser();
273 const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
274
275 // requirePermission makes 3 DB selects (membership + role + role_permissions); let them pass,
276 // then fail on the handler's backfill_progress query (call 4).
277 const origSelect = ctx.db.select.bind(ctx.db);
278 vi.spyOn(ctx.db, "select")
279 .mockImplementationOnce(() => origSelect() as any) // permissions: membership
280 .mockImplementationOnce(() => origSelect() as any) // permissions: role
281 .mockImplementationOnce(() => origSelect() as any) // permissions: role_permissions
282 .mockReturnValueOnce({ // handler: backfill_progress
283 from: vi.fn().mockReturnValue({
284 where: vi.fn().mockReturnValue({
285 limit: vi.fn().mockRejectedValue(new Error("DB connection lost")),
286 }),
287 }),
288 } as any);
289
290 const res = await app.request("/api/admin/backfill/123", { headers: authHeaders });
291 expect(res.status).toBe(500);
292
293 consoleSpy.mockRestore();
294 });
295
296 it("returns 404 for unknown backfill ID", async () => {
297 await setupAdminUser();
298 const res = await app.request("/api/admin/backfill/999999", {
299 headers: authHeaders,
300 });
301 expect(res.status).toBe(404);
302 const data = await res.json();
303 expect(data.error).toContain("not found");
304 });
305
306 it("returns progress data for a known backfill ID (completed)", async () => {
307 await setupAdminUser();
308
309 const [row] = await ctx.db
310 .insert(backfillProgress)
311 .values({
312 status: "completed",
313 backfillType: "catch_up",
314 didsTotal: 10,
315 didsProcessed: 10,
316 recordsIndexed: 50,
317 startedAt: new Date("2026-01-01T00:00:00Z"),
318 completedAt: new Date("2026-01-01T00:05:00Z"),
319 })
320 .returning({ id: backfillProgress.id });
321
322 const res = await app.request(`/api/admin/backfill/${row.id.toString()}`, {
323 headers: authHeaders,
324 });
325 expect(res.status).toBe(200);
326 const data = await res.json();
327 expect(data.id).toBe(row.id.toString());
328 expect(data.status).toBe("completed");
329 expect(data.type).toBe("catch_up");
330 expect(data.didsTotal).toBe(10);
331 expect(data.didsProcessed).toBe(10);
332 expect(data.recordsIndexed).toBe(50);
333 expect(data.errorCount).toBe(0);
334 });
335
336 it("returns in_progress status for a running backfill", async () => {
337 await setupAdminUser();
338
339 const [row] = await ctx.db
340 .insert(backfillProgress)
341 .values({
342 status: "in_progress",
343 backfillType: "full_sync",
344 didsTotal: 100,
345 didsProcessed: 30,
346 recordsIndexed: 75,
347 startedAt: new Date("2026-01-01T00:00:00Z"),
348 })
349 .returning({ id: backfillProgress.id });
350
351 const res = await app.request(`/api/admin/backfill/${row.id.toString()}`, {
352 headers: authHeaders,
353 });
354 expect(res.status).toBe(200);
355 const data = await res.json();
356 expect(data.status).toBe("in_progress");
357 expect(data.didsProcessed).toBe(30);
358 expect(data.completedAt).toBeNull();
359 });
360 });
361
362 describe("GET /api/admin/backfill/:id/errors", () => {
363 it("returns 401 without authentication", async () => {
364 const res = await app.request("/api/admin/backfill/1/errors");
365 expect(res.status).toBe(401);
366 });
367
368 it("returns 403 when user lacks manageForum permission", async () => {
369 await ctx.db.insert(users).values({
370 did: ADMIN_DID,
371 handle: "admin.test",
372 indexedAt: new Date(),
373 });
374
375 const res = await app.request("/api/admin/backfill/1/errors", {
376 headers: authHeaders,
377 });
378 expect(res.status).toBe(403);
379 });
380
381 it("returns 400 for non-numeric backfill ID", async () => {
382 await setupAdminUser();
383 const res = await app.request("/api/admin/backfill/notanumber/errors", {
384 headers: authHeaders,
385 });
386 expect(res.status).toBe(400);
387 });
388
389 it("returns 500 when database query fails", async () => {
390 await setupAdminUser();
391 const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
392
393 const origSelect = ctx.db.select.bind(ctx.db);
394 vi.spyOn(ctx.db, "select")
395 .mockImplementationOnce(() => origSelect() as any) // permissions: membership
396 .mockImplementationOnce(() => origSelect() as any) // permissions: role
397 .mockImplementationOnce(() => origSelect() as any) // permissions: role_permissions
398 .mockReturnValueOnce({ // handler: backfill_errors query
399 from: vi.fn().mockReturnValue({
400 where: vi.fn().mockReturnValue({
401 orderBy: vi.fn().mockReturnValue({
402 limit: vi.fn().mockRejectedValue(new Error("DB connection lost")),
403 }),
404 }),
405 }),
406 } as any);
407
408 const res = await app.request("/api/admin/backfill/123/errors", { headers: authHeaders });
409 expect(res.status).toBe(500);
410
411 consoleSpy.mockRestore();
412 });
413
414 it("returns empty errors array when no errors exist", async () => {
415 await setupAdminUser();
416
417 const [row] = await ctx.db
418 .insert(backfillProgress)
419 .values({
420 status: "completed",
421 backfillType: "full_sync",
422 didsTotal: 3,
423 didsProcessed: 3,
424 recordsIndexed: 15,
425 startedAt: new Date("2026-01-01T00:00:00Z"),
426 })
427 .returning({ id: backfillProgress.id });
428
429 const res = await app.request(`/api/admin/backfill/${row.id.toString()}/errors`, {
430 headers: authHeaders,
431 });
432 expect(res.status).toBe(200);
433 const data = await res.json();
434 expect(data.errors).toHaveLength(0);
435 });
436
437 it("returns errors for a backfill with failures", async () => {
438 await setupAdminUser();
439
440 const [row] = await ctx.db
441 .insert(backfillProgress)
442 .values({
443 status: "completed",
444 backfillType: "catch_up",
445 didsTotal: 5,
446 didsProcessed: 5,
447 recordsIndexed: 8,
448 startedAt: new Date("2026-01-01T00:00:00Z"),
449 })
450 .returning({ id: backfillProgress.id });
451
452 await ctx.db.insert(backfillErrors).values({
453 backfillId: row.id,
454 did: "did:plc:failed-user",
455 collection: "space.atbb.post",
456 errorMessage: "PDS connection failed",
457 createdAt: new Date("2026-01-01T00:01:00Z"),
458 });
459
460 const res = await app.request(`/api/admin/backfill/${row.id.toString()}/errors`, {
461 headers: authHeaders,
462 });
463 expect(res.status).toBe(200);
464 const data = await res.json();
465 expect(data.errors).toHaveLength(1);
466 expect(data.errors[0].did).toBe("did:plc:failed-user");
467 expect(data.errors[0].collection).toBe("space.atbb.post");
468 expect(data.errors[0].errorMessage).toBe("PDS connection failed");
469 });
470 });
471});