feat : copy youtube studio
CI / Verify (push) Successful in 2m8s
CI / Build immutable images and deploy (push) Successful in 2m39s

This commit is contained in:
2026-10-08 03:26:00 +07:00 Unverified
parent 9bc710c94b
commit b665fe7497
16 changed files with 196 additions and 46 deletions
+127 -1
View File
@@ -2,6 +2,7 @@ import { randomInt, randomUUID } from "node:crypto";
import { eq, inArray } from "drizzle-orm";
import sharp from "sharp";
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { after } from "next/server";
vi.mock("server-only", () => ({}));
vi.mock("@/lib/notifications/events", () => ({ notifyNotificationChange: vi.fn() }));
@@ -84,6 +85,7 @@ describeDatabase("guide comments against PostgreSQL", { timeout: 30000 }, () =>
storage.write.mockReset().mockResolvedValue(undefined); storage.delete.mockReset().mockResolvedValue(undefined);
session.get.mockResolvedValue(null);
rateLimit.mockReset().mockResolvedValue(undefined);
vi.mocked(after).mockClear();
});
afterAll(async () => {
const db = getDb();
@@ -127,6 +129,39 @@ describeDatabase("guide comments against PostgreSQL", { timeout: 30000 }, () =>
expect((await page()).items[0].replyCount).toBe(2);
await expect(publishComment(request("Bad reply", { reply: reply.id }), author, { target: `guide:${otherGuideId}` })).rejects.toMatchObject({ status: 400 });
});
it("groups the admin inbox and keeps nested admin replies attached to their recipient", async () => {
const root = await post();
const reply = await post("Reader reply", { reply: root.id });
const nested = await publishComment(request("Admin reply", { reply: reply.id }), admin, { target });
const second = await post("Second conversation");
const grouped = await listComments({ viewer: admin, inbox: true, grouped: true, target });
expect(grouped.items.map((item) => item.id)).toEqual([second.id, root.id]);
expect(grouped.items[1].replyCount).toBe(2);
const replies = await listComments({ viewer: admin, rootId: root.id });
expect(replies.items[1]).toMatchObject({ id: nested.id, rootId: root.id, replyToId: reply.id, authorAdmin: true });
expect((await listComments({ viewer: admin, inbox: true, grouped: true, target, unanswered: true })).items.map((item) => item.id)).toEqual([second.id]);
expect((await listComments({ viewer: admin, inbox: true, target })).items).toHaveLength(4);
expect((await listComments({ viewer: admin, inbox: true, grouped: true, target: `guide:${otherGuideId}` })).items).toHaveLength(0);
session.get.mockResolvedValue({ user: { id: admin.id } });
const response = await inboxRoute.GET(new Request(`https://guide.example.test/api/admin/comments?grouped=true&target=${target}`));
expect(response.status).toBe(200);
expect(response.headers.get("Cache-Control")).toBe("private, no-store");
expect((await response.json()).items.map((item: { id: string }) => item.id)).toEqual([second.id, root.id]);
});
it("finds grouped conversations by reply visibility without filtering their context", async () => {
const root = await post();
const reply = await post("Reply", { reply: root.id });
const nested = await post("Nested reply", { reply: reply.id });
await mutateComment(nested.id, admin, "moderation", true);
const hidden = await listComments({ viewer: admin, inbox: true, grouped: true, target, status: "hidden" });
expect(hidden.items.map((item) => item.id)).toEqual([root.id]);
expect(hidden.items[0].hidden).toBe(false);
expect((await listComments({ viewer: admin, rootId: root.id })).items).toHaveLength(2);
expect((await listComments({ viewer: admin, inbox: true, grouped: true, target, status: "visible" })).items.map((item) => item.id)).toEqual([root.id]);
await mutateComment(root.id, admin, "moderation", true);
expect((await listComments({ viewer: admin, inbox: true, grouped: true, target, status: "visible" })).items).toHaveLength(0);
expect((await listComments({ viewer: admin, inbox: true, grouped: true, target, status: "hidden" })).items).toHaveLength(1);
});
it("keeps Stygian schedule threads independent", async () => {
await publishComment(request("Schedule A"), author, { target: `stygian:${scheduleId}` });
expect((await listComments({ viewer: author, target: `stygian:${scheduleId + 1}` })).items).toHaveLength(0);
@@ -174,6 +209,47 @@ describeDatabase("guide comments against PostgreSQL", { timeout: 30000 }, () =>
await expect(publishComment(request("Foreign image", { version: 1, keep: [image.id] }), author, { id: otherRoot.id })).rejects.toMatchObject({ status: 400 });
await expect(mutateComment(root.id, other, "delete")).rejects.toMatchObject({ status: 403 });
});
it("edits images through PATCH while preserving retained images and revision history", async () => {
const root = await post("Original", { images: [png] });
const originalImage = (await page()).items[0].images[0];
session.get.mockResolvedValue({ user: { id: author.id } });
const context = { params: Promise.resolve({ id: root.id }) };
const edit = request("Added image", { version: 1, keep: [originalImage.id], images: [png] });
const response = await itemRoute.PATCH(new Request(edit, { method: "PATCH" }), context);
expect(await response.json()).toMatchObject({ id: root.id });
expect(response.status).toBe(200);
const updated = (await page()).items[0];
expect(updated).toMatchObject({ version: 2, text: "Added image" });
expect(updated.images).toHaveLength(2);
expect(updated.images[0]).toEqual(originalImage);
const replacement = request("", { version: 2, images: [png] });
expect((await itemRoute.PATCH(new Request(replacement, { method: "PATCH" }), context)).status).toBe(200);
expect((await page()).items[0].images).toHaveLength(1);
const history = await commentHistory(root.id, author);
expect(history.items.map((item) => item.images.length)).toEqual([1, 2, 1]);
});
it("retries temporary image-storage failures without duplicating the edit", async () => {
const root = await post("Original");
storage.write.mockRejectedValueOnce(Object.assign(new Error("Temporary storage failure"), { code: "ECONNRESET" }));
await publishComment(request("Edited", { version: 1, images: [png] }), author, { id: root.id });
expect(storage.write).toHaveBeenCalledTimes(2);
expect(storage.write.mock.calls[0][0]).toBe(storage.write.mock.calls[1][0]);
expect((await page()).items[0]).toMatchObject({ text: "Edited", version: 2 });
expect((await commentHistory(root.id, author)).items).toHaveLength(2);
expect(storage.delete).not.toHaveBeenCalled();
});
it("keeps the original revision and cleans up images when storage retries fail", async () => {
const root = await post("Original");
storage.write.mockRejectedValue(Object.assign(new Error("Storage unavailable"), { statusCode: 503 }));
session.get.mockResolvedValue({ user: { id: author.id } });
const response = await itemRoute.PATCH(request("Failed edit", { version: 1, images: [png] }), { params: Promise.resolve({ id: root.id }) });
expect(response.status).toBe(503);
expect(await response.json()).toEqual({ error: "comment-image-upload-failed" });
expect(storage.write).toHaveBeenCalledTimes(3);
expect(storage.delete).toHaveBeenCalledTimes(1);
expect((await page()).items[0]).toMatchObject({ text: "Original", version: 1 });
expect(after).not.toHaveBeenCalled();
});
it("blocks hidden-root replies, history, and attachments and restores access", async () => {
const root = await post("Root", { images: [png] }); const reply = await post("Reply", { reply: root.id, images: [png] });
const image = (await page()).items[0].images[0].id;
@@ -255,6 +331,10 @@ describeDatabase("guide comments against PostgreSQL", { timeout: 30000 }, () =>
const replies = await listComments({ viewer: author, rootId: roots[0].id });
expect(replies.items).toHaveLength(20);
expect((await listComments({ viewer: author, rootId: roots[0].id, cursor: replies.nextCursor })).items).toHaveLength(2);
const grouped = await listComments({ viewer: admin, inbox: true, grouped: true, target });
const groupedNext = await listComments({ viewer: admin, inbox: true, grouped: true, target, cursor: grouped.nextCursor });
expect(grouped.items).toHaveLength(20); expect(groupedNext.items).toHaveLength(3);
expect(new Set([...grouped.items, ...groupedNext.items].map((item) => item.id))).toEqual(new Set(roots.map((item) => item.id)));
}, 30000);
it("filters unanswered roots and treats an admin reply as an answer", async () => {
const root = await post();
@@ -267,7 +347,7 @@ describeDatabase("guide comments against PostgreSQL", { timeout: 30000 }, () =>
await expect(post("Fake", { images: [new Uint8Array([0, 1, 2])] })).rejects.toMatchObject({ status: 422 });
expect(storage.write).not.toHaveBeenCalled();
storage.write.mockRejectedValueOnce(new Error("storage unavailable"));
await expect(post("Failed", { images: [png] })).rejects.toThrow("storage unavailable");
await expect(post("Failed", { images: [png] })).rejects.toMatchObject({ status: 503, message: "comment-image-upload-failed" });
expect(storage.delete).toHaveBeenCalledWith(storage.write.mock.calls[0][0]);
expect((await page()).items).toHaveLength(0);
});
@@ -466,6 +546,52 @@ describeDatabase("guide comments against PostgreSQL", { timeout: 30000 }, () =>
expect(notifyNotificationChange).toHaveBeenCalledWith(admin.id);
expect((await listNotifications(adminUser)).items).toHaveLength(0);
}, 60000);
it("notifies admins for each saved edit without duplicating the same revision", async () => {
const root = await post("Original");
const adminUser = { ...admin, role: "admin", emailVerified: true };
await sendCommentPush(root.id, 1);
await readNotifications(admin.id);
session.get.mockResolvedValue({ user: { id: author.id } });
const response = await itemRoute.PATCH(request("Edited with image", { version: 1, images: [png] }), { params: Promise.resolve({ id: root.id }) });
expect(response.status).toBe(200);
expect(await response.json()).toMatchObject({ id: root.id, version: 2 });
expect(after).toHaveBeenCalledTimes(1);
const task = vi.mocked(after).mock.calls[0][0];
if (typeof task !== "function") throw new Error("Expected scheduled notification callback");
await task();
await task();
const feed = await listNotifications(adminUser);
expect(feed.unread).toBe(1);
expect(feed.items[0]).toMatchObject({ kind: "comment_edit", eventKey: `comment:${root.id}:edit:2`, body: "Edited with image", readAt: null });
await publishComment(request("Third revision", { version: 2 }), author, { id: root.id });
await sendCommentPush(root.id, 3);
expect((await listNotifications(adminUser)).unread).toBe(2);
// A delayed notification must use the revision that triggered it.
await sendCommentPush(root.id, 2);
expect((await listNotifications(adminUser)).unread).toBe(2);
});
it("notifies the direct recipient of edited replies and suppresses hidden edits", async () => {
await saveSubscription(author.id);
const root = await post("Root");
const reply = await publishComment(request("Reply", { reply: root.id }), other, { target });
await sendCommentPush(reply.id, 1);
await readNotifications(author.id);
pushTransport.sendNotification.mockClear();
await publishComment(request("Edited reply", { version: 1, images: [png] }), other, { id: reply.id });
await sendCommentPush(reply.id, 2);
expect(pushTransport.sendNotification).toHaveBeenCalledTimes(1);
expect(JSON.parse(pushTransport.sendNotification.mock.calls[0][1])).toMatchObject({ body: "Edited reply", title: "Reader แก้ไขคำตอบกลับความคิดเห็นของคุณ" });
const feed = await listNotifications({ ...author, role: "user", emailVerified: true });
expect(feed.unread).toBe(1);
expect(feed.items[0]).toMatchObject({ kind: "comment_edit", body: "Edited reply" });
expect(feed.items[0].url).toContain(`reply=${reply.id}#comment-${root.id}`);
await publishComment(request("Hidden revision", { version: 2 }), other, { id: reply.id });
await mutateComment(root.id, admin, "moderation", true);
pushTransport.sendNotification.mockClear();
await sendCommentPush(reply.id, 3);
expect(pushTransport.sendNotification).not.toHaveBeenCalled();
expect((await getDb().select().from(schema.notifications).where(eq(schema.notifications.eventKey, `comment:${reply.id}:edit:3`)))).toHaveLength(0);
});
it("pages notification history without dropping notifications sharing a timestamp", async () => {
const user = { ...author, role: "user", emailVerified: true };
for (let index = 0; index < 25; index++) await createNotifications([{ userId: author.id, url: "/" }], { eventKey: `fixture:${index}`, kind: "future", title: "Future notification", body: "Text" });