Skip to content
Open
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
203 changes: 203 additions & 0 deletions apps/web/__tests__/unit/comment-disabled-settings.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
import * as Db from "@cap/database/schema";
import type { MySqlTable } from "drizzle-orm/mysql-core";
import { Effect } from "effect";
import { beforeEach, describe, expect, it, vi } from "vitest";

type Row = Record<string, unknown>;

const videoId = "accessible-video";
const organizationId = "organization";

const mocks = vi.hoisted(() => ({
notify: vi.fn(async () => undefined),
state: {
videoSettings: null as Row | null,
organizationSettings: null as Row | null,
spaces: [] as Row[],
inserted: [] as Row[],
},
}));

vi.mock("@cap/database", () => ({
db: () => ({
select: () => ({
from: (table: MySqlTable) => {
const rows = () =>
table === Db.spaceVideos
? mocks.state.spaces
: [
{
id: videoId,
videoSettings: mocks.state.videoSettings,
organizationSettings: mocks.state.organizationSettings,
},
];
const query = {
leftJoin: () => query,
innerJoin: () => query,
where: () =>
Object.assign(Promise.resolve(rows()), {
limit: async () => rows(),
}),
};
return query;
},
}),
insert: () => ({
values: async (row: Row) => {
mocks.state.inserted.push(row);
},
}),
}),
}));
vi.mock("@cap/database/auth/session", () => ({
getCurrentUser: async () => ({ id: "viewer", name: "Viewer" }),
}));
vi.mock("@cap/web-domain", () => ({
Comment: { CommentId: { make: (id: string) => id } },
Policy: { withPublicPolicy: () => (effect: unknown) => effect },
}));
vi.mock("@cap/web-backend", async () => {
const { resolveEffectiveVideoRules } = await import(
"../../../../packages/web-backend/src/Videos/EffectiveVideoRules"
);
return {
provideOptionalAuth: (effect: unknown) => effect,
resolveEffectiveVideoRules,
VideosPolicy: Effect.succeed({ canView: () => Effect.void }),
};
});
vi.mock("server-only", () => ({}));
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
vi.mock("@/lib/comment-reply-parent", () => ({
isReplyParentOnVideo: async () => true,
}));
vi.mock("@/lib/org-pro", () => ({ isVideoOwnerPro: async () => true }));
vi.mock("@/lib/Notification", () => ({ createNotification: mocks.notify }));
vi.mock("@/lib/server", () => ({ runPromiseExit: Effect.runPromiseExit }));

import { newComment } from "@/actions/videos/new-comment";
import { canUseMediaComments } from "@/lib/media-comments";

function post(type: "text" | "emoji", parentCommentId = "") {
return newComment({
content: type === "emoji" ? "👍" : "A comment",
videoId: videoId as never,
type,
authorImage: null,
parentCommentId: parentCommentId as never,
timestamp: null,
});
}

const canRecord = () =>
canUseMediaComments(
{ id: "viewer" } as never,
{
id: videoId,
ownerId: "owner",
} as never,
);

beforeEach(() => {
mocks.state.videoSettings = null;
mocks.state.organizationSettings = null;
mocks.state.spaces = [];
mocks.state.inserted = [];
mocks.notify.mockClear();
});

describe("comment and reaction settings", () => {
it("posts when nothing is disabled", async () => {
await post("text");
await post("emoji");
expect(mocks.state.inserted).toHaveLength(2);
});

it("rejects comments and replies when the video disables comments", async () => {
mocks.state.videoSettings = { disableComments: true };

await expect(post("text")).rejects.toThrow("disabled");
await expect(post("text", "parent")).rejects.toThrow("disabled");
await expect(post("emoji", "parent")).rejects.toThrow("disabled");

expect(mocks.state.inserted).toEqual([]);
expect(mocks.notify).not.toHaveBeenCalled();
});

it("rejects reactions when the video disables reactions", async () => {
mocks.state.videoSettings = { disableReactions: true };

await expect(post("emoji")).rejects.toThrow("disabled");

expect(mocks.state.inserted).toEqual([]);
});

it("keeps comments and reactions independent", async () => {
mocks.state.videoSettings = { disableComments: true };
await post("emoji");

mocks.state.videoSettings = { disableReactions: true };
await post("text");

expect(mocks.state.inserted.map((row) => row.type)).toEqual([
"emoji",
"text",
]);
});

it("inherits the organization setting unless the video overrides it", async () => {
mocks.state.organizationSettings = { disableComments: true };
await expect(post("text")).rejects.toThrow("disabled");

mocks.state.videoSettings = { disableComments: false };
await post("text");

expect(mocks.state.inserted).toHaveLength(1);
});

it("applies a space's setting over the video's", async () => {
mocks.state.videoSettings = { disableComments: false };
mocks.state.spaces = [
{
id: "space",
name: "Space",
organizationId,
settings: { disableComments: true },
},
];

await expect(post("text")).rejects.toThrow("disabled");

expect(mocks.state.inserted).toEqual([]);
});

it("ignores the organization's own all-members space like the share page", async () => {
mocks.state.spaces = [
{
id: organizationId,
name: "Everyone",
organizationId,
settings: { disableComments: true },
},
];

await post("text");

expect(mocks.state.inserted).toHaveLength(1);
});

it("stops media comments when comments are disabled", async () => {
expect(await canRecord()).toBe(true);

mocks.state.videoSettings = { disableComments: true };

expect(await canRecord()).toBe(false);
});

it("keeps media comments available when only reactions are disabled", async () => {
mocks.state.videoSettings = { disableReactions: true };

expect(await canRecord()).toBe(true);
});
});
3 changes: 3 additions & 0 deletions apps/web/__tests__/unit/comment-reply-parent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,9 @@ vi.mock("@/lib/media-comments", () => ({
}));
vi.mock("@/lib/Notification", () => ({ createNotification: mocks.notify }));
vi.mock("@/lib/server", () => ({ runPromiseExit: Effect.runPromiseExit }));
vi.mock("@/lib/video-feedback-rules", () => ({
isVideoFeedbackDisabled: async () => false,
}));

