feat : copy youtube studio
This commit is contained in:
@@ -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" });
|
||||
|
||||
Reference in New Issue
Block a user