Skip to content
Merged
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
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ jobs:
__tests__/unit/playback-store.test.ts
__tests__/unit/seo-demo-videos.test.ts
__tests__/unit/signed-baa-card.test.ts
__tests__/unit/space-page-round-trips.test.ts
__tests__/unit/speed-controller.test.ts
__tests__/unit/summary-editor.test.ts
__tests__/unit/sso-login-pages.test.ts
Expand Down
268 changes: 268 additions & 0 deletions apps/web/__tests__/unit/space-page-round-trips.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,268 @@
import { Database, ImageUploads } from "@cap/web-backend";
import {
CurrentUser,
Folder,
Organisation,
Space,
User,
} from "@cap/web-domain";
import { drizzle } from "drizzle-orm/mysql-proxy";
import { Effect, Layer, Option } from "effect";
import { beforeEach, describe, expect, it, vi } from "vitest";
import SharedCapsPage from "@/app/(org)/dashboard/spaces/[spaceId]/page";
import { getVideosByFolderId } from "@/lib/folder";
import {
Database as BackendDatabase,
type DbClient,
} from "../../../../packages/web-backend/src/Database";
import { OrganisationsPolicy } from "../../../../packages/web-backend/src/Organisations/OrganisationsPolicy";
import { Spaces } from "../../../../packages/web-backend/src/Spaces";
import { SpacesPolicy } from "../../../../packages/web-backend/src/Spaces/SpacesPolicy";

const mocks = vi.hoisted(() => ({
query: vi.fn(),
spaceOrOrg: null as unknown,
}));

vi.mock("server-only", () => ({}));
vi.mock("@cap/database", async () => {
const { drizzle } = await import("drizzle-orm/mysql-proxy");
const client = drizzle(mocks.query);
return { db: () => client };
});
vi.mock("@cap/database/auth/session", () => ({
getCurrentUser: async () => ({ id: "user-1", activeOrganizationId: "org-1" }),
}));
vi.mock("@cap/env", () => ({ serverEnv: () => ({}) }));
vi.mock("@cap/web-backend", async () => {
const { drizzle } = await import("drizzle-orm/mysql-proxy");
const { Effect, Layer } = await import("effect");
const { resolveEffectiveVideoRules } = await import(
"../../../../packages/web-backend/src/Videos/EffectiveVideoRules"
);
const client = drizzle(mocks.query);
class Database extends Effect.Service<Database>()("Database", {
succeed: {
use: <T>(callback: (db: typeof client) => Promise<T>) =>
Effect.promise(() => callback(client)),
},
}) {}
class ImageUploads extends Effect.Service<ImageUploads>()("ImageUploads", {
succeed: { resolveImageUrl: (value: string) => Effect.succeed(value) },
}) {}
class Spaces extends Effect.Service<Spaces>()("Spaces", {
succeed: { getSpaceOrOrg: () => Effect.succeed(mocks.spaceOrOrg) },
}) {}
return {
Database,
ImageUploads,
Spaces,
makeCurrentUserLayer: () => Layer.empty,
resolveEffectiveVideoRules,
};
});
vi.mock("@/lib/server", async () => {
const { Database, ImageUploads, Spaces } = await import("@cap/web-backend");
return {
runPromise: <A, E>(
effect: Effect.Effect<A, E, Database | ImageUploads | Spaces>,
) =>
Effect.runPromise(
Effect.provide(
effect,
Layer.mergeAll(
Database.Default,
ImageUploads.Default,
Spaces.Default,
),
),
),
};
});
vi.mock("@/app/(org)/dashboard/spaces/[spaceId]/SharedCaps", () => ({
SharedCaps: () => null,
}));

const PAGE_SIZE = 15;
const LIST_COLUMNS = {
space: [
"owner-1",
"Walkthrough",
"2026-10-23 12:00:00",
null,
false,
60,
true,
null,
false,
0,
0,
"Owner",
"2026-10-23 12:00:00",
false,
],
folder: [
"owner-1",
"Walkthrough",
"2026-10-23 12:00:00",
true,
null,
{ type: "webMP4" },
false,
60,
null,
"org-1",
0,
0,
[],
"Owner",
"2026-10-23 12:00:00",
false,
false,
],
};

