test: verify atomic share creation
This commit is contained in:
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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<string>;
|
||||
insertShare(
|
||||
input: CreateShareInput,
|
||||
imageCdnId: string | null,
|
||||
): Promise<CreatedShare>;
|
||||
};
|
||||
|
||||
export type CreateShareStore = {
|
||||
transaction<T>(callback: (tx: CreateShareTransaction) => Promise<T>): Promise<T>;
|
||||
};
|
||||
|
||||
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);
|
||||
});
|
||||
}
|
||||
+42
-56
@@ -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;
|
||||
},
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user