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

feat(appview): category management endpoints POST/PUT/DELETE (ATB-44) (#76)

* feat(appview): POST /api/admin/categories create endpoint (ATB-44)

* feat(appview): PUT /api/admin/categories/:id update endpoint (ATB-44)

* test(appview): add malformed JSON test for PUT /api/admin/categories/:id (ATB-44)

* test(appview): add failing tests for DELETE /api/admin/categories/:id (ATB-44)

* feat(appview): DELETE /api/admin/categories/:id delete endpoint (ATB-44)

* docs(bruno): add category management API collection (ATB-44)

* fix(appview): use handleRouteError after consolidation refactor (ATB-44)

PR #74 consolidated handleReadError, handleWriteError, and
handleSecurityCheckError into a single handleRouteError. Update the
new category management handlers added in this branch to use the
consolidated name.

* fix(appview): address category endpoint review feedback (ATB-44)

- Tighten sortOrder validation: Number.isInteger() && >= 0 instead of
typeof === "number" (rejects floats, negatives, NaN, Infinity per lexicon
constraint integer, minimum: 0)
- Add 503 "ForumAgent not authenticated" tests for POST, PUT, DELETE
- Add 503 database failure tests for PUT and DELETE category lookup
- Add 403 permission tests for POST, PUT, DELETE

* fix(appview): address final review feedback on category endpoints (ATB-44)

- Fix PUT data loss: putRecord is a full AT Protocol record replacement,
not a patch. Fall back to existing category.description and
category.sortOrder when not provided in request body.
- Add test verifying existing description/sortOrder are preserved on
partial updates (regression test for the data loss bug).
- Add test for DELETE board-count preflight query failure path (503),
using a call-count mock so category lookup succeeds while the second
select throws.

authored by

Malpercio and committed by
GitHub
96062ce0 0ed35d76

+993 -3
+629 -2
apps/appview/src/routes/__tests__/admin.test.ts
··· 2 2 import { createTestContext, type TestContext } from "../../lib/__tests__/test-context.js"; 3 3 import { Hono } from "hono"; 4 4 import type { Variables } from "../../types.js"; 5 - import { memberships, roles, rolePermissions, users, forums } from "@atbb/db"; 5 + import { memberships, roles, rolePermissions, users, forums, categories, boards } from "@atbb/db"; 6 6 7 7 // Mock middleware at module level 8 8 let mockUser: any; 9 9 let mockGetUserRole: ReturnType<typeof vi.fn>; 10 10 let mockPutRecord: ReturnType<typeof vi.fn>; 11 + let mockDeleteRecord: ReturnType<typeof vi.fn>; 11 12 12 13 // Create the mock function at module level 13 14 mockGetUserRole = vi.fn(); ··· 46 47 mockGetUserRole.mockClear(); 47 48 48 49 // Mock putRecord 49 - mockPutRecord = vi.fn().mockResolvedValue({ uri: "at://...", cid: "bafytest" }); 50 + mockPutRecord = vi.fn().mockResolvedValue({ data: { uri: "at://...", cid: "bafytest" } }); 51 + mockDeleteRecord = vi.fn().mockResolvedValue({}); 50 52 51 53 // Mock ForumAgent 52 54 ctx.forumAgent = { ··· 55 57 atproto: { 56 58 repo: { 57 59 putRecord: mockPutRecord, 60 + deleteRecord: mockDeleteRecord, 58 61 }, 59 62 }, 60 63 }, ··· 815 818 expect(data.role).toBe("Guest"); 816 819 expect(data.roleUri).toBeNull(); 817 820 expect(data.permissions).toEqual([]); 821 + }); 822 + }); 823 + 824 + describe.sequential("POST /api/admin/categories", () => { 825 + beforeEach(async () => { 826 + await ctx.cleanDatabase(); 827 + 828 + mockUser = { did: "did:plc:test-admin" }; 829 + mockPutRecord.mockClear(); 830 + mockDeleteRecord.mockClear(); 831 + mockPutRecord.mockResolvedValue({ data: { uri: `at://${ctx.config.forumDid}/space.atbb.forum.category/tid123`, cid: "bafycategory" } }); 832 + }); 833 + 834 + it("creates category with valid body → 201 and putRecord called", async () => { 835 + const res = await app.request("/api/admin/categories", { 836 + method: "POST", 837 + headers: { "Content-Type": "application/json" }, 838 + body: JSON.stringify({ name: "General Discussion", description: "Talk about anything.", sortOrder: 1 }), 839 + }); 840 + 841 + expect(res.status).toBe(201); 842 + const data = await res.json(); 843 + expect(data.uri).toContain("/space.atbb.forum.category/"); 844 + expect(data.cid).toBe("bafycategory"); 845 + expect(mockPutRecord).toHaveBeenCalledWith( 846 + expect.objectContaining({ 847 + repo: ctx.config.forumDid, 848 + collection: "space.atbb.forum.category", 849 + rkey: expect.any(String), 850 + record: expect.objectContaining({ 851 + $type: "space.atbb.forum.category", 852 + name: "General Discussion", 853 + description: "Talk about anything.", 854 + sortOrder: 1, 855 + createdAt: expect.any(String), 856 + }), 857 + }) 858 + ); 859 + }); 860 + 861 + it("creates category without optional fields → 201", async () => { 862 + const res = await app.request("/api/admin/categories", { 863 + method: "POST", 864 + headers: { "Content-Type": "application/json" }, 865 + body: JSON.stringify({ name: "Minimal" }), 866 + }); 867 + 868 + expect(res.status).toBe(201); 869 + expect(mockPutRecord).toHaveBeenCalledWith( 870 + expect.objectContaining({ 871 + record: expect.objectContaining({ name: "Minimal" }), 872 + }) 873 + ); 874 + }); 875 + 876 + it("returns 400 when name is missing → no PDS write", async () => { 877 + const res = await app.request("/api/admin/categories", { 878 + method: "POST", 879 + headers: { "Content-Type": "application/json" }, 880 + body: JSON.stringify({ description: "No name field" }), 881 + }); 882 + 883 + expect(res.status).toBe(400); 884 + const data = await res.json(); 885 + expect(data.error).toContain("name"); 886 + expect(mockPutRecord).not.toHaveBeenCalled(); 887 + }); 888 + 889 + it("returns 400 when name is empty string → no PDS write", async () => { 890 + const res = await app.request("/api/admin/categories", { 891 + method: "POST", 892 + headers: { "Content-Type": "application/json" }, 893 + body: JSON.stringify({ name: " " }), 894 + }); 895 + 896 + expect(res.status).toBe(400); 897 + expect(mockPutRecord).not.toHaveBeenCalled(); 898 + }); 899 + 900 + it("returns 400 for malformed JSON", async () => { 901 + const res = await app.request("/api/admin/categories", { 902 + method: "POST", 903 + headers: { "Content-Type": "application/json" }, 904 + body: "{ bad json }", 905 + }); 906 + 907 + expect(res.status).toBe(400); 908 + const data = await res.json(); 909 + expect(data.error).toContain("Invalid JSON"); 910 + expect(mockPutRecord).not.toHaveBeenCalled(); 911 + }); 912 + 913 + it("returns 401 when unauthenticated → no PDS write", async () => { 914 + mockUser = null; 915 + 916 + const res = await app.request("/api/admin/categories", { 917 + method: "POST", 918 + headers: { "Content-Type": "application/json" }, 919 + body: JSON.stringify({ name: "Test" }), 920 + }); 921 + 922 + expect(res.status).toBe(401); 923 + expect(mockPutRecord).not.toHaveBeenCalled(); 924 + }); 925 + 926 + it("returns 503 when PDS network error", async () => { 927 + mockPutRecord.mockRejectedValue(new Error("fetch failed")); 928 + 929 + const res = await app.request("/api/admin/categories", { 930 + method: "POST", 931 + headers: { "Content-Type": "application/json" }, 932 + body: JSON.stringify({ name: "Test" }), 933 + }); 934 + 935 + expect(res.status).toBe(503); 936 + const data = await res.json(); 937 + expect(data.error).toContain("Unable to reach external service"); 938 + expect(mockPutRecord).toHaveBeenCalled(); 939 + }); 940 + 941 + it("returns 500 when ForumAgent unavailable", async () => { 942 + ctx.forumAgent = null; 943 + 944 + const res = await app.request("/api/admin/categories", { 945 + method: "POST", 946 + headers: { "Content-Type": "application/json" }, 947 + body: JSON.stringify({ name: "Test" }), 948 + }); 949 + 950 + expect(res.status).toBe(500); 951 + const data = await res.json(); 952 + expect(data.error).toContain("Forum agent not available"); 953 + }); 954 + 955 + it("returns 503 when ForumAgent not authenticated", async () => { 956 + const originalAgent = ctx.forumAgent; 957 + ctx.forumAgent = { getAgent: () => null } as any; 958 + 959 + const res = await app.request("/api/admin/categories", { 960 + method: "POST", 961 + headers: { "Content-Type": "application/json" }, 962 + body: JSON.stringify({ name: "Test" }), 963 + }); 964 + 965 + expect(res.status).toBe(503); 966 + const data = await res.json(); 967 + expect(data.error).toBe("Forum agent not authenticated. Please try again later."); 968 + expect(mockPutRecord).not.toHaveBeenCalled(); 969 + 970 + ctx.forumAgent = originalAgent; 971 + }); 972 + 973 + it("returns 403 when user lacks manageCategories permission", async () => { 974 + const { requirePermission } = await import("../../middleware/permissions.js"); 975 + const mockRequirePermission = requirePermission as any; 976 + mockRequirePermission.mockImplementation(() => async (c: any) => { 977 + return c.json({ error: "Forbidden" }, 403); 978 + }); 979 + 980 + const testApp = new Hono<{ Variables: Variables }>().route("/api/admin", createAdminRoutes(ctx)); 981 + const res = await testApp.request("/api/admin/categories", { 982 + method: "POST", 983 + headers: { "Content-Type": "application/json" }, 984 + body: JSON.stringify({ name: "Test" }), 985 + }); 986 + 987 + expect(res.status).toBe(403); 988 + expect(mockPutRecord).not.toHaveBeenCalled(); 989 + 990 + mockRequirePermission.mockImplementation(() => async (_c: any, next: any) => { 991 + await next(); 992 + }); 993 + }); 994 + }); 995 + 996 + describe.sequential("PUT /api/admin/categories/:id", () => { 997 + let categoryId: string; 998 + 999 + beforeEach(async () => { 1000 + await ctx.cleanDatabase(); 1001 + 1002 + await ctx.db.insert(forums).values({ 1003 + did: ctx.config.forumDid, 1004 + rkey: "self", 1005 + cid: "bafytest", 1006 + name: "Test Forum", 1007 + description: "A test forum", 1008 + indexedAt: new Date(), 1009 + }); 1010 + 1011 + const [cat] = await ctx.db.insert(categories).values({ 1012 + did: ctx.config.forumDid, 1013 + rkey: "tid-test-cat", 1014 + cid: "bafycat", 1015 + name: "Original Name", 1016 + description: "Original description", 1017 + sortOrder: 1, 1018 + createdAt: new Date("2026-01-01T00:00:00.000Z"), 1019 + indexedAt: new Date(), 1020 + }).returning({ id: categories.id }); 1021 + 1022 + categoryId = cat.id.toString(); 1023 + 1024 + mockUser = { did: "did:plc:test-admin" }; 1025 + mockPutRecord.mockClear(); 1026 + mockDeleteRecord.mockClear(); 1027 + mockPutRecord.mockResolvedValue({ data: { uri: `at://${ctx.config.forumDid}/space.atbb.forum.category/tid-test-cat`, cid: "bafynewcid" } }); 1028 + }); 1029 + 1030 + it("updates category name → 200 and putRecord called with same rkey", async () => { 1031 + const res = await app.request(`/api/admin/categories/${categoryId}`, { 1032 + method: "PUT", 1033 + headers: { "Content-Type": "application/json" }, 1034 + body: JSON.stringify({ name: "Updated Name", description: "New desc", sortOrder: 2 }), 1035 + }); 1036 + 1037 + expect(res.status).toBe(200); 1038 + const data = await res.json(); 1039 + expect(data.uri).toContain("/space.atbb.forum.category/"); 1040 + expect(data.cid).toBe("bafynewcid"); 1041 + expect(mockPutRecord).toHaveBeenCalledWith( 1042 + expect.objectContaining({ 1043 + repo: ctx.config.forumDid, 1044 + collection: "space.atbb.forum.category", 1045 + rkey: "tid-test-cat", 1046 + record: expect.objectContaining({ 1047 + $type: "space.atbb.forum.category", 1048 + name: "Updated Name", 1049 + description: "New desc", 1050 + sortOrder: 2, 1051 + }), 1052 + }) 1053 + ); 1054 + }); 1055 + 1056 + it("preserves original createdAt, description, and sortOrder when not provided", async () => { 1057 + // category was created with description: "Original description", sortOrder: 1 1058 + const res = await app.request(`/api/admin/categories/${categoryId}`, { 1059 + method: "PUT", 1060 + headers: { "Content-Type": "application/json" }, 1061 + body: JSON.stringify({ name: "Updated Name" }), 1062 + }); 1063 + 1064 + expect(res.status).toBe(200); 1065 + expect(mockPutRecord).toHaveBeenCalledWith( 1066 + expect.objectContaining({ 1067 + record: expect.objectContaining({ 1068 + createdAt: "2026-01-01T00:00:00.000Z", 1069 + description: "Original description", 1070 + sortOrder: 1, 1071 + }), 1072 + }) 1073 + ); 1074 + }); 1075 + 1076 + it("returns 400 when name is missing", async () => { 1077 + const res = await app.request(`/api/admin/categories/${categoryId}`, { 1078 + method: "PUT", 1079 + headers: { "Content-Type": "application/json" }, 1080 + body: JSON.stringify({ description: "No name" }), 1081 + }); 1082 + 1083 + expect(res.status).toBe(400); 1084 + const data = await res.json(); 1085 + expect(data.error).toContain("name"); 1086 + expect(mockPutRecord).not.toHaveBeenCalled(); 1087 + }); 1088 + 1089 + it("returns 400 when name is whitespace-only", async () => { 1090 + const res = await app.request(`/api/admin/categories/${categoryId}`, { 1091 + method: "PUT", 1092 + headers: { "Content-Type": "application/json" }, 1093 + body: JSON.stringify({ name: " " }), 1094 + }); 1095 + 1096 + expect(res.status).toBe(400); 1097 + expect(mockPutRecord).not.toHaveBeenCalled(); 1098 + }); 1099 + 1100 + it("returns 400 for malformed JSON", async () => { 1101 + const res = await app.request(`/api/admin/categories/${categoryId}`, { 1102 + method: "PUT", 1103 + headers: { "Content-Type": "application/json" }, 1104 + body: "{ bad json }", 1105 + }); 1106 + 1107 + expect(res.status).toBe(400); 1108 + const data = await res.json(); 1109 + expect(data.error).toContain("Invalid JSON"); 1110 + expect(mockPutRecord).not.toHaveBeenCalled(); 1111 + }); 1112 + 1113 + it("returns 400 for invalid category ID format", async () => { 1114 + const res = await app.request("/api/admin/categories/not-a-number", { 1115 + method: "PUT", 1116 + headers: { "Content-Type": "application/json" }, 1117 + body: JSON.stringify({ name: "Test" }), 1118 + }); 1119 + 1120 + expect(res.status).toBe(400); 1121 + const data = await res.json(); 1122 + expect(data.error).toContain("Invalid category ID"); 1123 + expect(mockPutRecord).not.toHaveBeenCalled(); 1124 + }); 1125 + 1126 + it("returns 404 when category not found", async () => { 1127 + const res = await app.request("/api/admin/categories/99999", { 1128 + method: "PUT", 1129 + headers: { "Content-Type": "application/json" }, 1130 + body: JSON.stringify({ name: "Test" }), 1131 + }); 1132 + 1133 + expect(res.status).toBe(404); 1134 + const data = await res.json(); 1135 + expect(data.error).toContain("Category not found"); 1136 + expect(mockPutRecord).not.toHaveBeenCalled(); 1137 + }); 1138 + 1139 + it("returns 401 when unauthenticated", async () => { 1140 + mockUser = null; 1141 + 1142 + const res = await app.request(`/api/admin/categories/${categoryId}`, { 1143 + method: "PUT", 1144 + headers: { "Content-Type": "application/json" }, 1145 + body: JSON.stringify({ name: "Test" }), 1146 + }); 1147 + 1148 + expect(res.status).toBe(401); 1149 + expect(mockPutRecord).not.toHaveBeenCalled(); 1150 + }); 1151 + 1152 + it("returns 503 when PDS network error", async () => { 1153 + mockPutRecord.mockRejectedValue(new Error("fetch failed")); 1154 + 1155 + const res = await app.request(`/api/admin/categories/${categoryId}`, { 1156 + method: "PUT", 1157 + headers: { "Content-Type": "application/json" }, 1158 + body: JSON.stringify({ name: "Test" }), 1159 + }); 1160 + 1161 + expect(res.status).toBe(503); 1162 + const data = await res.json(); 1163 + expect(data.error).toContain("Unable to reach external service"); 1164 + expect(mockPutRecord).toHaveBeenCalled(); 1165 + }); 1166 + 1167 + it("returns 500 when ForumAgent unavailable", async () => { 1168 + ctx.forumAgent = null; 1169 + 1170 + const res = await app.request(`/api/admin/categories/${categoryId}`, { 1171 + method: "PUT", 1172 + headers: { "Content-Type": "application/json" }, 1173 + body: JSON.stringify({ name: "Test" }), 1174 + }); 1175 + 1176 + expect(res.status).toBe(500); 1177 + const data = await res.json(); 1178 + expect(data.error).toContain("Forum agent not available"); 1179 + }); 1180 + 1181 + it("returns 503 when ForumAgent not authenticated", async () => { 1182 + const originalAgent = ctx.forumAgent; 1183 + ctx.forumAgent = { getAgent: () => null } as any; 1184 + 1185 + const res = await app.request(`/api/admin/categories/${categoryId}`, { 1186 + method: "PUT", 1187 + headers: { "Content-Type": "application/json" }, 1188 + body: JSON.stringify({ name: "Test" }), 1189 + }); 1190 + 1191 + expect(res.status).toBe(503); 1192 + const data = await res.json(); 1193 + expect(data.error).toBe("Forum agent not authenticated. Please try again later."); 1194 + expect(mockPutRecord).not.toHaveBeenCalled(); 1195 + 1196 + ctx.forumAgent = originalAgent; 1197 + }); 1198 + 1199 + it("returns 503 when category lookup query fails", async () => { 1200 + const dbSelectSpy = vi.spyOn(ctx.db, "select").mockImplementationOnce(() => { 1201 + throw new Error("Database connection lost"); 1202 + }); 1203 + 1204 + const res = await app.request(`/api/admin/categories/${categoryId}`, { 1205 + method: "PUT", 1206 + headers: { "Content-Type": "application/json" }, 1207 + body: JSON.stringify({ name: "Test" }), 1208 + }); 1209 + 1210 + expect(res.status).toBe(503); 1211 + const data = await res.json(); 1212 + expect(data.error).toBe("Database temporarily unavailable. Please try again later."); 1213 + expect(mockPutRecord).not.toHaveBeenCalled(); 1214 + 1215 + dbSelectSpy.mockRestore(); 1216 + }); 1217 + 1218 + it("returns 403 when user lacks manageCategories permission", async () => { 1219 + const { requirePermission } = await import("../../middleware/permissions.js"); 1220 + const mockRequirePermission = requirePermission as any; 1221 + mockRequirePermission.mockImplementation(() => async (c: any) => { 1222 + return c.json({ error: "Forbidden" }, 403); 1223 + }); 1224 + 1225 + const testApp = new Hono<{ Variables: Variables }>().route("/api/admin", createAdminRoutes(ctx)); 1226 + const res = await testApp.request(`/api/admin/categories/${categoryId}`, { 1227 + method: "PUT", 1228 + headers: { "Content-Type": "application/json" }, 1229 + body: JSON.stringify({ name: "Test" }), 1230 + }); 1231 + 1232 + expect(res.status).toBe(403); 1233 + expect(mockPutRecord).not.toHaveBeenCalled(); 1234 + 1235 + mockRequirePermission.mockImplementation(() => async (_c: any, next: any) => { 1236 + await next(); 1237 + }); 1238 + }); 1239 + }); 1240 + 1241 + describe.sequential("DELETE /api/admin/categories/:id", () => { 1242 + let categoryId: string; 1243 + 1244 + beforeEach(async () => { 1245 + await ctx.cleanDatabase(); 1246 + 1247 + await ctx.db.insert(forums).values({ 1248 + did: ctx.config.forumDid, 1249 + rkey: "self", 1250 + cid: "bafytest", 1251 + name: "Test Forum", 1252 + description: "A test forum", 1253 + indexedAt: new Date(), 1254 + }); 1255 + 1256 + const [cat] = await ctx.db.insert(categories).values({ 1257 + did: ctx.config.forumDid, 1258 + rkey: "tid-test-del", 1259 + cid: "bafycat", 1260 + name: "Delete Me", 1261 + description: null, 1262 + sortOrder: 1, 1263 + createdAt: new Date(), 1264 + indexedAt: new Date(), 1265 + }).returning({ id: categories.id }); 1266 + 1267 + categoryId = cat.id.toString(); 1268 + 1269 + mockUser = { did: "did:plc:test-admin" }; 1270 + mockDeleteRecord.mockClear(); 1271 + mockDeleteRecord.mockResolvedValue({}); 1272 + }); 1273 + 1274 + it("deletes empty category → 200 and deleteRecord called", async () => { 1275 + const res = await app.request(`/api/admin/categories/${categoryId}`, { 1276 + method: "DELETE", 1277 + }); 1278 + 1279 + expect(res.status).toBe(200); 1280 + const data = await res.json(); 1281 + expect(data.success).toBe(true); 1282 + expect(mockDeleteRecord).toHaveBeenCalledWith({ 1283 + repo: ctx.config.forumDid, 1284 + collection: "space.atbb.forum.category", 1285 + rkey: "tid-test-del", 1286 + }); 1287 + }); 1288 + 1289 + it("returns 409 when category has boards → deleteRecord NOT called", async () => { 1290 + await ctx.db.insert(boards).values({ 1291 + did: ctx.config.forumDid, 1292 + rkey: "tid-board-1", 1293 + cid: "bafyboard", 1294 + name: "Blocked Board", 1295 + categoryId: BigInt(categoryId), 1296 + categoryUri: `at://${ctx.config.forumDid}/space.atbb.forum.category/tid-test-del`, 1297 + createdAt: new Date(), 1298 + indexedAt: new Date(), 1299 + }); 1300 + 1301 + const res = await app.request(`/api/admin/categories/${categoryId}`, { 1302 + method: "DELETE", 1303 + }); 1304 + 1305 + expect(res.status).toBe(409); 1306 + const data = await res.json(); 1307 + expect(data.error).toContain("boards"); 1308 + expect(mockDeleteRecord).not.toHaveBeenCalled(); 1309 + }); 1310 + 1311 + it("returns 400 for invalid category ID", async () => { 1312 + const res = await app.request("/api/admin/categories/not-a-number", { 1313 + method: "DELETE", 1314 + }); 1315 + 1316 + expect(res.status).toBe(400); 1317 + const data = await res.json(); 1318 + expect(data.error).toContain("Invalid category ID"); 1319 + expect(mockDeleteRecord).not.toHaveBeenCalled(); 1320 + }); 1321 + 1322 + it("returns 404 when category not found", async () => { 1323 + const res = await app.request("/api/admin/categories/99999", { 1324 + method: "DELETE", 1325 + }); 1326 + 1327 + expect(res.status).toBe(404); 1328 + const data = await res.json(); 1329 + expect(data.error).toContain("Category not found"); 1330 + expect(mockDeleteRecord).not.toHaveBeenCalled(); 1331 + }); 1332 + 1333 + it("returns 401 when unauthenticated", async () => { 1334 + mockUser = null; 1335 + 1336 + const res = await app.request(`/api/admin/categories/${categoryId}`, { 1337 + method: "DELETE", 1338 + }); 1339 + 1340 + expect(res.status).toBe(401); 1341 + expect(mockDeleteRecord).not.toHaveBeenCalled(); 1342 + }); 1343 + 1344 + it("returns 503 when PDS network error on delete", async () => { 1345 + mockDeleteRecord.mockRejectedValue(new Error("fetch failed")); 1346 + 1347 + const res = await app.request(`/api/admin/categories/${categoryId}`, { 1348 + method: "DELETE", 1349 + }); 1350 + 1351 + expect(res.status).toBe(503); 1352 + const data = await res.json(); 1353 + expect(data.error).toContain("Unable to reach external service"); 1354 + expect(mockDeleteRecord).toHaveBeenCalled(); 1355 + }); 1356 + 1357 + it("returns 500 when ForumAgent unavailable", async () => { 1358 + ctx.forumAgent = null; 1359 + 1360 + const res = await app.request(`/api/admin/categories/${categoryId}`, { 1361 + method: "DELETE", 1362 + }); 1363 + 1364 + expect(res.status).toBe(500); 1365 + const data = await res.json(); 1366 + expect(data.error).toContain("Forum agent not available"); 1367 + }); 1368 + 1369 + it("returns 503 when ForumAgent not authenticated", async () => { 1370 + const originalAgent = ctx.forumAgent; 1371 + ctx.forumAgent = { getAgent: () => null } as any; 1372 + 1373 + const res = await app.request(`/api/admin/categories/${categoryId}`, { 1374 + method: "DELETE", 1375 + }); 1376 + 1377 + expect(res.status).toBe(503); 1378 + const data = await res.json(); 1379 + expect(data.error).toBe("Forum agent not authenticated. Please try again later."); 1380 + expect(mockDeleteRecord).not.toHaveBeenCalled(); 1381 + 1382 + ctx.forumAgent = originalAgent; 1383 + }); 1384 + 1385 + it("returns 503 when category lookup query fails", async () => { 1386 + const dbSelectSpy = vi.spyOn(ctx.db, "select").mockImplementationOnce(() => { 1387 + throw new Error("Database connection lost"); 1388 + }); 1389 + 1390 + const res = await app.request(`/api/admin/categories/${categoryId}`, { 1391 + method: "DELETE", 1392 + }); 1393 + 1394 + expect(res.status).toBe(503); 1395 + const data = await res.json(); 1396 + expect(data.error).toBe("Database temporarily unavailable. Please try again later."); 1397 + expect(mockDeleteRecord).not.toHaveBeenCalled(); 1398 + 1399 + dbSelectSpy.mockRestore(); 1400 + }); 1401 + 1402 + it("returns 503 when board count query fails", async () => { 1403 + const originalSelect = ctx.db.select.bind(ctx.db); 1404 + let callCount = 0; 1405 + const dbSelectSpy = vi.spyOn(ctx.db, "select").mockImplementation((...args: any[]) => { 1406 + callCount++; 1407 + if (callCount === 1) { 1408 + // First call: category lookup — pass through to real DB 1409 + return (originalSelect as any)(...args); 1410 + } 1411 + // Second call: board count preflight — throw DB error 1412 + throw new Error("Database connection lost"); 1413 + }); 1414 + 1415 + const res = await app.request(`/api/admin/categories/${categoryId}`, { 1416 + method: "DELETE", 1417 + }); 1418 + 1419 + expect(res.status).toBe(503); 1420 + const data = await res.json(); 1421 + expect(data.error).toBe("Database temporarily unavailable. Please try again later."); 1422 + expect(mockDeleteRecord).not.toHaveBeenCalled(); 1423 + 1424 + dbSelectSpy.mockRestore(); 1425 + }); 1426 + 1427 + it("returns 403 when user lacks manageCategories permission", async () => { 1428 + const { requirePermission } = await import("../../middleware/permissions.js"); 1429 + const mockRequirePermission = requirePermission as any; 1430 + mockRequirePermission.mockImplementation(() => async (c: any) => { 1431 + return c.json({ error: "Forbidden" }, 403); 1432 + }); 1433 + 1434 + const testApp = new Hono<{ Variables: Variables }>().route("/api/admin", createAdminRoutes(ctx)); 1435 + const res = await testApp.request(`/api/admin/categories/${categoryId}`, { 1436 + method: "DELETE", 1437 + }); 1438 + 1439 + expect(res.status).toBe(403); 1440 + expect(mockDeleteRecord).not.toHaveBeenCalled(); 1441 + 1442 + mockRequirePermission.mockImplementation(() => async (_c: any, next: any) => { 1443 + await next(); 1444 + }); 818 1445 }); 819 1446 }); 820 1447
+217 -1
apps/appview/src/routes/admin.ts
··· 3 3 import type { Variables } from "../types.js"; 4 4 import { requireAuth } from "../middleware/auth.js"; 5 5 import { requirePermission, getUserRole } from "../middleware/permissions.js"; 6 - import { memberships, roles, rolePermissions, users, forums, backfillProgress, backfillErrors } from "@atbb/db"; 6 + import { memberships, roles, rolePermissions, users, forums, backfillProgress, backfillErrors, categories, boards } from "@atbb/db"; 7 7 import { eq, and, sql, asc, count } from "drizzle-orm"; 8 8 import { isProgrammingError } from "../lib/errors.js"; 9 9 import { BackfillStatus } from "../lib/backfill-manager.js"; ··· 13 13 safeParseJsonBody, 14 14 getForumAgentOrError, 15 15 } from "../lib/route-errors.js"; 16 + import { TID } from "@atproto/common-web"; 17 + import { parseBigIntParam } from "./helpers.js"; 16 18 17 19 export function createAdminRoutes(ctx: AppContext) { 18 20 const app = new Hono<{ Variables: Variables }>(); ··· 478 480 operation: "GET /api/admin/backfill/:id/errors", 479 481 logger: ctx.logger, 480 482 id, 483 + }); 484 + } 485 + } 486 + ); 487 + 488 + /** 489 + * POST /api/admin/categories 490 + * 491 + * Create a new forum category. Writes space.atbb.forum.category to Forum DID's PDS. 492 + * The firehose indexer creates the DB row asynchronously. 493 + */ 494 + app.post( 495 + "/categories", 496 + requireAuth(ctx), 497 + requirePermission(ctx, "space.atbb.permission.manageCategories"), 498 + async (c) => { 499 + const { body, error: parseError } = await safeParseJsonBody(c); 500 + if (parseError) return parseError; 501 + 502 + const { name, description, sortOrder } = body; 503 + 504 + if (typeof name !== "string" || name.trim().length === 0) { 505 + return c.json({ error: "name is required and must be a non-empty string" }, 400); 506 + } 507 + 508 + const { agent, error: agentError } = getForumAgentOrError(ctx, c, "POST /api/admin/categories"); 509 + if (agentError) return agentError; 510 + 511 + const rkey = TID.nextStr(); 512 + const now = new Date().toISOString(); 513 + 514 + try { 515 + const result = await agent.com.atproto.repo.putRecord({ 516 + repo: ctx.config.forumDid, 517 + collection: "space.atbb.forum.category", 518 + rkey, 519 + record: { 520 + $type: "space.atbb.forum.category", 521 + name: name.trim(), 522 + ...(typeof description === "string" && { description: description.trim() }), 523 + ...(Number.isInteger(sortOrder) && sortOrder >= 0 && { sortOrder }), 524 + createdAt: now, 525 + }, 526 + }); 527 + 528 + return c.json({ uri: result.data.uri, cid: result.data.cid }, 201); 529 + } catch (error) { 530 + return handleRouteError(c, error, "Failed to create category", { 531 + operation: "POST /api/admin/categories", 532 + logger: ctx.logger, 533 + }); 534 + } 535 + } 536 + ); 537 + 538 + /** 539 + * PUT /api/admin/categories/:id 540 + * 541 + * Update an existing category. Fetches existing rkey from DB, calls putRecord 542 + * with updated fields preserving the original createdAt. 543 + * The firehose indexer updates the DB row asynchronously. 544 + */ 545 + app.put( 546 + "/categories/:id", 547 + requireAuth(ctx), 548 + requirePermission(ctx, "space.atbb.permission.manageCategories"), 549 + async (c) => { 550 + const idParam = c.req.param("id"); 551 + const id = parseBigIntParam(idParam); 552 + if (id === null) { 553 + return c.json({ error: "Invalid category ID" }, 400); 554 + } 555 + 556 + const { body, error: parseError } = await safeParseJsonBody(c); 557 + if (parseError) return parseError; 558 + 559 + const { name, description, sortOrder } = body; 560 + 561 + if (typeof name !== "string" || name.trim().length === 0) { 562 + return c.json({ error: "name is required and must be a non-empty string" }, 400); 563 + } 564 + 565 + let category: typeof categories.$inferSelect; 566 + try { 567 + const [row] = await ctx.db 568 + .select() 569 + .from(categories) 570 + .where(and(eq(categories.id, id), eq(categories.did, ctx.config.forumDid))) 571 + .limit(1); 572 + 573 + if (!row) { 574 + return c.json({ error: "Category not found" }, 404); 575 + } 576 + category = row; 577 + } catch (error) { 578 + return handleRouteError(c, error, "Failed to look up category", { 579 + operation: "PUT /api/admin/categories/:id", 580 + logger: ctx.logger, 581 + id: idParam, 582 + }); 583 + } 584 + 585 + const { agent, error: agentError } = getForumAgentOrError(ctx, c, "PUT /api/admin/categories/:id"); 586 + if (agentError) return agentError; 587 + 588 + // putRecord is a full replacement — fall back to existing values for 589 + // optional fields not provided in the request body, to avoid data loss. 590 + const resolvedDescription = typeof description === "string" 591 + ? description.trim() 592 + : category.description; 593 + const resolvedSortOrder = (Number.isInteger(sortOrder) && sortOrder >= 0) 594 + ? sortOrder 595 + : category.sortOrder; 596 + 597 + try { 598 + const result = await agent.com.atproto.repo.putRecord({ 599 + repo: ctx.config.forumDid, 600 + collection: "space.atbb.forum.category", 601 + rkey: category.rkey, 602 + record: { 603 + $type: "space.atbb.forum.category", 604 + name: name.trim(), 605 + ...(resolvedDescription != null && { description: resolvedDescription }), 606 + ...(resolvedSortOrder != null && { sortOrder: resolvedSortOrder }), 607 + createdAt: category.createdAt.toISOString(), 608 + }, 609 + }); 610 + 611 + return c.json({ uri: result.data.uri, cid: result.data.cid }); 612 + } catch (error) { 613 + return handleRouteError(c, error, "Failed to update category", { 614 + operation: "PUT /api/admin/categories/:id", 615 + logger: ctx.logger, 616 + id: idParam, 617 + }); 618 + } 619 + } 620 + ); 621 + 622 + /** 623 + * DELETE /api/admin/categories/:id 624 + * 625 + * Delete a category. Pre-flight: refuses with 409 if any boards reference this 626 + * category in the DB. If clear, calls deleteRecord on the Forum DID's PDS. 627 + * The firehose indexer removes the DB row asynchronously. 628 + */ 629 + app.delete( 630 + "/categories/:id", 631 + requireAuth(ctx), 632 + requirePermission(ctx, "space.atbb.permission.manageCategories"), 633 + async (c) => { 634 + const idParam = c.req.param("id"); 635 + const id = parseBigIntParam(idParam); 636 + if (id === null) { 637 + return c.json({ error: "Invalid category ID" }, 400); 638 + } 639 + 640 + let category: typeof categories.$inferSelect; 641 + try { 642 + const [row] = await ctx.db 643 + .select() 644 + .from(categories) 645 + .where(and(eq(categories.id, id), eq(categories.did, ctx.config.forumDid))) 646 + .limit(1); 647 + 648 + if (!row) { 649 + return c.json({ error: "Category not found" }, 404); 650 + } 651 + category = row; 652 + } catch (error) { 653 + return handleRouteError(c, error, "Failed to look up category", { 654 + operation: "DELETE /api/admin/categories/:id", 655 + logger: ctx.logger, 656 + id: idParam, 657 + }); 658 + } 659 + 660 + // Pre-flight: refuse if any boards reference this category 661 + try { 662 + const [boardCount] = await ctx.db 663 + .select({ count: count() }) 664 + .from(boards) 665 + .where(eq(boards.categoryId, id)); 666 + 667 + if (boardCount && boardCount.count > 0) { 668 + return c.json( 669 + { error: "Cannot delete category with boards. Remove all boards first." }, 670 + 409 671 + ); 672 + } 673 + } catch (error) { 674 + return handleRouteError(c, error, "Failed to check category boards", { 675 + operation: "DELETE /api/admin/categories/:id", 676 + logger: ctx.logger, 677 + id: idParam, 678 + }); 679 + } 680 + 681 + const { agent, error: agentError } = getForumAgentOrError(ctx, c, "DELETE /api/admin/categories/:id"); 682 + if (agentError) return agentError; 683 + 684 + try { 685 + await agent.com.atproto.repo.deleteRecord({ 686 + repo: ctx.config.forumDid, 687 + collection: "space.atbb.forum.category", 688 + rkey: category.rkey, 689 + }); 690 + 691 + return c.json({ success: true }); 692 + } catch (error) { 693 + return handleRouteError(c, error, "Failed to delete category", { 694 + operation: "DELETE /api/admin/categories/:id", 695 + logger: ctx.logger, 696 + id: idParam, 481 697 }); 482 698 } 483 699 }
+48
bruno/AppView API/Admin/Create Category.bru
··· 1 + meta { 2 + name: Create Category 3 + type: http 4 + seq: 10 5 + } 6 + 7 + post { 8 + url: {{appview_url}}/api/admin/categories 9 + } 10 + 11 + body:json { 12 + { 13 + "name": "General Discussion", 14 + "description": "Talk about anything.", 15 + "sortOrder": 1 16 + } 17 + } 18 + 19 + assert { 20 + res.status: eq 201 21 + res.body.uri: isDefined 22 + res.body.cid: isDefined 23 + } 24 + 25 + docs { 26 + Create a new forum category. Writes space.atbb.forum.category to the Forum DID's PDS. 27 + The firehose indexer creates the DB row asynchronously. 28 + 29 + **Requires:** space.atbb.permission.manageCategories 30 + 31 + Body: 32 + - name (required): Category display name 33 + - description (optional): Short description 34 + - sortOrder (optional): Numeric sort position (lower = first) 35 + 36 + Returns (201): 37 + { 38 + "uri": "at://did:plc:.../space.atbb.forum.category/abc123", 39 + "cid": "bafyrei..." 40 + } 41 + 42 + Error codes: 43 + - 400: Missing or empty name, malformed JSON 44 + - 401: Not authenticated 45 + - 403: Missing manageCategories permission 46 + - 500: ForumAgent not configured 47 + - 503: PDS network error 48 + }
+43
bruno/AppView API/Admin/Delete Category.bru
··· 1 + meta { 2 + name: Delete Category 3 + type: http 4 + seq: 12 5 + } 6 + 7 + delete { 8 + url: {{appview_url}}/api/admin/categories/:id 9 + } 10 + 11 + params:path { 12 + id: {{category_id}} 13 + } 14 + 15 + assert { 16 + res.status: eq 200 17 + res.body.success: isTrue 18 + } 19 + 20 + docs { 21 + Delete a forum category. Pre-flight check refuses with 409 if any boards reference 22 + this category. If clear, calls deleteRecord on the Forum DID's PDS. 23 + The firehose indexer removes the DB row asynchronously. 24 + 25 + **Requires:** space.atbb.permission.manageCategories 26 + 27 + Path params: 28 + - id: Category database ID (bigint as string) 29 + 30 + Returns (200): 31 + { 32 + "success": true 33 + } 34 + 35 + Error codes: 36 + - 400: Invalid ID format 37 + - 401: Not authenticated 38 + - 403: Missing manageCategories permission 39 + - 404: Category not found 40 + - 409: Category has boards — remove them first 41 + - 500: ForumAgent not configured 42 + - 503: PDS network error 43 + }
+56
bruno/AppView API/Admin/Update Category.bru
··· 1 + meta { 2 + name: Update Category 3 + type: http 4 + seq: 11 5 + } 6 + 7 + put { 8 + url: {{appview_url}}/api/admin/categories/:id 9 + } 10 + 11 + params:path { 12 + id: {{category_id}} 13 + } 14 + 15 + body:json { 16 + { 17 + "name": "Updated Name", 18 + "description": "Updated description.", 19 + "sortOrder": 2 20 + } 21 + } 22 + 23 + assert { 24 + res.status: eq 200 25 + res.body.uri: isDefined 26 + res.body.cid: isDefined 27 + } 28 + 29 + docs { 30 + Update an existing forum category. Fetches existing rkey from DB, calls putRecord 31 + with updated fields preserving the original createdAt. 32 + 33 + **Requires:** space.atbb.permission.manageCategories 34 + 35 + Path params: 36 + - id: Category database ID (bigint as string) 37 + 38 + Body: 39 + - name (required): New display name 40 + - description (optional): New description 41 + - sortOrder (optional): New sort position 42 + 43 + Returns (200): 44 + { 45 + "uri": "at://did:plc:.../space.atbb.forum.category/abc123", 46 + "cid": "bafyrei..." 47 + } 48 + 49 + Error codes: 50 + - 400: Missing name, empty name, invalid ID format, malformed JSON 51 + - 401: Not authenticated 52 + - 403: Missing manageCategories permission 53 + - 404: Category not found 54 + - 500: ForumAgent not configured 55 + - 503: PDS network error 56 + }