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
at atb-52-css-token-extraction 2046 lines 73 kB view raw
1import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; 2 3const mockFetch = vi.fn(); 4 5describe("createAdminRoutes — GET /admin", () => { 6 beforeEach(() => { 7 vi.stubGlobal("fetch", mockFetch); 8 vi.stubEnv("APPVIEW_URL", "http://localhost:3000"); 9 vi.resetModules(); 10 }); 11 12 afterEach(() => { 13 vi.unstubAllGlobals(); 14 vi.unstubAllEnvs(); 15 mockFetch.mockReset(); 16 }); 17 18 function mockResponse(body: unknown, ok = true, status = 200) { 19 return { 20 ok, 21 status, 22 statusText: ok ? "OK" : "Error", 23 json: () => Promise.resolve(body), 24 }; 25 } 26 27 /** 28 * Sets up the two-fetch mock sequence for an authenticated session. 29 * Call 1: GET /api/auth/session 30 * Call 2: GET /api/admin/members/me 31 */ 32 function setupAuthenticatedSession(permissions: string[]) { 33 mockFetch.mockResolvedValueOnce( 34 mockResponse({ authenticated: true, did: "did:plc:user", handle: "alice.bsky.social" }) 35 ); 36 mockFetch.mockResolvedValueOnce(mockResponse({ permissions })); 37 } 38 39 async function loadAdminRoutes() { 40 const { createAdminRoutes } = await import("../admin.js"); 41 return createAdminRoutes("http://localhost:3000"); 42 } 43 44 // ── Unauthenticated ───────────────────────────────────────────────────── 45 46 it("redirects unauthenticated users to /login", async () => { 47 // No atbb_session cookie → zero fetch calls 48 const routes = await loadAdminRoutes(); 49 const res = await routes.request("/admin"); 50 expect(res.status).toBe(302); 51 expect(res.headers.get("location")).toBe("/login"); 52 }); 53 54 // ── No admin permissions → 403 ────────────────────────────────────────── 55 56 it("returns 403 for authenticated user with no permissions", async () => { 57 setupAuthenticatedSession([]); 58 const routes = await loadAdminRoutes(); 59 const res = await routes.request("/admin", { 60 headers: { cookie: "atbb_session=token" }, 61 }); 62 expect(res.status).toBe(403); 63 const html = await res.text(); 64 expect(html).toContain("Access Denied"); 65 }); 66 67 it("returns 403 for authenticated user with only an unrelated permission", async () => { 68 setupAuthenticatedSession(["space.atbb.permission.someOtherThing"]); 69 const routes = await loadAdminRoutes(); 70 const res = await routes.request("/admin", { 71 headers: { cookie: "atbb_session=token" }, 72 }); 73 expect(res.status).toBe(403); 74 }); 75 76 // ── Wildcard → all cards ───────────────────────────────────────────────── 77 78 it("grants access and shows all cards for wildcard (*) permission", async () => { 79 setupAuthenticatedSession(["*"]); 80 const routes = await loadAdminRoutes(); 81 const res = await routes.request("/admin", { 82 headers: { cookie: "atbb_session=token" }, 83 }); 84 expect(res.status).toBe(200); 85 const html = await res.text(); 86 expect(html).toContain('href="/admin/members"'); 87 expect(html).toContain('href="/admin/structure"'); 88 expect(html).toContain('href="/admin/modlog"'); 89 }); 90 91 // ── Single permission → only that card ────────────────────────────────── 92 93 it("shows only Members card for user with only manageMembers", async () => { 94 setupAuthenticatedSession(["space.atbb.permission.manageMembers"]); 95 const routes = await loadAdminRoutes(); 96 const res = await routes.request("/admin", { 97 headers: { cookie: "atbb_session=token" }, 98 }); 99 expect(res.status).toBe(200); 100 const html = await res.text(); 101 expect(html).toContain('href="/admin/members"'); 102 expect(html).not.toContain('href="/admin/structure"'); 103 expect(html).not.toContain('href="/admin/modlog"'); 104 }); 105 106 it("shows only Structure card for user with only manageCategories", async () => { 107 setupAuthenticatedSession(["space.atbb.permission.manageCategories"]); 108 const routes = await loadAdminRoutes(); 109 const res = await routes.request("/admin", { 110 headers: { cookie: "atbb_session=token" }, 111 }); 112 expect(res.status).toBe(200); 113 const html = await res.text(); 114 expect(html).not.toContain('href="/admin/members"'); 115 expect(html).toContain('href="/admin/structure"'); 116 expect(html).not.toContain('href="/admin/modlog"'); 117 }); 118 119 it("shows only Mod Log card for user with only moderatePosts", async () => { 120 setupAuthenticatedSession(["space.atbb.permission.moderatePosts"]); 121 const routes = await loadAdminRoutes(); 122 const res = await routes.request("/admin", { 123 headers: { cookie: "atbb_session=token" }, 124 }); 125 expect(res.status).toBe(200); 126 const html = await res.text(); 127 expect(html).not.toContain('href="/admin/members"'); 128 expect(html).not.toContain('href="/admin/structure"'); 129 expect(html).toContain('href="/admin/modlog"'); 130 }); 131 132 it("shows only Mod Log card for user with only banUsers", async () => { 133 setupAuthenticatedSession(["space.atbb.permission.banUsers"]); 134 const routes = await loadAdminRoutes(); 135 const res = await routes.request("/admin", { 136 headers: { cookie: "atbb_session=token" }, 137 }); 138 expect(res.status).toBe(200); 139 const html = await res.text(); 140 expect(html).not.toContain('href="/admin/members"'); 141 expect(html).not.toContain('href="/admin/structure"'); 142 expect(html).toContain('href="/admin/modlog"'); 143 }); 144 145 it("shows only Mod Log card for user with only lockTopics", async () => { 146 setupAuthenticatedSession(["space.atbb.permission.lockTopics"]); 147 const routes = await loadAdminRoutes(); 148 const res = await routes.request("/admin", { 149 headers: { cookie: "atbb_session=token" }, 150 }); 151 expect(res.status).toBe(200); 152 const html = await res.text(); 153 expect(html).not.toContain('href="/admin/members"'); 154 expect(html).not.toContain('href="/admin/structure"'); 155 expect(html).toContain('href="/admin/modlog"'); 156 }); 157 158 // ── Multi-permission combos ────────────────────────────────────────────── 159 160 it("shows Members and Mod Log cards for manageMembers + moderatePosts", async () => { 161 setupAuthenticatedSession([ 162 "space.atbb.permission.manageMembers", 163 "space.atbb.permission.moderatePosts", 164 ]); 165 const routes = await loadAdminRoutes(); 166 const res = await routes.request("/admin", { 167 headers: { cookie: "atbb_session=token" }, 168 }); 169 expect(res.status).toBe(200); 170 const html = await res.text(); 171 expect(html).toContain('href="/admin/members"'); 172 expect(html).not.toContain('href="/admin/structure"'); 173 expect(html).toContain('href="/admin/modlog"'); 174 }); 175 176 // ── Page structure ─────────────────────────────────────────────────────── 177 178 it("renders 'Admin Panel' page title", async () => { 179 setupAuthenticatedSession(["space.atbb.permission.manageMembers"]); 180 const routes = await loadAdminRoutes(); 181 const res = await routes.request("/admin", { 182 headers: { cookie: "atbb_session=token" }, 183 }); 184 const html = await res.text(); 185 expect(html).toContain("Admin Panel"); 186 }); 187 188 it("renders admin-nav-grid container", async () => { 189 setupAuthenticatedSession(["space.atbb.permission.manageMembers"]); 190 const routes = await loadAdminRoutes(); 191 const res = await routes.request("/admin", { 192 headers: { cookie: "atbb_session=token" }, 193 }); 194 const html = await res.text(); 195 expect(html).toContain("admin-nav-grid"); 196 }); 197}); 198 199describe("createAdminRoutes — GET /admin/members", () => { 200 beforeEach(() => { 201 vi.stubGlobal("fetch", mockFetch); 202 vi.stubEnv("APPVIEW_URL", "http://localhost:3000"); 203 vi.resetModules(); 204 }); 205 206 afterEach(() => { 207 vi.unstubAllGlobals(); 208 vi.unstubAllEnvs(); 209 mockFetch.mockReset(); 210 }); 211 212 function mockResponse(body: unknown, ok = true, status = 200) { 213 return { 214 ok, 215 status, 216 statusText: ok ? "OK" : "Error", 217 json: () => Promise.resolve(body), 218 }; 219 } 220 221 function setupSession(permissions: string[]) { 222 mockFetch.mockResolvedValueOnce( 223 mockResponse({ authenticated: true, did: "did:plc:admin", handle: "admin.bsky.social" }) 224 ); 225 mockFetch.mockResolvedValueOnce(mockResponse({ permissions })); 226 } 227 228 const SAMPLE_MEMBERS = [ 229 { 230 did: "did:plc:alice", 231 handle: "alice.bsky.social", 232 role: "Owner", 233 roleUri: "at://did:plc:forum/space.atbb.forum.role/owner", 234 joinedAt: "2026-01-01T00:00:00.000Z", 235 }, 236 { 237 did: "did:plc:bob", 238 handle: "bob.bsky.social", 239 role: "Member", 240 roleUri: "at://did:plc:forum/space.atbb.forum.role/member", 241 joinedAt: "2026-01-05T00:00:00.000Z", 242 }, 243 ]; 244 245 const SAMPLE_ROLES = [ 246 { id: "1", name: "Owner", uri: "at://did:plc:forum/space.atbb.forum.role/owner", priority: 0, permissions: ["*"] }, 247 { id: "2", name: "Member", uri: "at://did:plc:forum/space.atbb.forum.role/member", priority: 30, permissions: [] }, 248 ]; 249 250 async function loadAdminRoutes() { 251 const { createAdminRoutes } = await import("../admin.js"); 252 return createAdminRoutes("http://localhost:3000"); 253 } 254 255 it("redirects unauthenticated users to /login", async () => { 256 const routes = await loadAdminRoutes(); 257 const res = await routes.request("/admin/members"); 258 expect(res.status).toBe(302); 259 expect(res.headers.get("location")).toBe("/login"); 260 }); 261 262 it("returns 403 for authenticated user without manageMembers", async () => { 263 setupSession(["space.atbb.permission.manageCategories"]); 264 const routes = await loadAdminRoutes(); 265 const res = await routes.request("/admin/members", { 266 headers: { cookie: "atbb_session=token" }, 267 }); 268 expect(res.status).toBe(403); 269 }); 270 271 it("renders member table with handles and role badges", async () => { 272 setupSession(["space.atbb.permission.manageMembers"]); 273 mockFetch.mockResolvedValueOnce( 274 mockResponse({ members: SAMPLE_MEMBERS, isTruncated: false }) 275 ); 276 277 const routes = await loadAdminRoutes(); 278 const res = await routes.request("/admin/members", { 279 headers: { cookie: "atbb_session=token" }, 280 }); 281 282 expect(res.status).toBe(200); 283 const html = await res.text(); 284 expect(html).toContain("alice.bsky.social"); 285 expect(html).toContain("bob.bsky.social"); 286 expect(html).toContain("role-badge"); 287 expect(html).toContain("Owner"); 288 }); 289 290 it("renders joined date for members", async () => { 291 setupSession(["space.atbb.permission.manageMembers"]); 292 mockFetch.mockResolvedValueOnce( 293 mockResponse({ members: SAMPLE_MEMBERS, isTruncated: false }) 294 ); 295 296 const routes = await loadAdminRoutes(); 297 const res = await routes.request("/admin/members", { 298 headers: { cookie: "atbb_session=token" }, 299 }); 300 301 const html = await res.text(); 302 expect(html).toContain("Jan"); 303 expect(html).toContain("2026"); 304 }); 305 306 it("hides role assignment form when user lacks manageRoles", async () => { 307 setupSession(["space.atbb.permission.manageMembers"]); 308 mockFetch.mockResolvedValueOnce( 309 mockResponse({ members: SAMPLE_MEMBERS, isTruncated: false }) 310 ); 311 312 const routes = await loadAdminRoutes(); 313 const res = await routes.request("/admin/members", { 314 headers: { cookie: "atbb_session=token" }, 315 }); 316 317 const html = await res.text(); 318 expect(html).not.toContain("hx-post"); 319 expect(html).not.toContain("Assign"); 320 }); 321 322 it("shows role assignment form when user has manageRoles", async () => { 323 setupSession([ 324 "space.atbb.permission.manageMembers", 325 "space.atbb.permission.manageRoles", 326 ]); 327 mockFetch.mockResolvedValueOnce( 328 mockResponse({ members: SAMPLE_MEMBERS, isTruncated: false }) 329 ); 330 mockFetch.mockResolvedValueOnce(mockResponse({ roles: SAMPLE_ROLES })); 331 332 const routes = await loadAdminRoutes(); 333 const res = await routes.request("/admin/members", { 334 headers: { cookie: "atbb_session=token" }, 335 }); 336 337 const html = await res.text(); 338 expect(html).toContain("hx-post"); 339 expect(html).toContain("/admin/members/did:plc:bob/role"); 340 expect(html).toContain("Assign"); 341 }); 342 343 it("shows empty state when no members", async () => { 344 setupSession(["space.atbb.permission.manageMembers"]); 345 mockFetch.mockResolvedValueOnce( 346 mockResponse({ members: [], isTruncated: false }) 347 ); 348 349 const routes = await loadAdminRoutes(); 350 const res = await routes.request("/admin/members", { 351 headers: { cookie: "atbb_session=token" }, 352 }); 353 354 const html = await res.text(); 355 expect(html).toContain("No members"); 356 }); 357 358 it("shows truncated indicator when isTruncated is true", async () => { 359 setupSession(["space.atbb.permission.manageMembers"]); 360 mockFetch.mockResolvedValueOnce( 361 mockResponse({ members: SAMPLE_MEMBERS, isTruncated: true }) 362 ); 363 364 const routes = await loadAdminRoutes(); 365 const res = await routes.request("/admin/members", { 366 headers: { cookie: "atbb_session=token" }, 367 }); 368 369 const html = await res.text(); 370 expect(html).toContain("+"); 371 }); 372 373 it("returns 503 on AppView network error fetching members", async () => { 374 setupSession(["space.atbb.permission.manageMembers"]); 375 mockFetch.mockRejectedValueOnce(new Error("fetch failed")); 376 377 const routes = await loadAdminRoutes(); 378 const res = await routes.request("/admin/members", { 379 headers: { cookie: "atbb_session=token" }, 380 }); 381 382 expect(res.status).toBe(503); 383 const html = await res.text(); 384 expect(html).toContain("error-display"); 385 }); 386 387 it("returns 500 on AppView server error fetching members", async () => { 388 setupSession(["space.atbb.permission.manageMembers"]); 389 mockFetch.mockResolvedValueOnce(mockResponse({}, false, 500)); 390 391 const routes = await loadAdminRoutes(); 392 const res = await routes.request("/admin/members", { 393 headers: { cookie: "atbb_session=token" }, 394 }); 395 396 expect(res.status).toBe(500); 397 const html = await res.text(); 398 expect(html).toContain("error-display"); 399 }); 400 401 it("redirects to /login when AppView members returns 401 (session expired)", async () => { 402 setupSession(["space.atbb.permission.manageMembers"]); 403 mockFetch.mockResolvedValueOnce(mockResponse({}, false, 401)); 404 405 const routes = await loadAdminRoutes(); 406 const res = await routes.request("/admin/members", { 407 headers: { cookie: "atbb_session=token" }, 408 }); 409 410 expect(res.status).toBe(302); 411 expect(res.headers.get("location")).toBe("/login"); 412 }); 413 414 it("renders page with empty role dropdown when roles fetch fails", async () => { 415 setupSession([ 416 "space.atbb.permission.manageMembers", 417 "space.atbb.permission.manageRoles", 418 ]); 419 // members fetch succeeds 420 mockFetch.mockResolvedValueOnce( 421 mockResponse({ members: SAMPLE_MEMBERS, isTruncated: false }) 422 ); 423 // roles fetch fails 424 mockFetch.mockResolvedValueOnce(mockResponse({}, false, 500)); 425 426 const routes = await loadAdminRoutes(); 427 const res = await routes.request("/admin/members", { 428 headers: { cookie: "atbb_session=token" }, 429 }); 430 431 expect(res.status).toBe(200); 432 const html = await res.text(); 433 // Page still renders with member data 434 expect(html).toContain("alice.bsky.social"); 435 // Assign Role column still present (permission says yes, just no options) 436 expect(html).toContain("hx-post"); 437 }); 438}); 439 440describe("createAdminRoutes — POST /admin/members/:did/role", () => { 441 beforeEach(() => { 442 vi.stubGlobal("fetch", mockFetch); 443 vi.stubEnv("APPVIEW_URL", "http://localhost:3000"); 444 vi.resetModules(); 445 }); 446 447 afterEach(() => { 448 vi.unstubAllGlobals(); 449 vi.unstubAllEnvs(); 450 mockFetch.mockReset(); 451 }); 452 453 function mockResponse(body: unknown, ok = true, status = 200) { 454 return { 455 ok, 456 status, 457 statusText: ok ? "OK" : "Error", 458 json: () => Promise.resolve(body), 459 }; 460 } 461 462 const SAMPLE_ROLES = [ 463 { id: "1", name: "Owner", uri: "at://did:plc:forum/space.atbb.forum.role/owner", priority: 0, permissions: ["*"] }, 464 { id: "2", name: "Member", uri: "at://did:plc:forum/space.atbb.forum.role/member", priority: 30, permissions: [] }, 465 ]; 466 467 function makeFormBody(overrides: Partial<Record<string, string>> = {}): string { 468 return new URLSearchParams({ 469 roleUri: "at://did:plc:forum/space.atbb.forum.role/member", 470 handle: "bob.bsky.social", 471 joinedAt: "2026-01-05T00:00:00.000Z", 472 currentRole: "Owner", 473 currentRoleUri: "at://did:plc:forum/space.atbb.forum.role/owner", 474 canManageRoles: "1", 475 rolesJson: JSON.stringify(SAMPLE_ROLES), 476 ...overrides, 477 }).toString(); 478 } 479 480 async function loadAdminRoutes() { 481 const { createAdminRoutes } = await import("../admin.js"); 482 return createAdminRoutes("http://localhost:3000"); 483 } 484 485 function setupPostSession(permissions: string[] = ["space.atbb.permission.manageRoles"]) { 486 mockFetch.mockResolvedValueOnce( 487 mockResponse({ authenticated: true, did: "did:plc:admin", handle: "admin.bsky.social" }) 488 ); 489 mockFetch.mockResolvedValueOnce(mockResponse({ permissions })); 490 } 491 492 it("returns updated <tr> with new role name on success", async () => { 493 setupPostSession(); 494 mockFetch.mockResolvedValueOnce( 495 mockResponse({ success: true, roleAssigned: "Member", targetDid: "did:plc:bob" }) 496 ); 497 498 const routes = await loadAdminRoutes(); 499 const res = await routes.request("/admin/members/did:plc:bob/role", { 500 method: "POST", 501 headers: { 502 "Content-Type": "application/x-www-form-urlencoded", 503 cookie: "atbb_session=token", 504 }, 505 body: makeFormBody(), 506 }); 507 508 expect(res.status).toBe(200); 509 const html = await res.text(); 510 expect(html).toContain("<tr"); 511 expect(html).toContain("Member"); 512 expect(html).toContain("bob.bsky.social"); 513 }); 514 515 it("returns row with friendly error on AppView 403", async () => { 516 setupPostSession(); 517 mockFetch.mockResolvedValueOnce(mockResponse({}, false, 403)); 518 519 const routes = await loadAdminRoutes(); 520 const res = await routes.request("/admin/members/did:plc:bob/role", { 521 method: "POST", 522 headers: { 523 "Content-Type": "application/x-www-form-urlencoded", 524 cookie: "atbb_session=token", 525 }, 526 body: makeFormBody(), 527 }); 528 529 expect(res.status).toBe(200); 530 const html = await res.text(); 531 expect(html).toContain("member-row__error"); 532 expect(html).toContain("equal or higher authority"); 533 expect(html).toContain("Owner"); // preserves current role 534 }); 535 536 it("returns row with friendly error on AppView 404", async () => { 537 setupPostSession(); 538 mockFetch.mockResolvedValueOnce(mockResponse({}, false, 404)); 539 540 const routes = await loadAdminRoutes(); 541 const res = await routes.request("/admin/members/did:plc:bob/role", { 542 method: "POST", 543 headers: { 544 "Content-Type": "application/x-www-form-urlencoded", 545 cookie: "atbb_session=token", 546 }, 547 body: makeFormBody(), 548 }); 549 550 expect(res.status).toBe(200); 551 const html = await res.text(); 552 expect(html).toContain("member-row__error"); 553 expect(html).toContain("not found"); 554 }); 555 556 it("returns row with friendly error on AppView 500", async () => { 557 setupPostSession(); 558 mockFetch.mockResolvedValueOnce(mockResponse({}, false, 500)); 559 560 const routes = await loadAdminRoutes(); 561 const res = await routes.request("/admin/members/did:plc:bob/role", { 562 method: "POST", 563 headers: { 564 "Content-Type": "application/x-www-form-urlencoded", 565 cookie: "atbb_session=token", 566 }, 567 body: makeFormBody(), 568 }); 569 570 expect(res.status).toBe(200); 571 const html = await res.text(); 572 expect(html).toContain("member-row__error"); 573 expect(html).toContain("Something went wrong"); 574 }); 575 576 it("returns row with unavailable message on network error", async () => { 577 setupPostSession(); 578 mockFetch.mockRejectedValueOnce(new Error("fetch failed")); 579 580 const routes = await loadAdminRoutes(); 581 const res = await routes.request("/admin/members/did:plc:bob/role", { 582 method: "POST", 583 headers: { 584 "Content-Type": "application/x-www-form-urlencoded", 585 cookie: "atbb_session=token", 586 }, 587 body: makeFormBody(), 588 }); 589 590 expect(res.status).toBe(200); 591 const html = await res.text(); 592 expect(html).toContain("member-row__error"); 593 expect(html).toContain("temporarily unavailable"); 594 }); 595 596 it("returns row with error and makes no AppView call when roleUri is missing", async () => { 597 setupPostSession(); 598 const routes = await loadAdminRoutes(); 599 const res = await routes.request("/admin/members/did:plc:bob/role", { 600 method: "POST", 601 headers: { 602 "Content-Type": "application/x-www-form-urlencoded", 603 cookie: "atbb_session=token", 604 }, 605 body: makeFormBody({ roleUri: "" }), 606 }); 607 608 expect(res.status).toBe(200); 609 const html = await res.text(); 610 expect(html).toContain("member-row__error"); 611 expect(mockFetch).not.toHaveBeenCalledWith( 612 expect.stringContaining("/api/admin/members/did:plc:bob/role"), 613 expect.anything() 614 ); 615 }); 616 617 it("re-renders form with new role pre-selected in dropdown on success", async () => { 618 setupPostSession(); 619 mockFetch.mockResolvedValueOnce( 620 mockResponse({ success: true, roleAssigned: "Member", targetDid: "did:plc:bob" }) 621 ); 622 623 const routes = await loadAdminRoutes(); 624 const res = await routes.request("/admin/members/did:plc:bob/role", { 625 method: "POST", 626 headers: { 627 "Content-Type": "application/x-www-form-urlencoded", 628 cookie: "atbb_session=token", 629 }, 630 body: makeFormBody({ 631 roleUri: "at://did:plc:forum/space.atbb.forum.role/member", 632 }), 633 }); 634 635 const html = await res.text(); 636 // The newly assigned role URI should appear as the selected option value in the form 637 expect(html).toContain("at://did:plc:forum/space.atbb.forum.role/member"); 638 }); 639 640 it("returns 401 error row for unauthenticated POST", async () => { 641 // No session mock — no cookie 642 const routes = await loadAdminRoutes(); 643 const res = await routes.request("/admin/members/did:plc:bob/role", { 644 method: "POST", 645 headers: { "Content-Type": "application/x-www-form-urlencoded" }, 646 body: makeFormBody(), 647 }); 648 649 expect(res.status).toBe(401); 650 const html = await res.text(); 651 expect(html).toContain("member-row__error"); 652 expect(mockFetch).not.toHaveBeenCalledWith( 653 expect.stringContaining("/api/admin/members/did:plc:bob/role"), 654 expect.anything() 655 ); 656 }); 657 658 it("returns 403 error row when user lacks manageRoles", async () => { 659 setupPostSession(["space.atbb.permission.manageMembers"]); // has manageMembers but NOT manageRoles 660 const routes = await loadAdminRoutes(); 661 const res = await routes.request("/admin/members/did:plc:bob/role", { 662 method: "POST", 663 headers: { 664 "Content-Type": "application/x-www-form-urlencoded", 665 cookie: "atbb_session=token", 666 }, 667 body: makeFormBody(), 668 }); 669 670 expect(res.status).toBe(403); 671 const html = await res.text(); 672 expect(html).toContain("member-row__error"); 673 // No AppView role assignment call should have been made 674 expect(mockFetch).not.toHaveBeenCalledWith( 675 expect.stringContaining("/api/admin/members/did:plc:bob/role"), 676 expect.anything() 677 ); 678 }); 679 680 it("returns row with session-expired error when AppView returns 401", async () => { 681 setupPostSession(); 682 mockFetch.mockResolvedValueOnce(mockResponse({}, false, 401)); 683 684 const routes = await loadAdminRoutes(); 685 const res = await routes.request("/admin/members/did:plc:bob/role", { 686 method: "POST", 687 headers: { 688 "Content-Type": "application/x-www-form-urlencoded", 689 cookie: "atbb_session=token", 690 }, 691 body: makeFormBody(), 692 }); 693 694 expect(res.status).toBe(200); 695 const html = await res.text(); 696 expect(html).toContain("member-row__error"); 697 expect(html).toContain("session has expired"); 698 }); 699 700 it("returns error row with reload message when rolesJson is malformed", async () => { 701 setupPostSession(); 702 703 const routes = await loadAdminRoutes(); 704 const res = await routes.request("/admin/members/did:plc:bob/role", { 705 method: "POST", 706 headers: { 707 "Content-Type": "application/x-www-form-urlencoded", 708 cookie: "atbb_session=token", 709 }, 710 body: makeFormBody({ rolesJson: "not-valid-json{{" }), 711 }); 712 713 expect(res.status).toBe(200); 714 const html = await res.text(); 715 expect(html).toContain("member-row__error"); 716 expect(html).toContain("reload"); 717 // No AppView call should have been made 718 // (setupPostSession consumed 2 calls, then we check no more were made) 719 expect(mockFetch).toHaveBeenCalledTimes(2); 720 }); 721 722 it("returns error row and makes no AppView call when targetDid lacks did: prefix", async () => { 723 setupPostSession(); 724 725 const routes = await loadAdminRoutes(); 726 const res = await routes.request("/admin/members/notadid/role", { 727 method: "POST", 728 headers: { 729 "Content-Type": "application/x-www-form-urlencoded", 730 cookie: "atbb_session=token", 731 }, 732 body: makeFormBody({ handle: "bob.bsky.social" }), 733 }); 734 735 expect(res.status).toBe(200); 736 const html = await res.text(); 737 expect(html).toContain("member-row__error"); 738 expect(html).toContain("Invalid member identifier"); 739 // Session fetch calls consumed (2), but no AppView role call made 740 expect(mockFetch).not.toHaveBeenCalledWith( 741 expect.stringContaining("/api/admin/members/notadid/role"), 742 expect.anything() 743 ); 744 }); 745}); 746 747describe("createAdminRoutes — GET /admin/structure", () => { 748 beforeEach(() => { 749 vi.stubGlobal("fetch", mockFetch); 750 vi.stubEnv("APPVIEW_URL", "http://localhost:3000"); 751 vi.resetModules(); 752 }); 753 754 afterEach(() => { 755 vi.unstubAllGlobals(); 756 vi.unstubAllEnvs(); 757 mockFetch.mockReset(); 758 }); 759 760 function mockResponse(body: unknown, ok = true, status = 200) { 761 return { 762 ok, 763 status, 764 statusText: ok ? "OK" : "Error", 765 json: () => Promise.resolve(body), 766 }; 767 } 768 769 function setupSession(permissions: string[]) { 770 mockFetch.mockResolvedValueOnce( 771 mockResponse({ authenticated: true, did: "did:plc:admin", handle: "admin.bsky.social" }) 772 ); 773 mockFetch.mockResolvedValueOnce(mockResponse({ permissions })); 774 } 775 776 /** 777 * Sets up mock responses for the structure page data fetches. 778 * After the 2 session calls: 779 * Call 3: GET /api/categories 780 * Call 4+: GET /api/categories/:id/boards (one per category, parallel) 781 */ 782 function setupStructureFetch( 783 cats: Array<{ id: string; name: string; uri: string; sortOrder?: number }>, 784 boardsByCategory: Record<string, Array<{ id: string; name: string }>> = {} 785 ) { 786 mockFetch.mockResolvedValueOnce( 787 mockResponse({ 788 categories: cats.map((c) => ({ 789 id: c.id, 790 did: "did:plc:forum", 791 uri: c.uri, 792 name: c.name, 793 description: null, 794 slug: null, 795 sortOrder: c.sortOrder ?? 1, 796 forumId: "1", 797 createdAt: "2025-01-01T00:00:00.000Z", 798 indexedAt: "2025-01-01T00:00:00.000Z", 799 })), 800 }) 801 ); 802 for (const cat of cats) { 803 const boards = boardsByCategory[cat.id] ?? []; 804 mockFetch.mockResolvedValueOnce( 805 mockResponse({ 806 boards: boards.map((b) => ({ 807 id: b.id, 808 did: "did:plc:forum", 809 uri: `at://did:plc:forum/space.atbb.forum.board/${b.id}`, 810 name: b.name, 811 description: null, 812 slug: null, 813 sortOrder: 1, 814 categoryId: cat.id, 815 categoryUri: cat.uri, 816 createdAt: "2025-01-01T00:00:00.000Z", 817 indexedAt: "2025-01-01T00:00:00.000Z", 818 })), 819 }) 820 ); 821 } 822 } 823 824 async function loadAdminRoutes() { 825 const { createAdminRoutes } = await import("../admin.js"); 826 return createAdminRoutes("http://localhost:3000"); 827 } 828 829 it("redirects unauthenticated users to /login", async () => { 830 mockFetch.mockResolvedValueOnce( 831 mockResponse({ authenticated: false }) 832 ); 833 const routes = await loadAdminRoutes(); 834 const res = await routes.request("/admin/structure"); 835 expect(res.status).toBe(302); 836 expect(res.headers.get("location")).toBe("/login"); 837 }); 838 839 it("returns 403 for authenticated user without manageCategories", async () => { 840 setupSession(["space.atbb.permission.manageMembers"]); 841 const routes = await loadAdminRoutes(); 842 const res = await routes.request("/admin/structure", { 843 headers: { cookie: "atbb_session=token" }, 844 }); 845 expect(res.status).toBe(403); 846 }); 847 848 it("renders structure page with category and board names", async () => { 849 setupSession(["space.atbb.permission.manageCategories"]); 850 setupStructureFetch( 851 [{ id: "1", name: "General Discussion", uri: "at://did:plc:forum/space.atbb.forum.category/abc" }], 852 { "1": [{ id: "10", name: "General Chat" }] } 853 ); 854 855 const routes = await loadAdminRoutes(); 856 const res = await routes.request("/admin/structure", { 857 headers: { cookie: "atbb_session=token" }, 858 }); 859 860 expect(res.status).toBe(200); 861 const html = await res.text(); 862 expect(html).toContain("General Discussion"); 863 expect(html).toContain("General Chat"); 864 }); 865 866 it("renders empty state when no categories exist", async () => { 867 setupSession(["space.atbb.permission.manageCategories"]); 868 setupStructureFetch([]); 869 870 const routes = await loadAdminRoutes(); 871 const res = await routes.request("/admin/structure", { 872 headers: { cookie: "atbb_session=token" }, 873 }); 874 875 expect(res.status).toBe(200); 876 const html = await res.text(); 877 expect(html).toContain("No categories"); 878 }); 879 880 it("renders the add-category form", async () => { 881 setupSession(["space.atbb.permission.manageCategories"]); 882 setupStructureFetch([]); 883 884 const routes = await loadAdminRoutes(); 885 const res = await routes.request("/admin/structure", { 886 headers: { cookie: "atbb_session=token" }, 887 }); 888 889 const html = await res.text(); 890 expect(html).toContain('action="/admin/structure/categories"'); 891 }); 892 893 it("renders edit and delete actions for a category", async () => { 894 setupSession(["space.atbb.permission.manageCategories"]); 895 setupStructureFetch( 896 [{ id: "5", name: "Projects", uri: "at://did:plc:forum/space.atbb.forum.category/xyz" }], 897 ); 898 899 const routes = await loadAdminRoutes(); 900 const res = await routes.request("/admin/structure", { 901 headers: { cookie: "atbb_session=token" }, 902 }); 903 904 const html = await res.text(); 905 expect(html).toContain('action="/admin/structure/categories/5/edit"'); 906 expect(html).toContain('action="/admin/structure/categories/5/delete"'); 907 }); 908 909 it("renders edit and delete actions for a board", async () => { 910 setupSession(["space.atbb.permission.manageCategories"]); 911 setupStructureFetch( 912 [{ id: "1", name: "General", uri: "at://did:plc:forum/space.atbb.forum.category/abc" }], 913 { "1": [{ id: "20", name: "Showcase" }] } 914 ); 915 916 const routes = await loadAdminRoutes(); 917 const res = await routes.request("/admin/structure", { 918 headers: { cookie: "atbb_session=token" }, 919 }); 920 921 const html = await res.text(); 922 expect(html).toContain("Showcase"); 923 expect(html).toContain('action="/admin/structure/boards/20/edit"'); 924 expect(html).toContain('action="/admin/structure/boards/20/delete"'); 925 }); 926 927 it("renders add-board form with categoryUri hidden input", async () => { 928 setupSession(["space.atbb.permission.manageCategories"]); 929 setupStructureFetch( 930 [{ id: "1", name: "General", uri: "at://did:plc:forum/space.atbb.forum.category/abc" }], 931 ); 932 933 const routes = await loadAdminRoutes(); 934 const res = await routes.request("/admin/structure", { 935 headers: { cookie: "atbb_session=token" }, 936 }); 937 938 const html = await res.text(); 939 expect(html).toContain('name="categoryUri"'); 940 expect(html).toContain('value="at://did:plc:forum/space.atbb.forum.category/abc"'); 941 expect(html).toContain('action="/admin/structure/boards"'); 942 }); 943 944 it("renders error banner when ?error= query param is present", async () => { 945 setupSession(["space.atbb.permission.manageCategories"]); 946 setupStructureFetch([]); 947 948 const routes = await loadAdminRoutes(); 949 const res = await routes.request( 950 `/admin/structure?error=${encodeURIComponent("Cannot delete category with boards. Remove all boards first.")}`, 951 { headers: { cookie: "atbb_session=token" } } 952 ); 953 954 const html = await res.text(); 955 expect(html).toContain("Cannot delete category with boards"); 956 }); 957 958 it("returns 503 on AppView network error fetching categories", async () => { 959 setupSession(["space.atbb.permission.manageCategories"]); 960 mockFetch.mockRejectedValueOnce(new Error("fetch failed")); 961 962 const routes = await loadAdminRoutes(); 963 const res = await routes.request("/admin/structure", { 964 headers: { cookie: "atbb_session=token" }, 965 }); 966 967 expect(res.status).toBe(503); 968 const html = await res.text(); 969 expect(html).toContain("error-display"); 970 }); 971 972 it("returns 500 on AppView server error fetching categories", async () => { 973 setupSession(["space.atbb.permission.manageCategories"]); 974 mockFetch.mockResolvedValueOnce(mockResponse({}, false, 500)); 975 976 const routes = await loadAdminRoutes(); 977 const res = await routes.request("/admin/structure", { 978 headers: { cookie: "atbb_session=token" }, 979 }); 980 981 expect(res.status).toBe(500); 982 const html = await res.text(); 983 expect(html).toContain("error-display"); 984 }); 985 986 it("redirects to /login when AppView categories returns 401", async () => { 987 setupSession(["space.atbb.permission.manageCategories"]); 988 mockFetch.mockResolvedValueOnce(mockResponse({}, false, 401)); 989 990 const routes = await loadAdminRoutes(); 991 const res = await routes.request("/admin/structure", { 992 headers: { cookie: "atbb_session=token" }, 993 }); 994 995 expect(res.status).toBe(302); 996 expect(res.headers.get("location")).toBe("/login"); 997 }); 998}); 999 1000describe("createAdminRoutes — POST /admin/structure/categories", () => { 1001 beforeEach(() => { 1002 vi.stubGlobal("fetch", mockFetch); 1003 vi.stubEnv("APPVIEW_URL", "http://localhost:3000"); 1004 vi.resetModules(); 1005 }); 1006 1007 afterEach(() => { 1008 vi.unstubAllGlobals(); 1009 vi.unstubAllEnvs(); 1010 mockFetch.mockReset(); 1011 }); 1012 1013 function mockResponse(body: unknown, ok = true, status = 200) { 1014 return { ok, status, statusText: ok ? "OK" : "Error", json: () => Promise.resolve(body) }; 1015 } 1016 1017 function setupSession(permissions: string[]) { 1018 mockFetch.mockResolvedValueOnce( 1019 mockResponse({ authenticated: true, did: "did:plc:admin", handle: "admin.bsky.social" }) 1020 ); 1021 mockFetch.mockResolvedValueOnce(mockResponse({ permissions })); 1022 } 1023 1024 async function loadAdminRoutes() { 1025 const { createAdminRoutes } = await import("../admin.js"); 1026 return createAdminRoutes("http://localhost:3000"); 1027 } 1028 1029 function postForm(body: Record<string, string>) { 1030 const params = new URLSearchParams(body); 1031 return { 1032 method: "POST", 1033 headers: { 1034 cookie: "atbb_session=token", 1035 "content-type": "application/x-www-form-urlencoded", 1036 }, 1037 body: params.toString(), 1038 }; 1039 } 1040 1041 it("redirects to /login when unauthenticated", async () => { 1042 mockFetch.mockResolvedValueOnce(mockResponse({ authenticated: false })); 1043 const routes = await loadAdminRoutes(); 1044 const res = await routes.request("/admin/structure/categories", postForm({ name: "General" })); 1045 expect(res.status).toBe(302); 1046 expect(res.headers.get("location")).toBe("/login"); 1047 }); 1048 1049 it("returns 403 without manageCategories permission", async () => { 1050 setupSession(["space.atbb.permission.manageMembers"]); 1051 const routes = await loadAdminRoutes(); 1052 const res = await routes.request("/admin/structure/categories", postForm({ name: "General" })); 1053 expect(res.status).toBe(403); 1054 }); 1055 1056 it("redirects to /admin/structure on success", async () => { 1057 setupSession(["space.atbb.permission.manageCategories"]); 1058 mockFetch.mockResolvedValueOnce( 1059 mockResponse({ uri: "at://did:plc:forum/space.atbb.forum.category/abc", cid: "bafyrei..." }, true, 201) 1060 ); 1061 1062 const routes = await loadAdminRoutes(); 1063 const res = await routes.request( 1064 "/admin/structure/categories", 1065 postForm({ name: "General", description: "Talk about anything", sortOrder: "1" }) 1066 ); 1067 1068 expect(res.status).toBe(302); 1069 expect(res.headers.get("location")).toBe("/admin/structure"); 1070 }); 1071 1072 it("redirects with ?error= when name is missing", async () => { 1073 setupSession(["space.atbb.permission.manageCategories"]); 1074 1075 const routes = await loadAdminRoutes(); 1076 const res = await routes.request( 1077 "/admin/structure/categories", 1078 postForm({ name: "" }) 1079 ); 1080 1081 expect(res.status).toBe(302); 1082 const location = res.headers.get("location") ?? ""; 1083 expect(location).toContain("/admin/structure"); 1084 expect(location).toContain("error="); 1085 }); 1086 1087 it("redirects with ?error= on AppView error", async () => { 1088 setupSession(["space.atbb.permission.manageCategories"]); 1089 mockFetch.mockResolvedValueOnce( 1090 mockResponse({ error: "Unexpected error" }, false, 500) 1091 ); 1092 1093 const routes = await loadAdminRoutes(); 1094 const res = await routes.request( 1095 "/admin/structure/categories", 1096 postForm({ name: "General" }) 1097 ); 1098 1099 expect(res.status).toBe(302); 1100 const location = res.headers.get("location") ?? ""; 1101 expect(location).toContain("/admin/structure"); 1102 expect(location).toContain("error="); 1103 }); 1104 1105 it("redirects with ?error= on network error", async () => { 1106 setupSession(["space.atbb.permission.manageCategories"]); 1107 mockFetch.mockRejectedValueOnce(new Error("fetch failed")); 1108 1109 const routes = await loadAdminRoutes(); 1110 const res = await routes.request( 1111 "/admin/structure/categories", 1112 postForm({ name: "General" }) 1113 ); 1114 1115 expect(res.status).toBe(302); 1116 const location = res.headers.get("location") ?? ""; 1117 expect(location).toContain("/admin/structure"); 1118 expect(location).toContain("error="); 1119 }); 1120 1121 it("redirects with ?error= for negative sort order", async () => { 1122 setupSession(["space.atbb.permission.manageCategories"]); 1123 1124 const routes = await loadAdminRoutes(); 1125 const res = await routes.request( 1126 "/admin/structure/categories", 1127 postForm({ name: "General", sortOrder: "-1" }) 1128 ); 1129 1130 expect(res.status).toBe(302); 1131 const location = res.headers.get("location") ?? ""; 1132 expect(location).toContain("error="); 1133 }); 1134}); 1135 1136describe("createAdminRoutes — POST /admin/structure/categories/:id/edit", () => { 1137 beforeEach(() => { 1138 vi.stubGlobal("fetch", mockFetch); 1139 vi.stubEnv("APPVIEW_URL", "http://localhost:3000"); 1140 vi.resetModules(); 1141 }); 1142 1143 afterEach(() => { 1144 vi.unstubAllGlobals(); 1145 vi.unstubAllEnvs(); 1146 mockFetch.mockReset(); 1147 }); 1148 1149 function mockResponse(body: unknown, ok = true, status = 200) { 1150 return { ok, status, statusText: ok ? "OK" : "Error", json: () => Promise.resolve(body) }; 1151 } 1152 1153 function setupSession(permissions: string[]) { 1154 mockFetch.mockResolvedValueOnce( 1155 mockResponse({ authenticated: true, did: "did:plc:admin", handle: "admin.bsky.social" }) 1156 ); 1157 mockFetch.mockResolvedValueOnce(mockResponse({ permissions })); 1158 } 1159 1160 async function loadAdminRoutes() { 1161 const { createAdminRoutes } = await import("../admin.js"); 1162 return createAdminRoutes("http://localhost:3000"); 1163 } 1164 1165 function postForm(body: Record<string, string>) { 1166 const params = new URLSearchParams(body); 1167 return { 1168 method: "POST", 1169 headers: { 1170 cookie: "atbb_session=token", 1171 "content-type": "application/x-www-form-urlencoded", 1172 }, 1173 body: params.toString(), 1174 }; 1175 } 1176 1177 it("redirects to /login when unauthenticated", async () => { 1178 mockFetch.mockResolvedValueOnce(mockResponse({ authenticated: false })); 1179 const routes = await loadAdminRoutes(); 1180 const res = await routes.request("/admin/structure/categories/5/edit", postForm({ name: "Updated" })); 1181 expect(res.status).toBe(302); 1182 expect(res.headers.get("location")).toBe("/login"); 1183 }); 1184 1185 it("returns 403 without manageCategories", async () => { 1186 setupSession(["space.atbb.permission.manageMembers"]); 1187 const routes = await loadAdminRoutes(); 1188 const res = await routes.request("/admin/structure/categories/5/edit", postForm({ name: "Updated" })); 1189 expect(res.status).toBe(403); 1190 }); 1191 1192 it("redirects to /admin/structure on success", async () => { 1193 setupSession(["space.atbb.permission.manageCategories"]); 1194 mockFetch.mockResolvedValueOnce( 1195 mockResponse({ uri: "at://...", cid: "bafyrei..." }, true, 200) 1196 ); 1197 1198 const routes = await loadAdminRoutes(); 1199 const res = await routes.request( 1200 "/admin/structure/categories/5/edit", 1201 postForm({ name: "Updated Name", description: "", sortOrder: "2" }) 1202 ); 1203 1204 expect(res.status).toBe(302); 1205 expect(res.headers.get("location")).toBe("/admin/structure"); 1206 }); 1207 1208 it("redirects with ?error= when name is missing", async () => { 1209 setupSession(["space.atbb.permission.manageCategories"]); 1210 const routes = await loadAdminRoutes(); 1211 const res = await routes.request("/admin/structure/categories/5/edit", postForm({ name: "" })); 1212 expect(res.status).toBe(302); 1213 const location = res.headers.get("location") ?? ""; 1214 expect(location).toContain("error="); 1215 }); 1216 1217 it("redirects with ?error= on AppView error", async () => { 1218 setupSession(["space.atbb.permission.manageCategories"]); 1219 mockFetch.mockResolvedValueOnce(mockResponse({ error: "Not found" }, false, 404)); 1220 const routes = await loadAdminRoutes(); 1221 const res = await routes.request("/admin/structure/categories/5/edit", postForm({ name: "Updated" })); 1222 expect(res.status).toBe(302); 1223 const location = res.headers.get("location") ?? ""; 1224 expect(location).toContain("error="); 1225 }); 1226 1227 it("redirects with ?error= on network error", async () => { 1228 setupSession(["space.atbb.permission.manageCategories"]); 1229 mockFetch.mockRejectedValueOnce(new Error("fetch failed")); 1230 const routes = await loadAdminRoutes(); 1231 const res = await routes.request("/admin/structure/categories/5/edit", postForm({ name: "Updated" })); 1232 expect(res.status).toBe(302); 1233 const location = res.headers.get("location") ?? ""; 1234 expect(location).toContain("error="); 1235 }); 1236 1237 it("redirects with ?error= for negative sort order", async () => { 1238 setupSession(["space.atbb.permission.manageCategories"]); 1239 const routes = await loadAdminRoutes(); 1240 const res = await routes.request( 1241 "/admin/structure/categories/5/edit", 1242 postForm({ name: "Updated", sortOrder: "-5" }) 1243 ); 1244 expect(res.status).toBe(302); 1245 const location = res.headers.get("location") ?? ""; 1246 expect(location).toContain("error="); 1247 }); 1248}); 1249 1250describe("createAdminRoutes — POST /admin/structure/categories/:id/delete", () => { 1251 beforeEach(() => { 1252 vi.stubGlobal("fetch", mockFetch); 1253 vi.stubEnv("APPVIEW_URL", "http://localhost:3000"); 1254 vi.resetModules(); 1255 }); 1256 1257 afterEach(() => { 1258 vi.unstubAllGlobals(); 1259 vi.unstubAllEnvs(); 1260 mockFetch.mockReset(); 1261 }); 1262 1263 function mockResponse(body: unknown, ok = true, status = 200) { 1264 return { ok, status, statusText: ok ? "OK" : "Error", json: () => Promise.resolve(body) }; 1265 } 1266 1267 function setupSession(permissions: string[]) { 1268 mockFetch.mockResolvedValueOnce( 1269 mockResponse({ authenticated: true, did: "did:plc:admin", handle: "admin.bsky.social" }) 1270 ); 1271 mockFetch.mockResolvedValueOnce(mockResponse({ permissions })); 1272 } 1273 1274 async function loadAdminRoutes() { 1275 const { createAdminRoutes } = await import("../admin.js"); 1276 return createAdminRoutes("http://localhost:3000"); 1277 } 1278 1279 function postForm(body: Record<string, string> = {}) { 1280 const params = new URLSearchParams(body); 1281 return { 1282 method: "POST", 1283 headers: { 1284 cookie: "atbb_session=token", 1285 "content-type": "application/x-www-form-urlencoded", 1286 }, 1287 body: params.toString(), 1288 }; 1289 } 1290 1291 it("redirects to /login when unauthenticated", async () => { 1292 mockFetch.mockResolvedValueOnce(mockResponse({ authenticated: false })); 1293 const routes = await loadAdminRoutes(); 1294 const res = await routes.request("/admin/structure/categories/5/delete", postForm()); 1295 expect(res.status).toBe(302); 1296 expect(res.headers.get("location")).toBe("/login"); 1297 }); 1298 1299 it("returns 403 without manageCategories", async () => { 1300 setupSession(["space.atbb.permission.manageMembers"]); 1301 const routes = await loadAdminRoutes(); 1302 const res = await routes.request("/admin/structure/categories/5/delete", postForm()); 1303 expect(res.status).toBe(403); 1304 }); 1305 1306 it("redirects to /admin/structure on success", async () => { 1307 setupSession(["space.atbb.permission.manageCategories"]); 1308 mockFetch.mockResolvedValueOnce(mockResponse({}, true, 200)); 1309 1310 const routes = await loadAdminRoutes(); 1311 const res = await routes.request("/admin/structure/categories/5/delete", postForm()); 1312 1313 expect(res.status).toBe(302); 1314 expect(res.headers.get("location")).toBe("/admin/structure"); 1315 }); 1316 1317 it("redirects with ?error= on AppView error (e.g. 409 has boards)", async () => { 1318 setupSession(["space.atbb.permission.manageCategories"]); 1319 mockFetch.mockResolvedValueOnce( 1320 mockResponse({ error: "Cannot delete category with boards. Remove all boards first." }, false, 409) 1321 ); 1322 1323 const routes = await loadAdminRoutes(); 1324 const res = await routes.request("/admin/structure/categories/5/delete", postForm()); 1325 1326 expect(res.status).toBe(302); 1327 const location = res.headers.get("location") ?? ""; 1328 expect(location).toContain("/admin/structure"); 1329 expect(location).toContain("error="); 1330 expect(decodeURIComponent(location)).toContain("Cannot delete category with boards"); 1331 }); 1332 1333 it("redirects with ?error= on network error", async () => { 1334 setupSession(["space.atbb.permission.manageCategories"]); 1335 mockFetch.mockRejectedValueOnce(new Error("fetch failed")); 1336 1337 const routes = await loadAdminRoutes(); 1338 const res = await routes.request("/admin/structure/categories/5/delete", postForm()); 1339 1340 expect(res.status).toBe(302); 1341 const location = res.headers.get("location") ?? ""; 1342 expect(location).toContain("error="); 1343 }); 1344}); 1345 1346describe("createAdminRoutes — POST /admin/structure/boards", () => { 1347 beforeEach(() => { 1348 vi.stubGlobal("fetch", mockFetch); 1349 vi.stubEnv("APPVIEW_URL", "http://localhost:3000"); 1350 vi.resetModules(); 1351 }); 1352 1353 afterEach(() => { 1354 vi.unstubAllGlobals(); 1355 vi.unstubAllEnvs(); 1356 mockFetch.mockReset(); 1357 }); 1358 1359 function mockResponse(body: unknown, ok = true, status = 200) { 1360 return { ok, status, statusText: ok ? "OK" : "Error", json: () => Promise.resolve(body) }; 1361 } 1362 1363 function setupSession(permissions: string[]) { 1364 mockFetch.mockResolvedValueOnce( 1365 mockResponse({ authenticated: true, did: "did:plc:admin", handle: "admin.bsky.social" }) 1366 ); 1367 mockFetch.mockResolvedValueOnce(mockResponse({ permissions })); 1368 } 1369 1370 async function loadAdminRoutes() { 1371 const { createAdminRoutes } = await import("../admin.js"); 1372 return createAdminRoutes("http://localhost:3000"); 1373 } 1374 1375 function postForm(body: Record<string, string>) { 1376 const params = new URLSearchParams(body); 1377 return { 1378 method: "POST", 1379 headers: { 1380 cookie: "atbb_session=token", 1381 "content-type": "application/x-www-form-urlencoded", 1382 }, 1383 body: params.toString(), 1384 }; 1385 } 1386 1387 it("redirects to /login when unauthenticated", async () => { 1388 mockFetch.mockResolvedValueOnce(mockResponse({ authenticated: false })); 1389 const routes = await loadAdminRoutes(); 1390 const res = await routes.request( 1391 "/admin/structure/boards", 1392 postForm({ name: "General Chat", categoryUri: "at://did:plc:forum/space.atbb.forum.category/abc" }) 1393 ); 1394 expect(res.status).toBe(302); 1395 expect(res.headers.get("location")).toBe("/login"); 1396 }); 1397 1398 it("returns 403 without manageCategories permission", async () => { 1399 setupSession(["space.atbb.permission.manageMembers"]); 1400 const routes = await loadAdminRoutes(); 1401 const res = await routes.request( 1402 "/admin/structure/boards", 1403 postForm({ name: "General Chat", categoryUri: "at://did:plc:forum/space.atbb.forum.category/abc" }) 1404 ); 1405 expect(res.status).toBe(403); 1406 }); 1407 1408 it("redirects to /admin/structure on success", async () => { 1409 setupSession(["space.atbb.permission.manageCategories"]); 1410 mockFetch.mockResolvedValueOnce( 1411 mockResponse({ uri: "at://did:plc:forum/space.atbb.forum.board/xyz", cid: "bafyrei..." }, true, 201) 1412 ); 1413 1414 const routes = await loadAdminRoutes(); 1415 const res = await routes.request( 1416 "/admin/structure/boards", 1417 postForm({ 1418 name: "General Chat", 1419 description: "Chat about anything", 1420 sortOrder: "1", 1421 categoryUri: "at://did:plc:forum/space.atbb.forum.category/abc", 1422 }) 1423 ); 1424 1425 expect(res.status).toBe(302); 1426 expect(res.headers.get("location")).toBe("/admin/structure"); 1427 }); 1428 1429 it("redirects with ?error= when name is missing", async () => { 1430 setupSession(["space.atbb.permission.manageCategories"]); 1431 const routes = await loadAdminRoutes(); 1432 const res = await routes.request( 1433 "/admin/structure/boards", 1434 postForm({ name: "", categoryUri: "at://did:plc:forum/space.atbb.forum.category/abc" }) 1435 ); 1436 expect(res.status).toBe(302); 1437 const location = res.headers.get("location") ?? ""; 1438 expect(location).toContain("/admin/structure"); 1439 expect(location).toContain("error="); 1440 }); 1441 1442 it("redirects with ?error= when categoryUri is missing", async () => { 1443 setupSession(["space.atbb.permission.manageCategories"]); 1444 const routes = await loadAdminRoutes(); 1445 const res = await routes.request( 1446 "/admin/structure/boards", 1447 postForm({ name: "General Chat", categoryUri: "" }) 1448 ); 1449 expect(res.status).toBe(302); 1450 const location = res.headers.get("location") ?? ""; 1451 expect(location).toContain("error="); 1452 }); 1453 1454 it("redirects with ?error= on AppView error", async () => { 1455 setupSession(["space.atbb.permission.manageCategories"]); 1456 mockFetch.mockResolvedValueOnce( 1457 mockResponse({ error: "Category not found" }, false, 404) 1458 ); 1459 1460 const routes = await loadAdminRoutes(); 1461 const res = await routes.request( 1462 "/admin/structure/boards", 1463 postForm({ name: "General Chat", categoryUri: "at://did:plc:forum/space.atbb.forum.category/abc" }) 1464 ); 1465 1466 expect(res.status).toBe(302); 1467 const location = res.headers.get("location") ?? ""; 1468 expect(location).toContain("error="); 1469 }); 1470 1471 it("redirects with ?error= on network error", async () => { 1472 setupSession(["space.atbb.permission.manageCategories"]); 1473 mockFetch.mockRejectedValueOnce(new Error("fetch failed")); 1474 1475 const routes = await loadAdminRoutes(); 1476 const res = await routes.request( 1477 "/admin/structure/boards", 1478 postForm({ name: "General Chat", categoryUri: "at://did:plc:forum/space.atbb.forum.category/abc" }) 1479 ); 1480 1481 expect(res.status).toBe(302); 1482 const location = res.headers.get("location") ?? ""; 1483 expect(location).toContain("error="); 1484 }); 1485 1486 it("redirects with ?error= for negative sort order", async () => { 1487 setupSession(["space.atbb.permission.manageCategories"]); 1488 const routes = await loadAdminRoutes(); 1489 const res = await routes.request( 1490 "/admin/structure/boards", 1491 postForm({ 1492 name: "General Chat", 1493 categoryUri: "at://did:plc:forum/space.atbb.forum.category/abc", 1494 sortOrder: "-2", 1495 }) 1496 ); 1497 expect(res.status).toBe(302); 1498 const location = res.headers.get("location") ?? ""; 1499 expect(location).toContain("error="); 1500 }); 1501}); 1502 1503describe("createAdminRoutes — POST /admin/structure/boards/:id/edit", () => { 1504 beforeEach(() => { 1505 vi.stubGlobal("fetch", mockFetch); 1506 vi.stubEnv("APPVIEW_URL", "http://localhost:3000"); 1507 vi.resetModules(); 1508 }); 1509 1510 afterEach(() => { 1511 vi.unstubAllGlobals(); 1512 vi.unstubAllEnvs(); 1513 mockFetch.mockReset(); 1514 }); 1515 1516 function mockResponse(body: unknown, ok = true, status = 200) { 1517 return { ok, status, statusText: ok ? "OK" : "Error", json: () => Promise.resolve(body) }; 1518 } 1519 1520 function setupSession(permissions: string[]) { 1521 mockFetch.mockResolvedValueOnce( 1522 mockResponse({ authenticated: true, did: "did:plc:admin", handle: "admin.bsky.social" }) 1523 ); 1524 mockFetch.mockResolvedValueOnce(mockResponse({ permissions })); 1525 } 1526 1527 async function loadAdminRoutes() { 1528 const { createAdminRoutes } = await import("../admin.js"); 1529 return createAdminRoutes("http://localhost:3000"); 1530 } 1531 1532 function postForm(body: Record<string, string>) { 1533 const params = new URLSearchParams(body); 1534 return { 1535 method: "POST", 1536 headers: { 1537 cookie: "atbb_session=token", 1538 "content-type": "application/x-www-form-urlencoded", 1539 }, 1540 body: params.toString(), 1541 }; 1542 } 1543 1544 it("redirects to /login when unauthenticated", async () => { 1545 mockFetch.mockResolvedValueOnce(mockResponse({ authenticated: false })); 1546 const routes = await loadAdminRoutes(); 1547 const res = await routes.request("/admin/structure/boards/10/edit", postForm({ name: "Updated" })); 1548 expect(res.status).toBe(302); 1549 expect(res.headers.get("location")).toBe("/login"); 1550 }); 1551 1552 it("returns 403 without manageCategories", async () => { 1553 setupSession(["space.atbb.permission.manageMembers"]); 1554 const routes = await loadAdminRoutes(); 1555 const res = await routes.request("/admin/structure/boards/10/edit", postForm({ name: "Updated" })); 1556 expect(res.status).toBe(403); 1557 }); 1558 1559 it("redirects to /admin/structure on success", async () => { 1560 setupSession(["space.atbb.permission.manageCategories"]); 1561 mockFetch.mockResolvedValueOnce(mockResponse({ uri: "at://...", cid: "bafyrei..." }, true, 200)); 1562 1563 const routes = await loadAdminRoutes(); 1564 const res = await routes.request( 1565 "/admin/structure/boards/10/edit", 1566 postForm({ name: "Updated Board", description: "", sortOrder: "3" }) 1567 ); 1568 1569 expect(res.status).toBe(302); 1570 expect(res.headers.get("location")).toBe("/admin/structure"); 1571 }); 1572 1573 it("redirects with ?error= when name is missing", async () => { 1574 setupSession(["space.atbb.permission.manageCategories"]); 1575 const routes = await loadAdminRoutes(); 1576 const res = await routes.request("/admin/structure/boards/10/edit", postForm({ name: "" })); 1577 expect(res.status).toBe(302); 1578 const location = res.headers.get("location") ?? ""; 1579 expect(location).toContain("error="); 1580 }); 1581 1582 it("redirects with ?error= on AppView error", async () => { 1583 setupSession(["space.atbb.permission.manageCategories"]); 1584 mockFetch.mockResolvedValueOnce(mockResponse({ error: "Board not found" }, false, 404)); 1585 const routes = await loadAdminRoutes(); 1586 const res = await routes.request("/admin/structure/boards/10/edit", postForm({ name: "Updated" })); 1587 expect(res.status).toBe(302); 1588 const location = res.headers.get("location") ?? ""; 1589 expect(location).toContain("error="); 1590 }); 1591 1592 it("redirects with ?error= on network error", async () => { 1593 setupSession(["space.atbb.permission.manageCategories"]); 1594 mockFetch.mockRejectedValueOnce(new Error("fetch failed")); 1595 const routes = await loadAdminRoutes(); 1596 const res = await routes.request("/admin/structure/boards/10/edit", postForm({ name: "Updated" })); 1597 expect(res.status).toBe(302); 1598 const location = res.headers.get("location") ?? ""; 1599 expect(location).toContain("error="); 1600 }); 1601 1602 it("redirects with ?error= for negative sort order", async () => { 1603 setupSession(["space.atbb.permission.manageCategories"]); 1604 const routes = await loadAdminRoutes(); 1605 const res = await routes.request( 1606 "/admin/structure/boards/10/edit", 1607 postForm({ name: "Updated Board", sortOrder: "-3" }) 1608 ); 1609 expect(res.status).toBe(302); 1610 const location = res.headers.get("location") ?? ""; 1611 expect(location).toContain("error="); 1612 }); 1613}); 1614 1615describe("createAdminRoutes — POST /admin/structure/boards/:id/delete", () => { 1616 beforeEach(() => { 1617 vi.stubGlobal("fetch", mockFetch); 1618 vi.stubEnv("APPVIEW_URL", "http://localhost:3000"); 1619 vi.resetModules(); 1620 }); 1621 1622 afterEach(() => { 1623 vi.unstubAllGlobals(); 1624 vi.unstubAllEnvs(); 1625 mockFetch.mockReset(); 1626 }); 1627 1628 function mockResponse(body: unknown, ok = true, status = 200) { 1629 return { ok, status, statusText: ok ? "OK" : "Error", json: () => Promise.resolve(body) }; 1630 } 1631 1632 function setupSession(permissions: string[]) { 1633 mockFetch.mockResolvedValueOnce( 1634 mockResponse({ authenticated: true, did: "did:plc:admin", handle: "admin.bsky.social" }) 1635 ); 1636 mockFetch.mockResolvedValueOnce(mockResponse({ permissions })); 1637 } 1638 1639 async function loadAdminRoutes() { 1640 const { createAdminRoutes } = await import("../admin.js"); 1641 return createAdminRoutes("http://localhost:3000"); 1642 } 1643 1644 function postForm(body: Record<string, string> = {}) { 1645 const params = new URLSearchParams(body); 1646 return { 1647 method: "POST", 1648 headers: { 1649 cookie: "atbb_session=token", 1650 "content-type": "application/x-www-form-urlencoded", 1651 }, 1652 body: params.toString(), 1653 }; 1654 } 1655 1656 it("redirects to /login when unauthenticated", async () => { 1657 mockFetch.mockResolvedValueOnce(mockResponse({ authenticated: false })); 1658 const routes = await loadAdminRoutes(); 1659 const res = await routes.request("/admin/structure/boards/10/delete", postForm()); 1660 expect(res.status).toBe(302); 1661 expect(res.headers.get("location")).toBe("/login"); 1662 }); 1663 1664 it("returns 403 without manageCategories", async () => { 1665 setupSession(["space.atbb.permission.manageMembers"]); 1666 const routes = await loadAdminRoutes(); 1667 const res = await routes.request("/admin/structure/boards/10/delete", postForm()); 1668 expect(res.status).toBe(403); 1669 }); 1670 1671 it("redirects to /admin/structure on success", async () => { 1672 setupSession(["space.atbb.permission.manageCategories"]); 1673 mockFetch.mockResolvedValueOnce(mockResponse({}, true, 200)); 1674 1675 const routes = await loadAdminRoutes(); 1676 const res = await routes.request("/admin/structure/boards/10/delete", postForm()); 1677 1678 expect(res.status).toBe(302); 1679 expect(res.headers.get("location")).toBe("/admin/structure"); 1680 }); 1681 1682 it("redirects with ?error= on AppView error (e.g. 409 has posts)", async () => { 1683 setupSession(["space.atbb.permission.manageCategories"]); 1684 mockFetch.mockResolvedValueOnce( 1685 mockResponse({ error: "Cannot delete board with posts. Remove all posts first." }, false, 409) 1686 ); 1687 1688 const routes = await loadAdminRoutes(); 1689 const res = await routes.request("/admin/structure/boards/10/delete", postForm()); 1690 1691 expect(res.status).toBe(302); 1692 const location = res.headers.get("location") ?? ""; 1693 expect(decodeURIComponent(location)).toContain("Cannot delete board with posts"); 1694 }); 1695 1696 it("redirects with ?error= on network error", async () => { 1697 setupSession(["space.atbb.permission.manageCategories"]); 1698 mockFetch.mockRejectedValueOnce(new Error("fetch failed")); 1699 1700 const routes = await loadAdminRoutes(); 1701 const res = await routes.request("/admin/structure/boards/10/delete", postForm()); 1702 1703 expect(res.status).toBe(302); 1704 const location = res.headers.get("location") ?? ""; 1705 expect(location).toContain("error="); 1706 }); 1707}); 1708 1709describe("createAdminRoutes — GET /admin/modlog", () => { 1710 beforeEach(() => { 1711 vi.stubGlobal("fetch", mockFetch); 1712 vi.stubEnv("APPVIEW_URL", "http://localhost:3000"); 1713 vi.resetModules(); 1714 }); 1715 1716 afterEach(() => { 1717 vi.unstubAllGlobals(); 1718 vi.unstubAllEnvs(); 1719 mockFetch.mockReset(); 1720 }); 1721 1722 function mockResponse(body: unknown, ok = true, status = 200) { 1723 return { 1724 ok, 1725 status, 1726 statusText: ok ? "OK" : "Error", 1727 json: () => Promise.resolve(body), 1728 }; 1729 } 1730 1731 function setupSession(permissions: string[]) { 1732 mockFetch.mockResolvedValueOnce( 1733 mockResponse({ authenticated: true, did: "did:plc:admin", handle: "admin.bsky.social" }) 1734 ); 1735 mockFetch.mockResolvedValueOnce(mockResponse({ permissions })); 1736 } 1737 1738 async function loadAdminRoutes() { 1739 const { createAdminRoutes } = await import("../admin.js"); 1740 return createAdminRoutes("http://localhost:3000"); 1741 } 1742 1743 const SAMPLE_ACTIONS = [ 1744 { 1745 id: "1", 1746 action: "space.atbb.modAction.ban", 1747 moderatorDid: "did:plc:alice", 1748 moderatorHandle: "alice.bsky.social", 1749 subjectDid: "did:plc:bob", 1750 subjectHandle: "bob.bsky.social", 1751 subjectPostUri: null, 1752 reason: "Spam", 1753 createdAt: "2026-02-26T12:01:00.000Z", 1754 }, 1755 { 1756 id: "2", 1757 action: "space.atbb.modAction.delete", 1758 moderatorDid: "did:plc:alice", 1759 moderatorHandle: "alice.bsky.social", 1760 subjectDid: null, 1761 subjectHandle: null, 1762 subjectPostUri: "at://did:plc:bob/space.atbb.post/abc123", 1763 reason: "Inappropriate", 1764 createdAt: "2026-02-26T11:30:00.000Z", 1765 }, 1766 ]; 1767 1768 // ── Auth & permission gates ────────────────────────────────────────────── 1769 1770 it("redirects unauthenticated users to /login", async () => { 1771 const routes = await loadAdminRoutes(); 1772 const res = await routes.request("/admin/modlog"); 1773 expect(res.status).toBe(302); 1774 expect(res.headers.get("location")).toBe("/login"); 1775 }); 1776 1777 it("returns 403 for user without any mod permission", async () => { 1778 setupSession(["space.atbb.permission.manageCategories"]); 1779 const routes = await loadAdminRoutes(); 1780 const res = await routes.request("/admin/modlog", { 1781 headers: { cookie: "atbb_session=token" }, 1782 }); 1783 expect(res.status).toBe(403); 1784 const html = await res.text(); 1785 expect(html).toContain("permission"); 1786 }); 1787 1788 it("allows access for moderatePosts permission", async () => { 1789 setupSession(["space.atbb.permission.moderatePosts"]); 1790 mockFetch.mockResolvedValueOnce( 1791 mockResponse({ actions: [], total: 0, offset: 0, limit: 50 }) 1792 ); 1793 const routes = await loadAdminRoutes(); 1794 const res = await routes.request("/admin/modlog", { 1795 headers: { cookie: "atbb_session=token" }, 1796 }); 1797 expect(res.status).toBe(200); 1798 }); 1799 1800 it("allows access for banUsers permission", async () => { 1801 setupSession(["space.atbb.permission.banUsers"]); 1802 mockFetch.mockResolvedValueOnce( 1803 mockResponse({ actions: [], total: 0, offset: 0, limit: 50 }) 1804 ); 1805 const routes = await loadAdminRoutes(); 1806 const res = await routes.request("/admin/modlog", { 1807 headers: { cookie: "atbb_session=token" }, 1808 }); 1809 expect(res.status).toBe(200); 1810 }); 1811 1812 it("allows access for lockTopics permission", async () => { 1813 setupSession(["space.atbb.permission.lockTopics"]); 1814 mockFetch.mockResolvedValueOnce( 1815 mockResponse({ actions: [], total: 0, offset: 0, limit: 50 }) 1816 ); 1817 const routes = await loadAdminRoutes(); 1818 const res = await routes.request("/admin/modlog", { 1819 headers: { cookie: "atbb_session=token" }, 1820 }); 1821 expect(res.status).toBe(200); 1822 }); 1823 1824 // ── Table rendering ────────────────────────────────────────────────────── 1825 1826 it("renders table with moderator handle and action label", async () => { 1827 setupSession(["space.atbb.permission.banUsers"]); 1828 mockFetch.mockResolvedValueOnce( 1829 mockResponse({ actions: SAMPLE_ACTIONS, total: 2, offset: 0, limit: 50 }) 1830 ); 1831 const routes = await loadAdminRoutes(); 1832 const res = await routes.request("/admin/modlog", { 1833 headers: { cookie: "atbb_session=token" }, 1834 }); 1835 const html = await res.text(); 1836 expect(html).toContain("alice.bsky.social"); 1837 expect(html).toContain("Ban"); 1838 expect(html).toContain("bob.bsky.social"); 1839 expect(html).toContain("Spam"); 1840 }); 1841 1842 it("maps space.atbb.modAction.delete to 'Hide' label", async () => { 1843 setupSession(["space.atbb.permission.moderatePosts"]); 1844 mockFetch.mockResolvedValueOnce( 1845 mockResponse({ actions: SAMPLE_ACTIONS, total: 2, offset: 0, limit: 50 }) 1846 ); 1847 const routes = await loadAdminRoutes(); 1848 const res = await routes.request("/admin/modlog", { 1849 headers: { cookie: "atbb_session=token" }, 1850 }); 1851 const html = await res.text(); 1852 expect(html).toContain("Hide"); 1853 }); 1854 1855 it("shows post URI in subject column for post-targeting actions", async () => { 1856 setupSession(["space.atbb.permission.moderatePosts"]); 1857 mockFetch.mockResolvedValueOnce( 1858 mockResponse({ actions: SAMPLE_ACTIONS, total: 2, offset: 0, limit: 50 }) 1859 ); 1860 const routes = await loadAdminRoutes(); 1861 const res = await routes.request("/admin/modlog", { 1862 headers: { cookie: "atbb_session=token" }, 1863 }); 1864 const html = await res.text(); 1865 expect(html).toContain("at://did:plc:bob/space.atbb.post/abc123"); 1866 }); 1867 1868 it("shows handle in subject column for user-targeting actions", async () => { 1869 setupSession(["space.atbb.permission.banUsers"]); 1870 mockFetch.mockResolvedValueOnce( 1871 mockResponse({ actions: SAMPLE_ACTIONS, total: 2, offset: 0, limit: 50 }) 1872 ); 1873 const routes = await loadAdminRoutes(); 1874 const res = await routes.request("/admin/modlog", { 1875 headers: { cookie: "atbb_session=token" }, 1876 }); 1877 const html = await res.text(); 1878 expect(html).toContain("bob.bsky.social"); 1879 }); 1880 1881 it("shows empty state when no actions", async () => { 1882 setupSession(["space.atbb.permission.banUsers"]); 1883 mockFetch.mockResolvedValueOnce( 1884 mockResponse({ actions: [], total: 0, offset: 0, limit: 50 }) 1885 ); 1886 const routes = await loadAdminRoutes(); 1887 const res = await routes.request("/admin/modlog", { 1888 headers: { cookie: "atbb_session=token" }, 1889 }); 1890 const html = await res.text(); 1891 expect(html).toContain("No moderation actions"); 1892 }); 1893 1894 // ── Pagination ─────────────────────────────────────────────────────────── 1895 1896 it("renders 'Page 1 of 2' indicator for 51 total actions", async () => { 1897 setupSession(["space.atbb.permission.banUsers"]); 1898 mockFetch.mockResolvedValueOnce( 1899 mockResponse({ actions: SAMPLE_ACTIONS, total: 51, offset: 0, limit: 50 }) 1900 ); 1901 const routes = await loadAdminRoutes(); 1902 const res = await routes.request("/admin/modlog", { 1903 headers: { cookie: "atbb_session=token" }, 1904 }); 1905 const html = await res.text(); 1906 expect(html).toContain("Page 1 of 2"); 1907 }); 1908 1909 it("shows Next link when more pages exist", async () => { 1910 setupSession(["space.atbb.permission.banUsers"]); 1911 mockFetch.mockResolvedValueOnce( 1912 mockResponse({ actions: SAMPLE_ACTIONS, total: 51, offset: 0, limit: 50 }) 1913 ); 1914 const routes = await loadAdminRoutes(); 1915 const res = await routes.request("/admin/modlog", { 1916 headers: { cookie: "atbb_session=token" }, 1917 }); 1918 const html = await res.text(); 1919 expect(html).toContain('href="/admin/modlog?offset=50"'); 1920 expect(html).toContain("Next"); 1921 }); 1922 1923 it("hides Next link on last page", async () => { 1924 setupSession(["space.atbb.permission.banUsers"]); 1925 mockFetch.mockResolvedValueOnce( 1926 mockResponse({ actions: SAMPLE_ACTIONS, total: 51, offset: 50, limit: 50 }) 1927 ); 1928 const routes = await loadAdminRoutes(); 1929 const res = await routes.request("/admin/modlog?offset=50", { 1930 headers: { cookie: "atbb_session=token" }, 1931 }); 1932 const html = await res.text(); 1933 expect(html).not.toContain('href="/admin/modlog?offset=100"'); 1934 }); 1935 1936 it("shows Previous link when not on first page", async () => { 1937 setupSession(["space.atbb.permission.banUsers"]); 1938 mockFetch.mockResolvedValueOnce( 1939 mockResponse({ actions: SAMPLE_ACTIONS, total: 51, offset: 50, limit: 50 }) 1940 ); 1941 const routes = await loadAdminRoutes(); 1942 const res = await routes.request("/admin/modlog?offset=50", { 1943 headers: { cookie: "atbb_session=token" }, 1944 }); 1945 const html = await res.text(); 1946 expect(html).toContain('href="/admin/modlog?offset=0"'); 1947 expect(html).toContain("Previous"); 1948 }); 1949 1950 it("hides Previous link on first page", async () => { 1951 setupSession(["space.atbb.permission.banUsers"]); 1952 mockFetch.mockResolvedValueOnce( 1953 mockResponse({ actions: SAMPLE_ACTIONS, total: 51, offset: 0, limit: 50 }) 1954 ); 1955 const routes = await loadAdminRoutes(); 1956 const res = await routes.request("/admin/modlog", { 1957 headers: { cookie: "atbb_session=token" }, 1958 }); 1959 const html = await res.text(); 1960 expect(html).not.toContain('href="/admin/modlog?offset=-50"'); 1961 expect(html).not.toContain("Previous"); 1962 }); 1963 1964 it("passes offset query param to AppView", async () => { 1965 setupSession(["space.atbb.permission.banUsers"]); 1966 mockFetch.mockResolvedValueOnce( 1967 mockResponse({ actions: SAMPLE_ACTIONS, total: 100, offset: 50, limit: 50 }) 1968 ); 1969 const routes = await loadAdminRoutes(); 1970 await routes.request("/admin/modlog?offset=50", { 1971 headers: { cookie: "atbb_session=token" }, 1972 }); 1973 // Third fetch call (index 2) is the modlog API call 1974 const modlogCall = mockFetch.mock.calls[2]; 1975 expect(modlogCall[0]).toContain("offset=50"); 1976 expect(modlogCall[0]).toContain("limit=50"); 1977 }); 1978 1979 it("ignores invalid offset and defaults to 0", async () => { 1980 setupSession(["space.atbb.permission.banUsers"]); 1981 mockFetch.mockResolvedValueOnce( 1982 mockResponse({ actions: SAMPLE_ACTIONS, total: 2, offset: 0, limit: 50 }) 1983 ); 1984 const routes = await loadAdminRoutes(); 1985 const res = await routes.request("/admin/modlog?offset=notanumber", { 1986 headers: { cookie: "atbb_session=token" }, 1987 }); 1988 expect(res.status).toBe(200); 1989 const modlogCall = mockFetch.mock.calls[2]; 1990 expect(modlogCall[0]).toContain("offset=0"); 1991 }); 1992 1993 // ── Error handling ─────────────────────────────────────────────────────── 1994 1995 it("returns 503 on AppView network error", async () => { 1996 setupSession(["space.atbb.permission.banUsers"]); 1997 mockFetch.mockRejectedValueOnce(new Error("fetch failed")); 1998 const routes = await loadAdminRoutes(); 1999 const res = await routes.request("/admin/modlog", { 2000 headers: { cookie: "atbb_session=token" }, 2001 }); 2002 expect(res.status).toBe(503); 2003 const html = await res.text(); 2004 expect(html).toContain("error-display"); 2005 }); 2006 2007 it("returns 500 on AppView server error", async () => { 2008 setupSession(["space.atbb.permission.banUsers"]); 2009 mockFetch.mockResolvedValueOnce(mockResponse({}, false, 500)); 2010 const routes = await loadAdminRoutes(); 2011 const res = await routes.request("/admin/modlog", { 2012 headers: { cookie: "atbb_session=token" }, 2013 }); 2014 expect(res.status).toBe(500); 2015 const html = await res.text(); 2016 expect(html).toContain("error-display"); 2017 }); 2018 2019 it("returns 500 when AppView returns non-JSON response body", async () => { 2020 setupSession(["space.atbb.permission.banUsers"]); 2021 mockFetch.mockResolvedValueOnce({ 2022 ok: true, 2023 status: 200, 2024 statusText: "OK", 2025 json: () => Promise.reject(new SyntaxError("Unexpected token '<' in JSON")), 2026 }); 2027 const routes = await loadAdminRoutes(); 2028 const res = await routes.request("/admin/modlog", { 2029 headers: { cookie: "atbb_session=token" }, 2030 }); 2031 expect(res.status).toBe(500); 2032 const html = await res.text(); 2033 expect(html).toContain("error-display"); 2034 }); 2035 2036 it("redirects to /login when AppView returns 401", async () => { 2037 setupSession(["space.atbb.permission.banUsers"]); 2038 mockFetch.mockResolvedValueOnce(mockResponse({}, false, 401)); 2039 const routes = await loadAdminRoutes(); 2040 const res = await routes.request("/admin/modlog", { 2041 headers: { cookie: "atbb_session=token" }, 2042 }); 2043 expect(res.status).toBe(302); 2044 expect(res.headers.get("location")).toBe("/login"); 2045 }); 2046});