import { expect, test } from "bun:test"; import { and, eq } from "drizzle-orm"; import { events, getDb, groups, guests, photos, photoJobs } from "@album/database"; import { deletePrefix, getObjectBuffer, photoObjectPrefix, headObject } from "@album/storage"; import { photosRouter } from "./api/routers/photos"; import { eventRouter } from "./api/routers/event"; import { guestRouter } from "./api/routers/guest"; import type { TrpcContext } from "./api/trpc"; // Opt in against a migrated development database; creates and removes its own event. test.skipIf(process.env.PUBLISHING_INTEGRATION !== "1")("standalone notes, approval reset, hidden stats, and gallery policies", async () => { const db = getDb(); if (!["localhost", "127.0.0.1"].includes(new URL(process.env.DATABASE_URL!).hostname)) throw new Error("Local database required"); const [group] = await db.select({ id: groups.id }).from(groups).limit(1); if (!group) throw new Error("Seed a development group first"); const [event] = await db.insert(events).values({ groupId: group.id, title: "Publishing test", slug: `publishing-test-${crypto.randomUUID()}`, status: "published", uploadEnabled: false }).returning(); if (!event) throw new Error("Could not create test event"); let token: string | null = null; const ctx: TrpcContext = { session: null, requestOrigin: "http://localhost:3000", clientIdentifier: "publishing-test", cookies: new Map(), activeGroupId: null, guestTokenForEvent: () => token, setCookies: [], appendSetCookie: (value) => { token = decodeURIComponent(value.split(";")[0]!.split("=").slice(1).join("=")); } }; const guest = guestRouter.createCaller(ctx); const publicEvent = eventRouter.createCaller(ctx); let uploadedPhotoId: string | null = null; try { const result = await guest.ensure({ eventSlug: event.slug, note: "Private note without a photo" }); expect((await publicEvent.community(event.slug))).toEqual({ notes: [], stats: { photos: null, submitters: null, notes: null } }); await db.update(events).set({ notesPolicy: "automatic", showPhotoStats: true, showSubmitterStats: true, showNoteStats: true }).where(eq(events.id, event.id)); expect((await publicEvent.community(event.slug)).notes).toHaveLength(0); await guest.ensure({ eventSlug: event.slug, note: "New automatic note" }); const automatic = await publicEvent.community(event.slug); expect(automatic.notes).toHaveLength(1); expect(automatic.stats).toEqual({ photos: 0, submitters: 1, notes: 1 }); expect(Object.keys(automatic.notes[0]!)).toEqual(["id", "displayName", "note"]); await db.update(events).set({ notesPolicy: "approved" }).where(eq(events.id, event.id)); await guest.ensure({ eventSlug: event.slug, note: "Edited note requires approval" }); expect((await publicEvent.community(event.slug)).notes).toHaveLength(0); await db.update(guests).set({ noteApproved: true }).where(and(eq(guests.eventId, event.id), eq(guests.id, result.guestId))); expect((await publicEvent.community(event.slug)).notes).toHaveLength(1); const future = new Date(Date.now() + 3600000); const past = new Date(Date.now() - 3600000); await db.update(events).set({ notesVisibleAt: future, galleryVisibleAt: future, publishAt: future }).where(eq(events.id, event.id)); await expect(publicEvent.community(event.slug)).rejects.toThrow(); await db.update(events).set({ publishAt: past }).where(eq(events.id, event.id)); expect((await publicEvent.community(event.slug)).notes).toHaveLength(0); expect(await publicEvent.gallery(event.slug)).toEqual([]); await db.update(events).set({ notesVisibleAt: past, galleryVisibleAt: past, submissionsOpenAt: future }).where(eq(events.id, event.id)); expect((await publicEvent.community(event.slug)).notes).toHaveLength(1); await expect(guest.ensure({ eventSlug: event.slug, note: "Too early" })).rejects.toThrow(); await db.update(events).set({ submissionsOpenAt: past }).where(eq(events.id, event.id)); const emailOnly = guestRouter.createCaller({ ...ctx, guestTokenForEvent: () => null, appendSetCookie: () => {} }); const emailGuest = await emailOnly.ensure({ eventSlug: event.slug, email: "email-only@manyangles.test", notifyWhenReady: true }); const [saved] = await db.select({ note: guests.note, optIn: guests.notifyWhenReady }).from(guests).where(and(eq(guests.eventId, event.id), eq(guests.id, emailGuest.guestId))); expect(saved).toEqual({ note: null, optIn: true }); await db.update(events).set({ galleryVisibleAt: null }).where(eq(events.id, event.id)); await db.update(events).set({ galleryPolicy: "automatic", uploadEnabled: true }).where(eq(events.id, event.id)); const submission = await guest.startSubmission({ eventSlug: event.slug }); const photoCaller = photosRouter.createCaller(ctx); let original: Buffer | null = null; if (process.env.REAL_UPLOAD_INTEGRATION === "1") { const [demo] = await db.select({ id: events.id }).from(events).where(eq(events.slug, "demo")); const [source] = await db.select({ key: photos.originalKey }).from(photos).where(and(eq(photos.eventId, demo!.id), eq(photos.processingStatus, "ready"))).limit(1); original = await getObjectBuffer(source!.key); } const created = await photoCaller.create({ eventSlug: event.slug, submissionId: submission.submissionId, contentType: "image/jpeg", fileName: "test.jpg", byteSize: original?.length ?? 100 }); expect((await photoCaller.retryUpload({ eventId: event.id, photoId: created.photoId })).uploadUrl).toBeTruthy(); await expect(photosRouter.createCaller({ ...ctx, guestTokenForEvent: () => null }).retryUpload({ eventId: event.id, photoId: created.photoId })).rejects.toThrow("Guest session"); await expect(photoCaller.retryUpload({ eventId: crypto.randomUUID(), photoId: created.photoId })).rejects.toThrow(); await expect(photosRouter.createCaller({ ...ctx, guestTokenForEvent: () => null }).complete({ photoId: created.photoId })).rejects.toThrow("Guest session"); expect(await publicEvent.gallery(event.slug)).toEqual([]); if (original) { uploadedPhotoId = created.photoId; expect((await fetch(created.uploadUrl, { method: "PUT", headers: { "Content-Type": "image/jpeg" }, body: new Uint8Array(original) })).ok).toBe(true); await Promise.all([photoCaller.complete({ photoId: created.photoId }), photoCaller.complete({ photoId: created.photoId })]); expect((await photoCaller.retryUpload({ eventId: event.id, photoId: created.photoId })).uploadUrl).toBeNull(); expect(await db.select({ id: photoJobs.id }).from(photoJobs).where(eq(photoJobs.photoId, created.photoId))).toHaveLength(1); let ready = false; for (let attempt = 0; attempt < 45; attempt++) { const [photo] = await db.select().from(photos).where(and(eq(photos.eventId, event.id), eq(photos.id, created.photoId))); if (photo?.processingStatus === "failed") throw new Error("Image processing failed"); if (photo?.processingStatus === "ready") { expect(photo.displayKey).toBeTruthy(); expect(photo.thumbKey).toBeTruthy(); expect(Number((await headObject(photo.displayKey!))?.ContentLength)).toBeLessThan(original.length); expect(Number((await headObject(photo.thumbKey!))?.ContentLength)).toBeLessThan(original.length); ready = true; break; } await Bun.sleep(500); } expect(ready).toBe(true); } else { // Fast policy-only test; opt in above for real storage and worker processing. await db.update(photos).set({ processingStatus: "ready" }).where(and(eq(photos.eventId, event.id), eq(photos.id, created.photoId))); } expect(await publicEvent.gallery(event.slug)).toHaveLength(1); await db.update(photos).set({ visibility: "pending" }).where(and(eq(photos.eventId, event.id), eq(photos.id, created.photoId))); expect(await publicEvent.gallery(event.slug)).toEqual([]); await db.update(photos).set({ visibility: "public" }).where(and(eq(photos.eventId, event.id), eq(photos.id, created.photoId))); await db.update(events).set({ galleryPolicy: "approved" }).where(eq(events.id, event.id)); expect(await publicEvent.gallery(event.slug)).toEqual([]); await db.update(events).set({ galleryReleasedAt: new Date() }).where(eq(events.id, event.id)); expect(await publicEvent.gallery(event.slug)).toHaveLength(1); await db.update(events).set({ notesPolicy: "never", galleryPolicy: "never", galleryReleasedAt: new Date() }).where(eq(events.id, event.id)); expect((await publicEvent.community(event.slug)).notes).toHaveLength(0); expect(await publicEvent.gallery(event.slug)).toEqual([]); await db.update(events).set({ status: "closed" }).where(eq(events.id, event.id)); await expect(guest.ensure({ eventSlug: event.slug, note: "Closed" })).rejects.toThrow("Notes are closed"); await db.update(events).set({ status: "draft", publishAt: null }).where(eq(events.id, event.id)); await expect(publicEvent.community(event.slug)).rejects.toThrow(); } finally { if (uploadedPhotoId) await deletePrefix(photoObjectPrefix(event.id, uploadedPhotoId)); await db.delete(events).where(eq(events.id, event.id)); } }, 30000);