Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ npm-debug.log*
# Playwright MCP debug artifacts
.playwright-mcp/
.dedupe-report.json
.orphan-report.json

# Playwright e2e
.next-e2e
Expand Down
120 changes: 114 additions & 6 deletions lib/photo-upload.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,16 +43,20 @@ function existingPhoto(contentHash: string): Photo {
};
}

function fakeSupabase(options: { failUploadFor?: string[]; clashHashes?: string[]; insertErrorMessage?: string } = {}) {
function fakeSupabase(options: { failUploadFor?: string[]; existingUploadFor?: string[]; clashHashes?: string[]; legacyClashHashes?: string[]; lateClashHashes?: string[]; clashError?: string; recheckError?: string; insertErrorMessage?: string; insertThrows?: string } = {}) {
const uploaded: string[] = [];
let clashQueries = 0;
const upserted: boolean[] = [];
const removed: string[] = [];
const inserted: Array<Record<string, unknown>> = [];
const client = {
storage: {
from: () => ({
upload: async (path: string, file: File) => {
upload: async (path: string, file: File, uploadOptions?: { upsert?: boolean }) => {
if (options.failUploadFor?.some((name) => file.name.startsWith(name))) return { error: { message: "storage exploded" } };
if (options.existingUploadFor?.some((name) => file.name.startsWith(name))) return { error: { message: "The resource already exists", statusCode: "409" } };
uploaded.push(path);
upserted.push(Boolean(uploadOptions?.upsert));
return { error: null };
},
remove: async (paths: string[]) => {
Expand All @@ -64,11 +68,31 @@ function fakeSupabase(options: { failUploadFor?: string[]; clashHashes?: string[
from: () => ({
select: () => ({
eq: () => ({
in: async () => ({ data: (options.clashHashes ?? []).map((hash) => ({ content_hash: hash })) }),
in: async () => {
clashQueries += 1;
const errorMessage = clashQueries === 1 ? options.clashError : options.recheckError;
if (errorMessage) return { data: null, error: { message: errorMessage } };
const hashes = [
...(options.clashHashes ?? []),
...(options.legacyClashHashes ?? []),
// Rows a racer inserted between the clash check and the insert:
// visible only from the second (post-failure) query onward.
...(clashQueries > 1 ? options.lateClashHashes ?? [] : []),
];
return {
data: hashes.map((hash) => ({
content_hash: hash,
image_path: options.legacyClashHashes?.includes(hash) ? `lofoten-2026/legacy-${hash}.mp4` : `lofoten-2026/${hash}.mp4`,
thumbnail_path: null,
})),
error: null,
};
},
}),
}),
insert: (rows: Array<Record<string, unknown>>) => ({
select: async () => {
if (options.insertThrows) throw new Error(options.insertThrows);
if (options.insertErrorMessage) return { data: null, error: { message: options.insertErrorMessage } };
inserted.push(...rows);
const returned = rows.map((row, index) => ({ ...row, id: `row-${index}` }));
Expand All @@ -77,7 +101,7 @@ function fakeSupabase(options: { failUploadFor?: string[]; clashHashes?: string[
}),
}),
} as unknown as SupabaseClient;
return { client, uploaded, removed, inserted };
return { client, uploaded, upserted, removed, inserted };
}

const trip = { id: "trip-1", slug: "lofoten-2026" };
Expand Down Expand Up @@ -137,16 +161,100 @@ describe("uploadPhotoBatch", () => {
expect(inserted).toHaveLength(1);
});

it("removes storage objects for hashes someone else uploaded mid-batch", async () => {
// New rows are content-addressed, so a racing upload of an already-stored hash
// lands on the live row's exact key. Removing it would strand that row.
it("leaves storage alone for hashes someone else uploaded mid-batch", async () => {
const { client, removed, inserted } = fakeSupabase({ clashHashes: ["hash-a"] });
const { result } = await batch(client, [input({ clientId: "a" }), input({ clientId: "b" })]);

expect(result.failedClientIds).toEqual(["a"]);
expect(result.savedClientIds).toEqual(["b"]);
expect(removed).toHaveLength(1);
expect(removed).toEqual([]);
expect(inserted).toHaveLength(1);
});

it("removes an unused hash-path upload when the clashing row has a legacy UUID path", async () => {
const { client, removed, inserted } = fakeSupabase({ legacyClashHashes: ["hash-a"] });
const { result } = await batch(client, [input({ clientId: "a" }), input({ clientId: "b" })]);

expect(result.failedClientIds).toEqual(["a"]);
expect(result.savedClientIds).toEqual(["b"]);
expect(removed).toEqual(["lofoten-2026/hash-a.mp4"]);
expect(inserted).toHaveLength(1);
});

it("does not clean up live objects when the clash check fails", async () => {
const { client, removed } = fakeSupabase({ clashError: "clash lookup failed" });
const { result } = await batch(client, [input({ clientId: "a" })]);

expect(result.insertErrorMessage).toBe("clash lookup failed");
expect(result.failedClientIds).toEqual(["a"]);
expect(result.inserted).toBe(false);
expect(removed).toEqual([]);
});

it("keys objects by content hash without overwriting immutable objects", async () => {
const first = fakeSupabase();
await batch(first.client, [input({ clientId: "a" })]);
// Same file, second attempt: a batch that died before its insert last time.
const second = fakeSupabase();
await batch(second.client, [input({ clientId: "a" })]);

expect(first.uploaded).toEqual(["lofoten-2026/hash-a.mp4"]);
expect(second.uploaded).toEqual(first.uploaded);
expect(first.upserted).toEqual([false]);
});

it("reuses an immutable object left by an earlier unknown-outcome attempt", async () => {
const { client, inserted } = fakeSupabase({ existingUploadFor: ["a.jpg"] });
const { result } = await batch(client, [input({ clientId: "a" })]);

expect(result.savedClientIds).toEqual(["a"]);
expect(inserted).toHaveLength(1);
});

it("rolls back only the fresh objects when the insert fails", async () => {
const { client, removed } = fakeSupabase({ clashHashes: ["hash-a"], insertErrorMessage: "insert exploded" });
const { result } = await batch(client, [input({ clientId: "a" }), input({ clientId: "b" })]);

expect(result.insertErrorMessage).toBe("insert exploded");
// b's object is unreferenced and goes; a's is the existing row's own object.
expect(removed).toEqual(["lofoten-2026/hash-b.mp4"]);
expect(result.savedClientIds).toEqual([]);
});

it("spares objects a racing insert claimed when the unique index rejects the batch", async () => {
const { client, removed } = fakeSupabase({ lateClashHashes: ["hash-a"], insertErrorMessage: "unique constraint" });
const { result } = await batch(client, [input({ clientId: "a" }), input({ clientId: "b" })]);

expect(result.insertErrorMessage).toBe("unique constraint");
// hash-a gained a row mid-window, and that row references our own
// content-addressed objects; only hash-b's object is truly unreferenced.
expect(removed).toEqual(["lofoten-2026/hash-b.mp4"]);
});

it("skips rollback cleanup when the post-failure re-check fails", async () => {
const { client, removed } = fakeSupabase({ insertErrorMessage: "insert exploded", recheckError: "recheck down" });
const { result } = await batch(client, [input({ clientId: "a" })]);

expect(result.insertErrorMessage).toBe("insert exploded");
expect(removed).toEqual([]);
expect(result.warnings.some((warning) => warning.includes("recheck down"))).toBe(true);
});

it("leaves uploads in place when the insert throws", async () => {
const { client, removed, uploaded } = fakeSupabase({ insertThrows: "network died" });
const { result } = await batch(client, [input({ clientId: "a" })]);

expect(result.insertErrorMessage).toBe("network died");
expect(result.failedClientIds).toEqual(["a"]);
expect(result.inserted).toBe(false);
// Which hashes gained a row is unknown here, so blind cleanup could delete a
// live object. The content-addressed key is reused by the retry instead.
expect(uploaded).toEqual(["lofoten-2026/hash-a.mp4"]);
expect(removed).toEqual([]);
});

it("rolls back every uploaded object when the insert fails", async () => {
const { client, removed, inserted } = fakeSupabase({ insertErrorMessage: "unique constraint" });
const { result } = await batch(client, [input({ clientId: "a" }), input({ clientId: "b" })]);
Expand Down
Loading
Loading