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
4
fork

Configure Feed

Select the types of activity you want to include in your feed.

test(web): add failing tests for GET /admin/structure (ATB-47)

Malpercio 2caf450d e35f3688

+271
+253
apps/web/src/routes/__tests__/admin.test.tsx
··· 743 743 ); 744 744 }); 745 745 }); 746 + 747 + describe("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 + });
+18
apps/web/src/routes/admin.tsx
··· 29 29 priority: number; 30 30 } 31 31 32 + interface CategoryEntry { 33 + id: string; 34 + did: string; 35 + uri: string; 36 + name: string; 37 + description: string | null; 38 + sortOrder: number | null; 39 + } 40 + 41 + interface BoardEntry { 42 + id: string; 43 + name: string; 44 + description: string | null; 45 + sortOrder: number | null; 46 + categoryUri: string; 47 + uri: string; 48 + } 49 + 32 50 // ─── Helpers ─────────────────────────────────────────────────────────────── 33 51 34 52 function formatJoinedDate(isoString: string | null): string {