From c9f01231c64562ef37949245da9067c96c5aeb0d Mon Sep 17 00:00:00 2001 From: gunshiz Date: Sun, 16 Aug 2026 01:18:15 +0700 Subject: [PATCH] test: verify atomic share creation --- lib/share/create-core.test.ts | 76 +++++++++++++++++++++++++++ lib/share/create-core.ts | 46 ++++++++++++++++ lib/share/create.ts | 98 +++++++++++++++-------------------- 3 files changed, 164 insertions(+), 56 deletions(-) create mode 100644 lib/share/create-core.test.ts create mode 100644 lib/share/create-core.ts diff --git a/lib/share/create-core.test.ts b/lib/share/create-core.test.ts new file mode 100644 index 0000000..99c2aea --- /dev/null +++ b/lib/share/create-core.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, test } from "bun:test"; +import { + createShareAtomically, + type CreateShareInput, + type CreateShareStore, +} from "@/lib/share/create-core"; + +const input: CreateShareInput = { + content: "exact content\n", + description: "description", + image: { + bytes: Buffer.from("image"), + name: "image.png", + type: "image/png", + size: 5, + }, + source: "bot", + author: { + userId: "user-1", + discordId: "123456789012345678", + displayName: "Example user", + avatarUrl: null, + }, +}; + +function memoryStore(failShareInsert = false) { + const images: string[] = []; + const shares: string[] = []; + const store: CreateShareStore = { + async transaction(callback) { + const nextImages = [...images]; + const nextShares = [...shares]; + const result = await callback({ + async insertImage() { + nextImages.push("image-1"); + return "image-1"; + }, + async insertShare(_input, imageCdnId) { + if (failShareInsert) throw new Error("share insert failed"); + nextShares.push("share-1"); + return { + id: "a1b2c3d4", + imageCdnId, + createdAt: new Date("2026-08-16T00:00:00.000Z"), + }; + }, + }); + images.splice(0, images.length, ...nextImages); + shares.splice(0, shares.length, ...nextShares); + return result; + }, + }; + return { images, shares, store }; +} + +describe("atomic share creation", () => { + test("stores the image and share in one transaction", async () => { + const memory = memoryStore(); + await expect(createShareAtomically(input, memory.store)).resolves.toEqual({ + id: "a1b2c3d4", + imageCdnId: "image-1", + createdAt: new Date("2026-08-16T00:00:00.000Z"), + }); + expect(memory.images).toEqual(["image-1"]); + expect(memory.shares).toEqual(["share-1"]); + }); + + test("rolls back the image when inserting the share fails", async () => { + const memory = memoryStore(true); + await expect(createShareAtomically(input, memory.store)).rejects.toThrow( + "share insert failed", + ); + expect(memory.images).toEqual([]); + expect(memory.shares).toEqual([]); + }); +}); diff --git a/lib/share/create-core.ts b/lib/share/create-core.ts new file mode 100644 index 0000000..857079f --- /dev/null +++ b/lib/share/create-core.ts @@ -0,0 +1,46 @@ +import type { ValidatedImage } from "@/lib/share/validation"; + +export type ShareAuthor = { + userId: string; + discordId: string; + displayName: string; + avatarUrl: string | null; +}; + +export type CreateShareInput = { + content: string; + description: string | null; + image: ValidatedImage | null; + source: "web" | "bot"; + author: ShareAuthor; +}; + +export type CreatedShare = { + id: string; + imageCdnId: string | null; + createdAt: Date; +}; + +export type CreateShareTransaction = { + insertImage(image: ValidatedImage, ownerDiscordId: string): Promise; + insertShare( + input: CreateShareInput, + imageCdnId: string | null, + ): Promise; +}; + +export type CreateShareStore = { + transaction(callback: (tx: CreateShareTransaction) => Promise): Promise; +}; + +export function createShareAtomically( + input: CreateShareInput, + store: CreateShareStore, +) { + return store.transaction(async (tx) => { + const imageCdnId = input.image + ? await tx.insertImage(input.image, input.author.discordId) + : null; + return tx.insertShare(input, imageCdnId); + }); +} diff --git a/lib/share/create.ts b/lib/share/create.ts index de2d179..2d56721 100644 --- a/lib/share/create.ts +++ b/lib/share/create.ts @@ -2,63 +2,49 @@ import "server-only"; import { db } from "@/db"; import { cdn, shareTexts } from "@/db/schema"; -import type { ShareSource } from "@/db/schema/share"; -import type { ValidatedImage } from "@/lib/share/validation"; +import { + createShareAtomically, + type CreateShareInput, +} from "@/lib/share/create-core"; -export type ShareAuthor = { - userId: string; - discordId: string; - displayName: string; - avatarUrl: string | null; -}; +export type { ShareAuthor } from "@/lib/share/create-core"; -export async function createShare({ - content, - description, - image, - source, - author, -}: { - content: string; - description: string | null; - image: ValidatedImage | null; - source: ShareSource; - author: ShareAuthor; -}) { - return db.transaction(async (tx) => { - let imageCdnId: string | null = null; - if (image) { - const [record] = await tx - .insert(cdn) - .values({ - data: image.bytes, - name: image.name, - type: image.type, - size: image.size, - ownerDiscordId: author.discordId, - }) - .returning({ id: cdn.id }); - imageCdnId = record.id; - } - - const [share] = await tx - .insert(shareTexts) - .values({ - content, - description, - imageCdnId, - source, - authorId: author.userId, - authorDiscordId: author.discordId, - authorName: author.displayName, - authorAvatarUrl: author.avatarUrl, - }) - .returning({ - id: shareTexts.id, - imageCdnId: shareTexts.imageCdnId, - createdAt: shareTexts.createdAt, - }); - - return share; +export async function createShare(input: CreateShareInput) { + return createShareAtomically(input, { + transaction: (callback) => db.transaction(async (tx) => callback({ + async insertImage(image, ownerDiscordId) { + const [record] = await tx + .insert(cdn) + .values({ + data: image.bytes, + name: image.name, + type: image.type, + size: image.size, + ownerDiscordId, + }) + .returning({ id: cdn.id }); + return record.id; + }, + async insertShare(share, imageCdnId) { + const [record] = await tx + .insert(shareTexts) + .values({ + content: share.content, + description: share.description, + imageCdnId, + source: share.source, + authorId: share.author.userId, + authorDiscordId: share.author.discordId, + authorName: share.author.displayName, + authorAvatarUrl: share.author.avatarUrl, + }) + .returning({ + id: shareTexts.id, + imageCdnId: shareTexts.imageCdnId, + createdAt: shareTexts.createdAt, + }); + return record; + }, + })), }); }