function listRows(columns: unknown[]) {
return Array.from({ length: PAGE_SIZE }, (_, index) => [
`video-${index}`,
...columns,
]);
}

// Holds every query until the page has issued all the queries it can without
// an answer, then answers them together. Each release is one database round
// trip on the page's critical path.
async function countRoundTrips(
load: () => Promise<unknown>,
columns: unknown[],
) {
const pending: Array<() => void> = [];
mocks.query.mockImplementation(
(sql: string) =>
new Promise((resolve) =>
pending.push(() =>
resolve({
rows: sql.includes(" group by ") ? listRows(columns) : [],
}),
),
),
);

let settled = false;
const result = load().finally(() => {
settled = true;
});
let roundTrips = 0;
let idleTicks = 0;
while (!settled) {
const issued = mocks.query.mock.calls.length;
await new Promise((resolve) => setTimeout(resolve, 0));
if (mocks.query.mock.calls.length !== issued || pending.length === 0) {
idleTicks = 0;
continue;
}
if (++idleTicks < 3) continue;
idleTicks = 0;
roundTrips++;
for (const release of pending.splice(0)) release();
}
await result;
return { roundTrips, queries: mocks.query.mock.calls.length };
}

beforeEach(() => {
mocks.query.mockReset();
});

describe("space and folder page round trips", () => {
it("loads a space page in two round trips", async () => {
mocks.spaceOrOrg = {
variant: "space",
space: { id: "space-1", organizationId: "org-1", iconUrl: null },
};
const result = await countRoundTrips(
() =>
SharedCapsPage({
params: Promise.resolve({ spaceId: "space-1" }),
searchParams: Promise.resolve({}),
}),
LIST_COLUMNS.space,
);
expect(result).toEqual({ roundTrips: 2, queries: 8 });
});

it("loads an organization page in two round trips", async () => {
mocks.spaceOrOrg = {
variant: "organization",
organization: { id: "org-1", name: "Org", ownerId: "owner-1" },
};
const result = await countRoundTrips(
() =>
SharedCapsPage({
params: Promise.resolve({ spaceId: "org-1" }),
searchParams: Promise.resolve({}),
}),
LIST_COLUMNS.space,
);
expect(result).toEqual({ roundTrips: 2, queries: 7 });
});

it("looks up the space and the organization together", async () => {
const client = drizzle(mocks.query) as unknown as DbClient;
const result = await countRoundTrips(
() =>
Effect.flatMap(Spaces, (spaces) =>
spaces.getSpaceOrOrg(Space.SpaceId.make("space-1")),
).pipe(
Effect.provide(Spaces.DefaultWithoutDependencies),
Effect.provideService(
BackendDatabase,
BackendDatabase.make({
use: (callback) => Effect.promise(() => callback(client)),
}),
),
Effect.provideService(
SpacesPolicy,
SpacesPolicy.make({
isMember: () => Effect.void,
isOwner: () => Effect.void,
isAdmin: () => Effect.void,
}),
),
Effect.provideService(
OrganisationsPolicy,
OrganisationsPolicy.make({
isMember: () => Effect.void,
isOwner: () => Effect.void,
isAdminOrOwner: () => Effect.void,
}),
),
Effect.provideService(CurrentUser, {
id: User.UserId.make("user-1"),
email: "user@example.com",
activeOrganizationId: Organisation.OrganisationId.make("org-1"),
iconUrlOrKey: Option.none(),
}),
Effect.runPromise,
),
[],
);

expect(result).toEqual({ roundTrips: 1, queries: 2 });
});

it("loads folder videos in two round trips", async () => {
const result = await countRoundTrips(
() =>
getVideosByFolderId(Folder.FolderId.make("folder-1"), {
variant: "user",
}).pipe(
Effect.provide(Database.Default),
Effect.provide(ImageUploads.Default),
Effect.runPromise,
),
LIST_COLUMNS.folder,
);
expect(result).toEqual({ roundTrips: 2, queries: 4 });
});
});
87 changes: 42 additions & 45 deletions apps/web/app/(org)/dashboard/spaces/[spaceId]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,15 @@ async function fetchFolders(
.where(and(eq(folders.spaceId, spaceId), isNull(folders.parentId)));
}

