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
30 changes: 30 additions & 0 deletions apps/web/__tests__/unit/desktop-video-create.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ const deletion = vi.hoisted(() => ({
deleteVideo: vi.fn(),
principal: vi.fn(),
}));
const defaultSharing = vi.hoisted(() => ({
getNewVideoPublic: vi.fn(),
}));

const schema = {
organizations: { table: "organizations" },
Expand Down Expand Up @@ -42,6 +45,7 @@ const mockDb = {
vi.mock("@cap/database", () => ({
db: () => mockDb,
}));
vi.mock("@cap/database/video-sharing-default", () => defaultSharing);

vi.mock("@cap/database/auth/session", () => ({
getCurrentUser: vi.fn(),
Expand Down Expand Up @@ -400,6 +404,7 @@ describe("GET /create", () => {
vi.clearAllMocks();
resetMockDb();
stubStorage();
defaultSharing.getNewVideoPublic.mockResolvedValue(true);
const mod = await import("@/app/api/desktop/[...route]/video");
app = mod.app;
});
Expand Down Expand Up @@ -477,6 +482,31 @@ describe("GET /create", () => {
});
});

it("creates a private recording when the organization default resolves to private", async () => {
defaultSharing.getNewVideoPublic.mockResolvedValue(false);
mockGetCurrentUser.mockResolvedValue({
id: "user-1",
email: "someone@cap.test",
defaultOrgId: "org-1",
activeOrganizationId: "org-1",
});
mockDb.where
.mockResolvedValueOnce([
{ id: "org-1", name: "Acme", createdAt: new Date() },
])
.mockResolvedValueOnce([])
.mockResolvedValueOnce([{ count: 5 }]);

const response = await app.request("https://cap.test/create");

expect(response.status).toBe(200);
expect(defaultSharing.getNewVideoPublic).toHaveBeenCalledWith("org-1");
expect(insertedValues(schema.videos)).toMatchObject({
orgId: "org-1",
public: false,
});
});

it("heals a dangling defaultOrgId when the user has no remaining orgs", async () => {
mockGetCurrentUser.mockResolvedValue({
id: "user-1",
Expand Down
5 changes: 5 additions & 0 deletions apps/web/__tests__/unit/loom-import.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ const headersMock = vi.hoisted(() => vi.fn());
const getOrganizationAccessMock = vi.hoisted(() => vi.fn());
const requireSpaceManagerMock = vi.hoisted(() => vi.fn());
const requireOrganizationSettingsManagerMock = vi.hoisted(() => vi.fn());
const defaultSharing = vi.hoisted(() => ({
getNewVideoPublic: vi.fn(),
}));

const mockDb = {
select: vi.fn(() => mockDb),
Expand All @@ -29,6 +32,7 @@ const mockDb = {
vi.mock("@cap/database", () => ({
db: vi.fn(() => mockDb),
}));
vi.mock("@cap/database/video-sharing-default", () => defaultSharing);

vi.mock("server-only", () => ({}));

Expand Down Expand Up @@ -214,6 +218,7 @@ function withLimit(value: unknown) {
describe("importFromLoom", () => {
beforeEach(() => {
vi.clearAllMocks();
defaultSharing.getNewVideoPublic.mockResolvedValue(true);
whereMock.mockReset();
requireSpaceManagerMock.mockReset();
requireOrganizationSettingsManagerMock.mockReset();
Expand Down
39 changes: 39 additions & 0 deletions apps/web/__tests__/unit/share-audience.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,18 @@ describe("describeShareAudience", () => {
expect(audience.tooltip).toContain("outside your organization");
});

it("identifies domain restricted link sharing separately from a private recording", () => {
const audience = describeShareAudience({
isPublic: true,
allowedEmailDomain: "route.com",
passwordProtected: false,
audienceNames: [],
});

expect(audience.label).toBe("Restricted link access");
expect(audience.tooltip).toContain("route.com or invited viewers");
});

it("says a password is required when the public link is locked", () => {
const audience = describeShareAudience({
isPublic: true,
Expand Down Expand Up @@ -99,4 +111,31 @@ describe("describeShareAudience", () => {
expect(audience.label).toBe("Only you");
expect(audience.tooltip).toContain("Click to share it");
});

it("shows invited viewers on a private recording", () => {
const audience = describeShareAudience({
isPublic: false,
passwordProtected: false,
audienceNames: [],
viewerCount: 2,
});

expect(audience.kind).toBe("people");
expect(audience.label).toBe("Shared with 2 people");
expect(audience.tooltip).toContain("invited email address");
});

it("includes invited viewers alongside a shared space", () => {
const audience = describeShareAudience({
isPublic: false,
passwordProtected: false,
audienceNames: ["Design"],
viewerCount: 1,
});

expect(audience.label).toBe("Shared with spaces and 1 person");
expect(audience.tooltip).toContain(
"members of Design and 1 invited person",
);
});
});
1 change: 1 addition & 0 deletions apps/web/__tests__/unit/sso-settings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,7 @@ function makeFixture({
metadata: null,
tombstoneAt: null,
allowedEmailDomain: null,
defaultVideoVisibility: null,
customDomain: null,
domainVerified: null,
settings: null,
Expand Down
57 changes: 57 additions & 0 deletions apps/web/__tests__/unit/video-sharing-default.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { getNewVideoPublic } from "@cap/database/video-sharing-default";
import type { Organisation } from "@cap/web-domain";
import { beforeEach, describe, expect, it, vi } from "vitest";

const lookup = vi.hoisted(() => ({
limit: vi.fn(),
serverDefaultPublic: true,
}));

vi.mock("@cap/database", () => ({
db: () => ({
select: () => ({
from: () => ({
where: () => ({ limit: lookup.limit }),
}),
}),
}),
}));

vi.mock("@cap/env", () => ({
serverEnv: () => ({
CAP_VIDEOS_DEFAULT_PUBLIC: lookup.serverDefaultPublic,
}),
}));

const ORGANIZATION_ID = "org-1" as Organisation.OrganisationId;

describe("new recording visibility", () => {
beforeEach(() => {
lookup.limit.mockReset();
lookup.serverDefaultPublic = true;
});

it("keeps the current server default for organizations that have not opted in", async () => {
lookup.limit.mockResolvedValue([
{ defaultVideoVisibility: null, tombstoneAt: null },
]);
expect(await getNewVideoPublic(ORGANIZATION_ID)).toBe(true);

lookup.serverDefaultPublic = false;
expect(await getNewVideoPublic(ORGANIZATION_ID)).toBe(false);
});

it("starts recordings private after the organization opts in", async () => {
lookup.limit.mockResolvedValue([
{ defaultVideoVisibility: "private", tombstoneAt: null },
]);
expect(await getNewVideoPublic(ORGANIZATION_ID)).toBe(false);
});

it("rejects a missing or deleted organization", async () => {
lookup.limit.mockResolvedValue([]);
await expect(getNewVideoPublic(ORGANIZATION_ID)).rejects.toThrow(
"Organization not found",
);
});
});
190 changes: 190 additions & 0 deletions apps/web/__tests__/unit/video-viewer-invites.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
import type { Video } from "@cap/web-domain";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
getVideoViewerGrants,
inviteVideoViewer,
revokeVideoViewer,
} from "@/actions/videos/viewer-invites";

const fixtures = vi.hoisted(() => ({
user: vi.fn(),
sendEmail: vi.fn(),
revalidatePath: vi.fn(),
video: { id: "video-1", ownerId: "owner-1", name: "Demo" },
grants: [] as string[],
revokedEmails: [] as string[],
inserted: vi.fn(),
updated: vi.fn(),
}));

const schema = vi.hoisted(() => ({
videos: { id: "videoId", name: "videoName", ownerId: "videoOwnerId" },
videoViewerGrants: {
videoId: "grantVideoId",
email: "grantEmail",
revokedAt: "grantRevokedAt",
},
}));

vi.mock("@cap/database", () => ({
db: () => {
let selectedTable: unknown;
return {
select: () => ({
from: (table: unknown) => {
selectedTable = table;
return {
where: () => ({
limit: async () =>
selectedTable === schema.videos
? [fixtures.video]
: fixtures.grants.map((email) => ({
email,
revokedAt: fixtures.revokedEmails.includes(email)
? new Date(0)
: null,
})),
orderBy: async () => fixtures.grants.map((email) => ({ email })),
}),
};
},
}),
insert: () => ({
values: (value: unknown) => ({
onDuplicateKeyUpdate: async () => fixtures.inserted(value),
}),
}),
update: () => ({
set: (value: unknown) => ({
where: async () => fixtures.updated(value),
}),
}),
};
},
}));

vi.mock("@cap/database/auth/session", () => ({
getCurrentUser: fixtures.user,
}));
vi.mock("@cap/database/emails/config", () => ({
sendEmail: fixtures.sendEmail,
}));
vi.mock("@cap/database/emails/video-viewer-invite", () => ({
VideoViewerInvite: () => null,
}));
vi.mock("@cap/database/helpers", () => ({
nanoId: () => "grant-1",
}));
vi.mock("@cap/database/schema", () => schema);
vi.mock("@cap/env", () => ({
serverEnv: () => ({ WEB_URL: "https://cap.test" }),
}));
vi.mock("drizzle-orm", () => ({
and: vi.fn((...parts: unknown[]) => parts),
eq: vi.fn((column: unknown, value: unknown) => ({ column, value })),
isNull: vi.fn((column: unknown) => ({ column })),
}));
vi.mock("next/cache", () => ({
revalidatePath: fixtures.revalidatePath,
}));

const VIDEO_ID = "video-1" as Video.VideoId;

describe("recording viewer invitations", () => {
beforeEach(() => {
vi.clearAllMocks();
fixtures.grants = [];
fixtures.revokedEmails = [];
fixtures.video.ownerId = "owner-1";
fixtures.user.mockResolvedValue({ id: "owner-1" });
fixtures.sendEmail.mockResolvedValue({
data: { id: "email-1" },
error: null,
});
});

it("rejects a non-owner before writing a grant or sending email", async () => {
fixtures.user.mockResolvedValue({ id: "other-1" });

await expect(
inviteVideoViewer(VIDEO_ID, "viewer@example.com"),
).rejects.toThrow("Unauthorized");
expect(fixtures.inserted).not.toHaveBeenCalled();
expect(fixtures.sendEmail).not.toHaveBeenCalled();
});

it("grants access to the normalized email and sends the recording link", async () => {
const result = await inviteVideoViewer(VIDEO_ID, " Viewer@Example.com ");

expect(result).toEqual({
success: true,
alreadyAdded: false,
emailSent: true,
});
expect(fixtures.inserted).toHaveBeenCalledWith(
expect.objectContaining({
videoId: VIDEO_ID,
email: "viewer@example.com",
}),
);
expect(fixtures.sendEmail).toHaveBeenCalledWith(
expect.objectContaining({ email: "viewer@example.com" }),
);
});

it("keeps access when email delivery is unavailable so the owner can share the link", async () => {
fixtures.sendEmail.mockResolvedValue(undefined);

const result = await inviteVideoViewer(VIDEO_ID, "viewer@example.com");

expect(result.emailSent).toBe(false);
expect(fixtures.inserted).toHaveBeenCalledOnce();
});

it("does not resend an invitation for an active grant", async () => {
fixtures.grants = ["viewer@example.com"];

const result = await inviteVideoViewer(VIDEO_ID, "Viewer@Example.com");

expect(result.alreadyAdded).toBe(true);
expect(fixtures.inserted).not.toHaveBeenCalled();
expect(fixtures.sendEmail).not.toHaveBeenCalled();
});

it("can invite an email again after its grant was revoked", async () => {
fixtures.grants = ["viewer@example.com"];
fixtures.revokedEmails = ["viewer@example.com"];

const result = await inviteVideoViewer(VIDEO_ID, "viewer@example.com");

expect(result.alreadyAdded).toBe(false);
expect(fixtures.inserted).toHaveBeenCalledOnce();
expect(fixtures.sendEmail).toHaveBeenCalledOnce();
});

it("keeps the invited viewer list and removal owner-only", async () => {
fixtures.grants = ["viewer@example.com"];
fixtures.user.mockResolvedValue({ id: "other-1" });
await expect(getVideoViewerGrants(VIDEO_ID)).rejects.toThrow(
"Unauthorized",
);
await expect(
revokeVideoViewer(VIDEO_ID, "viewer@example.com"),
).rejects.toThrow("Unauthorized");
expect(fixtures.updated).not.toHaveBeenCalled();
});

it("marks a viewer grant revoked when the owner removes it", async () => {
fixtures.grants = ["viewer@example.com"];

await expect(
revokeVideoViewer(VIDEO_ID, "Viewer@Example.com"),
).resolves.toEqual({
success: true,
});
expect(fixtures.updated).toHaveBeenCalledWith({
revokedAt: expect.any(Date),
});
expect(fixtures.revalidatePath).toHaveBeenCalledWith(`/s/${VIDEO_ID}`);
});
});
Loading
Loading