Harden bulk retries, parallelize guest uploads, and move guest map below gallery
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { applyBulkPhotosInputSchema, bulkPhotosInputSchema, createGalleryPhotosInputSchema } from "./index";
|
||||
|
||||
test("bulk requests require confirmation, a receipt ID, a bounded unique selection, and a known action", () => {
|
||||
const input = { eventId: crypto.randomUUID(), photoIds: [crypto.randomUUID()], action: "public", confirm: true, requestId: crypto.randomUUID() };
|
||||
expect(applyBulkPhotosInputSchema.safeParse(input).success).toBe(true);
|
||||
for (const change of [{ confirm: false }, { requestId: undefined }, { photoIds: [] }, { photoIds: [input.photoIds[0], input.photoIds[0]] }, { photoIds: Array.from({ length: 101 }, () => crypto.randomUUID()) }, { action: "publish-everything" }]) {
|
||||
expect(applyBulkPhotosInputSchema.safeParse({ ...input, ...change }).success).toBe(false);
|
||||
}
|
||||
expect(bulkPhotosInputSchema.safeParse({ eventId: input.eventId, photoIds: input.photoIds, action: "delete" }).success).toBe(true);
|
||||
});
|
||||
|
||||
test("organizer upload batches require bounded files and stable request IDs", () => {
|
||||
const file = { fileName: "photo.jpg", contentType: "image/jpeg", byteSize: 500 };
|
||||
const input = { eventId: crypto.randomUUID(), requestId: crypto.randomUUID(), files: [file] };
|
||||
expect(createGalleryPhotosInputSchema.safeParse(input).success).toBe(true);
|
||||
for (const change of [{ requestId: undefined }, { files: [] }, { files: Array(26).fill(file) }, { files: [{ ...file, byteSize: 0 }] }, { files: [{ ...file, contentType: "text/html" }] }]) {
|
||||
expect(createGalleryPhotosInputSchema.safeParse({ ...input, ...change }).success).toBe(false);
|
||||
}
|
||||
});
|
||||
@@ -5,4 +5,4 @@ export const bulkPhotosInputSchema = z.object({
|
||||
photoIds: z.array(z.string().uuid()).min(1).max(100).refine(ids => new Set(ids).size === ids.length, "Duplicate photo IDs"),
|
||||
action: z.enum(["public", "hidden", "private", "rejected", "delete"]),
|
||||
});
|
||||
export const applyBulkPhotosInputSchema = bulkPhotosInputSchema.extend({ confirm: z.literal(true) });
|
||||
export const applyBulkPhotosInputSchema = bulkPhotosInputSchema.extend({ confirm: z.literal(true), requestId: z.string().uuid() });
|
||||
|
||||
@@ -177,9 +177,11 @@ export const completePhotoInputSchema = z.object({
|
||||
|
||||
export const createGalleryPhotosInputSchema = z.object({
|
||||
eventId: z.string().uuid(),
|
||||
requestId: z.string().uuid(),
|
||||
files: z.array(createPhotoInputSchema.pick({ contentType: true, byteSize: true, fileName: true })).min(1).max(25),
|
||||
});
|
||||
export const completeGalleryPhotosInputSchema = z.object({ eventId: z.string().uuid(), photoIds: z.array(z.string().uuid()).min(1).max(25) });
|
||||
export const retryGalleryPhotoInputSchema = z.object({ eventId: z.string().uuid(), photoId: z.string().uuid() });
|
||||
|
||||
export const BANNER_ASPECT_RATIO = 8 / 3;
|
||||
export const bannerCropSchema = z.object({
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
CREATE TABLE "operation_receipts" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"event_id" uuid NOT NULL REFERENCES "events"("id") ON DELETE CASCADE,
|
||||
"user_id" text NOT NULL REFERENCES "user"("id") ON DELETE CASCADE,
|
||||
"request_hash" text NOT NULL,
|
||||
"result" jsonb NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
@@ -81,6 +81,7 @@
|
||||
},
|
||||
{ "idx": 11, "version": "7", "when": 1789086000000, "tag": "0011_event_signs", "breakpoints": true },
|
||||
{ "idx": 12, "version": "7", "when": 1789086100000, "tag": "0012_invite_token", "breakpoints": true },
|
||||
{ "idx": 13, "version": "7", "when": 1789164000000, "tag": "0013_assistant_tokens", "breakpoints": true }
|
||||
{ "idx": 13, "version": "7", "when": 1789164000000, "tag": "0013_assistant_tokens", "breakpoints": true },
|
||||
{ "idx": 14, "version": "7", "when": 1789164100000, "tag": "0014_operation_receipts", "breakpoints": true }
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { createReadinessProbe } from "./health";
|
||||
|
||||
test("readiness catches failures and recovers on the next query", async () => {
|
||||
let fail = true;
|
||||
const ready = createReadinessProbe(async () => { if (fail) throw new Error("offline"); });
|
||||
expect(await ready()).toBe(false);
|
||||
fail = false;
|
||||
expect(await ready()).toBe(true);
|
||||
});
|
||||
|
||||
test("timed-out concurrent probes share one query rather than exhausting the pool", async () => {
|
||||
let calls = 0;
|
||||
let resolve!: () => void;
|
||||
const ready = createReadinessProbe(() => { calls++; return new Promise<void>(done => { resolve = done; }); }, 5);
|
||||
expect(await Promise.all([ready(), ready(), ready()])).toEqual([false, false, false]);
|
||||
expect(await ready()).toBe(false);
|
||||
expect(calls).toBe(1);
|
||||
resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
@@ -2,15 +2,19 @@ import { sql } from "drizzle-orm";
|
||||
import { getDb } from "./db";
|
||||
|
||||
// Bound probes and share an in-flight query so a DB outage cannot fill the pool.
|
||||
let pending: Promise<void> | undefined;
|
||||
export async function databaseReady() {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
pending ??= getDb().execute(sql`select 1`).then(() => {}).finally(() => { pending = undefined; });
|
||||
await Promise.race([pending, new Promise<never>((_, reject) => {
|
||||
timer = setTimeout(() => reject(new Error("Database probe timed out")), 2500);
|
||||
})]);
|
||||
return true;
|
||||
} catch { return false; }
|
||||
finally { clearTimeout(timer); }
|
||||
export function createReadinessProbe(query: () => Promise<unknown>, timeoutMs = 2500) {
|
||||
let pending: Promise<void> | undefined;
|
||||
return async function ready() {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
pending ??= Promise.resolve().then(query).then(() => {}).finally(() => { pending = undefined; });
|
||||
await Promise.race([pending, new Promise<never>((_, reject) => {
|
||||
timer = setTimeout(() => reject(new Error("Database probe timed out")), timeoutMs);
|
||||
})]);
|
||||
return true;
|
||||
} catch { return false; }
|
||||
finally { clearTimeout(timer); }
|
||||
};
|
||||
}
|
||||
|
||||
export const databaseReady = createReadinessProbe(() => getDb().execute(sql`select 1`));
|
||||
|
||||
@@ -16,6 +16,16 @@ import { sql } from "drizzle-orm";
|
||||
import { relations } from "drizzle-orm";
|
||||
import { user } from "./auth-schema";
|
||||
|
||||
// Durable receipts are kept with their owning event; no upload URLs or file contents.
|
||||
export const operationReceipts = pgTable("operation_receipts", {
|
||||
id: text("id").primaryKey(),
|
||||
eventId: uuid("event_id").notNull().references(() => events.id, { onDelete: "cascade" }),
|
||||
userId: text("user_id").notNull().references(() => user.id, { onDelete: "cascade" }),
|
||||
requestHash: text("request_hash").notNull(),
|
||||
result: jsonb("result").$type<unknown>().notNull(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
});
|
||||
|
||||
const timestamps = {
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.defaultNow()
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { renderAlbumReadyEmail, emailBrowserPreview } from "./index";
|
||||
import { EMAIL_LOGO_CID } from "./logo";
|
||||
|
||||
test("gallery email escapes content and includes a plain-text alternative", () => {
|
||||
const message = renderAlbumReadyEmail({ to: "test@example.test", eventTitle: '<script>alert("x")</script>', galleryUrl: "https://manyangles.test/e/demo" });
|
||||
@@ -9,8 +10,19 @@ test("gallery email escapes content and includes a plain-text alternative", () =
|
||||
expect(message.html).toContain("Manyangles");
|
||||
expect(message.text).toContain("https://manyangles.test/e/demo");
|
||||
expect(message.html).toContain("Arial,Helvetica,sans-serif");
|
||||
expect(message.html).toContain("cid:manyangles-mark-v1");
|
||||
expect(message.attachments[0]?.contentId).toBe("manyangles-mark-v1");
|
||||
expect(message.html).toContain(`cid:${EMAIL_LOGO_CID}`);
|
||||
expect(message.attachments[0]?.contentId).toBe(EMAIL_LOGO_CID);
|
||||
expect(Buffer.from(message.attachments[0]!.content, "base64").subarray(1, 4).toString()).toBe("PNG");
|
||||
expect(emailBrowserPreview(message.html)).toContain("data:image/png;base64,");
|
||||
});
|
||||
|
||||
test("logo stays inline in email and becomes a data URI only in browser previews", () => {
|
||||
const message = renderAlbumReadyEmail({ to: "test@example.test", eventTitle: "Wedding", galleryUrl: "https://manyangles.test/e/demo" });
|
||||
const preview = emailBrowserPreview(message.html);
|
||||
expect(message.attachments).toHaveLength(1);
|
||||
expect(message.html).not.toContain("data:image");
|
||||
expect(preview).not.toContain(`cid:${EMAIL_LOGO_CID}`);
|
||||
expect(preview).toContain(message.attachments[0]!.content);
|
||||
expect(emailBrowserPreview(preview)).toBe(preview);
|
||||
expect(message.html).toContain(`cid:${EMAIL_LOGO_CID}`);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user