import { expect, test } from "bun:test"; import { and, eq, inArray } from "drizzle-orm"; import { getDb, user, groups, guests, submissions, photos, events, eventMemberships } from "@album/database"; import { groupRouter } from "./api/routers/group"; import { managerRouter } from "./api/routers/manager"; import type { TrpcContext } from "./api/trpc"; import { deletePrefix, photoObjectPrefix, headObject, originalObjectKey } from "@album/storage"; import { runOnce } from "./operation-receipts"; test.skipIf(process.env.POLISH_INTEGRATION !== "1")("group ownership and event-scoped submission approval", async () => { if (!["localhost", "127.0.0.1"].includes(new URL(process.env.DATABASE_URL!).hostname)) throw new Error("Local database required"); const db = getDb(); const id = crypto.randomUUID(); const people = await db.insert(user).values([0, 1].map(n => ({ id: `${id}-${n}`, name: "Workflow test", email: `${id}-${n}@manyangles.test`, emailVerified: true }))).returning(); const ctx = (n: number): TrpcContext => ({ session: { user: people[n]!, session: {} } as TrpcContext["session"], cookies: new Map(), activeGroupId: null, requestOrigin: "http://localhost:3000", clientIdentifier: id, guestTokenForEvent: () => null, setCookies: [], appendSetCookie: () => {} }); let groupId: string | undefined; const cleanupUploads: { eventId: string; photoId: string }[] = []; try { const owner = groupRouter.createCaller(ctx(0)); const group = await owner.create({ name: "Workflow test" }); groupId = group.id; expect((await owner.get({ groupId })).role).toBe("owner"); await owner.rename({ groupId, name: "Renamed group" }); expect((await owner.get({ groupId })).name).toBe("Renamed group"); await expect(groupRouter.createCaller(ctx(1)).rename({ groupId, name: "Denied" })).rejects.toThrow(); const [event] = await db.insert(events).values({ groupId, title: "Workflow test", slug: id }).returning(); await db.insert(eventMemberships).values({ eventId: event!.id, userId: people[0]!.id, role: "owner" }); const receipt = { userId: people[0]!.id, eventId: event!.id, key: `rollback-${id}` }; await expect(runOnce(receipt, "same", async tx => { await tx.update(events).set({ title: "Rolled back" }).where(eq(events.id, event!.id)); throw new Error("simulated interruption"); })).rejects.toThrow(); expect((await db.select().from(events).where(eq(events.id, event!.id)))[0]!.title).toBe("Workflow test"); let executions = 0; const receipts = await Promise.all([0, 1, 2].map(() => runOnce(receipt, "same", async () => { executions++; return { ok: true }; }))); expect(executions).toBe(1); expect(receipts).toEqual([{ ok: true }, { ok: true }, { ok: true }]); const [guest] = await db.insert(guests).values({ eventId: event!.id, tokenHash: id }).returning(); const [submission] = await db.insert(submissions).values({ eventId: event!.id, guestId: guest!.id }).returning(); await db.insert(photos).values((["ready", "processing"] as const).map(processingStatus => ({ eventId: event!.id, submissionId: submission!.id, originalKey: `test/${id}/${processingStatus}`, contentType: "image/jpeg", processingStatus }))); const manager = managerRouter.createCaller(ctx(0)); const input = { eventId: event!.id, submissionId: submission!.id, visibility: "public" as const }; await expect(managerRouter.createCaller(ctx(1)).moderateSubmission(input)).rejects.toThrow(); await expect(manager.moderateSubmission({ ...input, eventId: crypto.randomUUID() })).rejects.toThrow(); expect((await manager.moderateSubmission(input)).updatedCount).toBe(2); expect((await manager.moderateSubmission(input)).updatedCount).toBe(0); const result = await db.select().from(photos).where(and(eq(photos.eventId, event!.id), eq(photos.submissionId, submission!.id))); expect(result.every(photo => photo.visibility === "public")).toBe(true); expect(result.find(photo => photo.processingStatus === "processing")).toBeDefined(); const [privatePhoto] = await db.insert(photos).values({ eventId: event!.id, submissionId: submission!.id, originalKey: `test/${id}/private`, contentType: "image/jpeg", processingStatus: "ready", visibility: "private" }).returning(); await db.insert(eventMemberships).values({ eventId: event!.id, userId: people[1]!.id, role: "moderator" }); const moderator = managerRouter.createCaller(ctx(1)); expect((await moderator.moderateSubmission({ ...input, visibility: "hidden" })).updatedCount).toBe(2); const [preserved] = await db.select().from(photos).where(and(eq(photos.eventId, event!.id), eq(photos.id, privatePhoto!.id))); expect(preserved!.visibility).toBe("private"); await expect(moderator.moderateSubmission({ ...input, visibility: "private" })).rejects.toThrow(); const bulk = { eventId: event!.id, photoIds: [...result.map(photo => photo.id), privatePhoto!.id], action: "public" as const, requestId: crypto.randomUUID() }; const preview = await moderator.previewBulkPhotos(bulk); expect(preview.affected).toBe(2); expect(preview.results.find(row => row.photoId === privatePhoto!.id)?.status).toBe("skipped"); await expect(moderator.bulkPhotos({ ...bulk, confirm: false as true })).rejects.toThrow(); expect((await moderator.bulkPhotos({ ...bulk, confirm: true })).affected).toBe(2); expect((await moderator.bulkPhotos({ ...bulk, confirm: true })).affected).toBe(2); await expect(moderator.bulkPhotos({ ...bulk, action: "hidden", confirm: true })).rejects.toThrow("different inputs"); // A replay cannot undo a later, intentional change. await moderator.bulkPhotos({ ...bulk, requestId: crypto.randomUUID(), action: "hidden", confirm: true }); await moderator.bulkPhotos({ ...bulk, confirm: true }); expect((await db.select().from(photos).where(and(eq(photos.eventId, event!.id), eq(photos.id, result[0]!.id))))[0]!.visibility).toBe("hidden"); await expect(moderator.bulkPhotos({ ...bulk, action: "private", confirm: true })).rejects.toThrow(); const [otherEvent] = await db.insert(events).values({ groupId, title: "Other event", slug: `${id}-other` }).returning(); await db.insert(eventMemberships).values({ eventId: otherEvent!.id, userId: people[0]!.id, role: "owner" }); const crossEvent = await manager.previewBulkPhotos({ ...bulk, eventId: otherEvent!.id }); expect(crossEvent.affected).toBe(0); const deletion = await manager.previewBulkPhotos({ ...bulk, action: "delete" }); expect(deletion.results.find(row => row.photoId === result.find(photo => photo.processingStatus === "processing")!.id)?.status).toBe("skipped"); await expect(manager.bulkPhotos({ ...bulk, photoIds: [result[0]!.id, result[0]!.id], confirm: true })).rejects.toThrow(); await expect(moderator.createGalleryPhotos({ eventId: event!.id, requestId: crypto.randomUUID(), files: [{ fileName: "test.png", contentType: "image/png", byteSize: 5 }] })).rejects.toThrow(); expect((await moderator.photos({ eventId: event!.id, limit: 1, visibility: "private" })).length).toBe(0); expect((await manager.photos({ eventId: event!.id, limit: 1 })).length).toBe(1); if (process.env.GALLERY_STORAGE_INTEGRATION === "1") { if (!["localhost", "127.0.0.1"].includes(new URL(process.env.S3_ENDPOINT!).hostname)) throw new Error("Local object storage required"); const bytes = Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aRZkAAAAASUVORK5CYII=", "base64"); const createInput = { eventId: event!.id, requestId: crypto.randomUUID(), files: [{ fileName: "test.png", contentType: "image/png" as const, byteSize: bytes.length }] }; const [uploads, replay] = await Promise.all([manager.createGalleryPhotos(createInput), manager.createGalleryPhotos(createInput)]); expect(replay.map(row => row.photoId)).toEqual(uploads.map(row => row.photoId)); for (const upload of uploads) cleanupUploads.push({ eventId: event!.id, photoId: upload.photoId }); expect((await fetch(uploads[0]!.uploadUrl!, { method: "PUT", headers: { "Content-Type": "image/png" }, body: bytes })).ok).toBe(true); expect((await manager.retryGalleryPhoto({ eventId: event!.id, photoId: uploads[0]!.photoId })).uploadUrl).toBeNull(); expect((await manager.createGalleryPhotos(createInput))[0]!.uploadUrl).toBeNull(); await expect(manager.createGalleryPhotos({ ...createInput, files: [{ ...createInput.files[0]!, byteSize: 1 }] })).rejects.toThrow("different inputs"); const batch = { eventId: event!.id, photoIds: uploads.map(upload => upload.photoId), requestId: crypto.randomUUID() }; expect((await manager.completeGalleryPhotos(batch))[0]!.status).toBe("processing"); expect((await manager.completeGalleryPhotos(batch))[0]!.status).toBe("processing"); expect((await manager.retryGalleryPhoto({ eventId: event!.id, photoId: uploads[0]!.photoId })).uploadUrl).toBeNull(); expect((await manager.previewBulkPhotos({ ...batch, action: "delete" })).affected).toBe(0); // Simulate the worker's terminal state; never modify a real upload. await db.update(photos).set({ processingStatus: "ready" }).where(and(eq(photos.eventId, event!.id), eq(photos.id, uploads[0]!.photoId))); expect((await manager.bulkPhotos({ ...batch, action: "delete", confirm: true })).affected).toBe(1); expect((await manager.bulkPhotos({ ...batch, action: "delete", confirm: true })).affected).toBe(1); expect(await headObject(originalObjectKey(event!.id, uploads[0]!.photoId))).toBeNull(); } } finally { for (const upload of cleanupUploads) await deletePrefix(photoObjectPrefix(upload.eventId, upload.photoId)); if (groupId) await db.delete(groups).where(eq(groups.id, groupId)); await db.delete(user).where(inArray(user.id, people.map(person => person.id))); } });