import { newComment } from "@/actions/videos/new-comment";
import { newMediaComment } from "@/actions/videos/new-media-comment";
Expand Down
9 changes: 9 additions & 0 deletions apps/web/actions/videos/new-comment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { revalidatePath } from "next/cache";
import { isReplyParentOnVideo } from "@/lib/comment-reply-parent";
import { createNotification } from "@/lib/Notification";
import * as EffectRuntime from "@/lib/server";
import { isVideoFeedbackDisabled } from "@/lib/video-feedback-rules";

export async function newComment(data: {
content: string;
Expand Down Expand Up @@ -69,6 +70,14 @@ export async function newComment(data: {
throw new Error("Video not found");
}

if (
await isVideoFeedbackDisabled(
videoId,
conditionalType === "reaction" ? "disableReactions" : "disableComments",
)
)
throw new Error("Feedback is disabled on this video");
Comment thread
yashranaway marked this conversation as resolved.
Resolved

if (
parentCommentId &&
!(await isReplyParentOnVideo(parentCommentId, videoId))
Expand Down
6 changes: 4 additions & 2 deletions apps/web/lib/media-comments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,19 @@ import type { users } from "@cap/database/schema";
import type { User, Video } from "@cap/web-domain";
import type { InferSelectModel } from "drizzle-orm";
import { isVideoOwnerPro } from "@/lib/org-pro";
import { isVideoFeedbackDisabled } from "@/lib/video-feedback-rules";

/**
* Recording media comments (video/voice replies on the share-page timeline) is
* part of the VIDEO OWNER's Pro plan: any signed-in viewer can record on a Pro
* owner's video. Viewing/playing media comments is never gated here — that
* follows the video's normal view policy.
* owner's video, unless comments are disabled on it. Viewing/playing media
* comments is never gated here — that follows the video's normal view policy.
*/
export async function canUseMediaComments(
user: Pick<InferSelectModel<typeof users>, "id"> | null,
video: { ownerId: User.UserId; id: Video.VideoId },
): Promise<boolean> {
if (!user) return false;
if (await isVideoFeedbackDisabled(video.id, "disableComments")) return false;
return isVideoOwnerPro(video.ownerId);
}
43 changes: 43 additions & 0 deletions apps/web/lib/video-feedback-rules.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { db } from "@cap/database";
import {
organizations,
spaces,
spaceVideos,
videos,
} from "@cap/database/schema";
import { resolveEffectiveVideoRules } from "@cap/web-backend";
import type { Video } from "@cap/web-domain";
import { eq } from "drizzle-orm";

export async function isVideoFeedbackDisabled(
videoId: Video.VideoId,
setting: "disableComments" | "disableReactions",
) {
const [[video], sharedSpaces] = await Promise.all([
db()
.select({
videoSettings: videos.settings,
organizationSettings: organizations.settings,
})
.from(videos)
.leftJoin(organizations, eq(organizations.id, videos.orgId))
.where(eq(videos.id, videoId))
.limit(1),
db()
.select({
id: spaces.id,
name: spaces.name,
organizationId: spaces.organizationId,
settings: spaces.settings,
})
.from(spaceVideos)
.innerJoin(spaces, eq(spaceVideos.spaceId, spaces.id))
.where(eq(spaceVideos.videoId, videoId)),
]);

return resolveEffectiveVideoRules({
videoSettings: video?.videoSettings,
organizationSettings: video?.organizationSettings,
spaces: sharedSpaces.filter((space) => space.id !== space.organizationId),
}).settings[setting];
}
Loading