async function fetchOrganizationSettings(orgId: Organisation.OrganisationId) {
const [row] = await db()
.select({ settings: organizations.settings })
.from(organizations)
.where(eq(organizations.id, orgId))
.limit(1);
return row?.settings ?? null;
}

const fetchSpaceMembers = Effect.fn(function* (
spaceId: Space.SpaceIdOrOrganisationId,
) {
Expand Down Expand Up @@ -296,23 +305,6 @@ export default async function SharedCapsPage(props: {
if (spaceOrOrg.variant === "space") {
const { space } = spaceOrOrg;

// Fetch members in parallel
const [spaceMembersData, organizationMembersData, foldersData] =
await Promise.all([
fetchSpaceMembers(space.id).pipe(runPromise),
fetchOrganizationMembers(space.organizationId).pipe(runPromise),
fetchFolders(space.id, false),
]);
const resolvedSpace = await Effect.gen(function* () {
const imageUploads = yield* ImageUploads;
return {
...space,
iconUrl: space.iconUrl
? yield* imageUploads.resolveImageUrl(space.iconUrl)
: null,
};
}).pipe(runPromise);

async function fetchSpaceVideos(
spaceId: Space.SpaceIdOrOrganisationId,
page: number,
Expand Down Expand Up @@ -383,22 +375,32 @@ export default async function SharedCapsPage(props: {
};
}

// Fetch videos and count in parallel
const { videos: spaceVideoData, totalCount } = await fetchSpaceVideos(
space.id,
page,
limit,
);
const [
spaceMembersData,
organizationMembersData,
foldersData,
{ videos: spaceVideoData, totalCount },
organizationSettings,
] = await Promise.all([
fetchSpaceMembers(space.id).pipe(runPromise),
fetchOrganizationMembers(space.organizationId).pipe(runPromise),
fetchFolders(space.id, false),
fetchSpaceVideos(space.id, page, limit),
fetchOrganizationSettings(space.organizationId),
]);
const resolvedSpace = await Effect.gen(function* () {
const imageUploads = yield* ImageUploads;
return {
...space,
iconUrl: space.iconUrl
? yield* imageUploads.resolveImageUrl(space.iconUrl)
: null,
};
}).pipe(runPromise);
const sharedSpacesMap = await fetchSharedSpacesForVideos(
spaceVideoData.map((video) => Video.VideoId.make(video.id)),
{ userId: user.id, organizationId: space.organizationId },
);
const [organizationSettingsRow] = await db()
.select({ settings: organizations.settings })
.from(organizations)
.where(eq(organizations.id, space.organizationId))
.limit(1);
const organizationSettings = organizationSettingsRow?.settings ?? null;
const processedVideoData = spaceVideoData.map((video) => {
const { effectiveDate: _effectiveDate, ...videoWithoutEffectiveDate } =
video;
Expand Down Expand Up @@ -516,26 +518,21 @@ export default async function SharedCapsPage(props: {
};
}

// Fetch videos and count in parallel

const [organizationVideos, organizationMembersData, foldersData] =
await Promise.all([
fetchOrganizationVideos(organization.id, page, limit),
fetchOrganizationMembers(organization.id).pipe(runPromise),
fetchFolders(organization.id, true),
]);

const { videos: orgVideoData, totalCount } = organizationVideos;
const [
{ videos: orgVideoData, totalCount },
organizationMembersData,
foldersData,
organizationSettings,
] = await Promise.all([
fetchOrganizationVideos(organization.id, page, limit),
fetchOrganizationMembers(organization.id).pipe(runPromise),
fetchFolders(organization.id, true),
fetchOrganizationSettings(organization.id),
]);
const sharedSpacesMap = await fetchSharedSpacesForVideos(
orgVideoData.map((video) => Video.VideoId.make(video.id)),
{ userId: user.id, organizationId: organization.id },
);
const [organizationSettingsRow] = await db()
.select({ settings: organizations.settings })
.from(organizations)
.where(eq(organizations.id, organization.id))
.limit(1);
const organizationSettings = organizationSettingsRow?.settings ?? null;
const processedVideoData = orgVideoData.map((video) => {
const { effectiveDate: _effectiveDate, ...videoWithoutEffectiveDate } =
video;
Expand Down
Loading
Loading