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
4 changes: 4 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -130,13 +130,16 @@ jobs:
__tests__/unit/embed-video-playback-chrome.test.ts
__tests__/unit/folder-videos.test.ts
__tests__/unit/homepage-demo-accessibility.test.ts
__tests__/unit/invite-decline-pro-seat.test.ts
__tests__/unit/invite-decline.test.ts
__tests__/unit/linkified-text.test.ts
__tests__/unit/loom-import-ui.test.ts
__tests__/unit/media-comment-playback-cleanup.test.ts
__tests__/unit/media-format-converter-selection.test.ts
__tests__/unit/media-format-converter.test.ts
__tests__/unit/media-player-store-refs.test.ts
__tests__/unit/playback-store.test.ts
__tests__/unit/remove-organization-member.test.ts
__tests__/unit/seo-demo-videos.test.ts
__tests__/unit/signed-baa-card.test.ts
__tests__/unit/space-folder-page-access.test.ts
Expand All @@ -145,6 +148,7 @@ jobs:
__tests__/unit/speed-controller.test.ts
__tests__/unit/summary-editor.test.ts
__tests__/unit/sso-login-pages.test.ts
__tests__/unit/toggle-pro-seat.test.ts
__tests__/unit/transcript-hours.test.ts
__tests__/unit/transcript-numbers.test.ts
__tests__/unit/video-analytics-access.test.ts
Expand Down
199 changes: 199 additions & 0 deletions apps/web/__tests__/unit/invite-decline-pro-seat.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
import { userIsPro } from "@cap/utils";
import { beforeEach, describe, expect, it, vi } from "vitest";

const mocks = vi.hoisted(() => ({
getCurrentUser: vi.fn(),
selectResults: [] as unknown[][],
user: { thirdPartyStripeSubscriptionId: null as string | null },
}));

const tables = vi.hoisted(() => ({
organizationInvites: {
id: "inviteId",
invitedEmail: "invitedEmail",
organizationId: "inviteOrgId",
},
organizationMembers: {
id: "memberId",
userId: "memberUserId",
organizationId: "memberOrgId",
hasProSeat: "hasProSeat",
},
organizations: { id: "orgId", ownerId: "ownerId" },
spaceMembers: { userId: "spaceUserId", spaceId: "spaceId" },
spaces: { id: "spaceId", organizationId: "spaceOrgId" },
users: {
id: "userId",
stripeSubscriptionId: "subscriptionId",
thirdPartyStripeSubscriptionId: "thirdPartySubscriptionId",
},
}));

const lockRows = vi.fn();
const reads: { table: unknown; joined: boolean; locked: boolean }[] = [];
const deletes: unknown[] = [];

type SelectQuery = Promise<unknown[]> & {
from(table: unknown): SelectQuery;
innerJoin(): SelectQuery;
where(): SelectQuery;
limit(): SelectQuery;
for(mode: string): SelectQuery;
};

function selectQuery() {
const read = { table: undefined as unknown, joined: false, locked: false };
reads.push(read);
const query: SelectQuery = Object.assign(
Promise.resolve(mocks.selectResults.shift() ?? []),
{
from: (table: unknown) => {
read.table = table;
return query;
},
innerJoin: () => {
read.joined = true;
return query;
},
where: () => query,
limit: () => query,
for: (mode: string) => {
read.locked = true;
lockRows(mode, read.table);
return query;
},
},
);
return query;
}

const database = {
select: vi.fn(selectQuery),
delete: vi.fn((table: unknown) => {
deletes.push(table);
return { where: async () => [{ affectedRows: 1 }] };
}),
update: vi.fn((table: unknown) => ({
set: (value: Record<string, unknown>) => {
if (table === tables.users && "thirdPartyStripeSubscriptionId" in value) {
mocks.user.thirdPartyStripeSubscriptionId =
(value.thirdPartyStripeSubscriptionId as string | null) ?? null;
}
return { where: async () => [{ affectedRows: 1 }] };
},
})),
transaction: vi.fn(async (fn: (tx: typeof database) => Promise<unknown>) =>
fn(database),
),
};

vi.mock("@cap/database", () => ({ db: () => database }));
vi.mock("@cap/env", () => ({
buildEnv: { NEXT_PUBLIC_IS_CAP: "true" },
serverEnv: () => ({ WEB_URL: "https://cap.test" }),
}));
vi.mock("@cap/database/auth/session", () => ({
getCurrentUser: mocks.getCurrentUser,
}));
vi.mock("@cap/database/schema", () => tables);

async function declineProSeat(
remainingSeats: { stripeSubscriptionId: string | null }[],
) {
mocks.selectResults = [
[
{
id: "invite-1",
invitedEmail: "invitee@example.com",
organizationId: "org-1",
},
],
[{ id: "member-1", hasProSeat: true }],
[{ id: "user-1" }],
remainingSeats,
[],
[],
];
const { POST } = await import("@/app/api/invite/decline/route");
return POST(
new Request("https://cap.test/api/invite/decline", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ inviteId: "invite-1" }),
}) as unknown as import("next/server").NextRequest,
);
}

function userIsStillPro() {
return userIsPro({ stripeSubscriptionStatus: null, ...mocks.user });
}

describe("POST /api/invite/decline with a Pro seat", () => {
beforeEach(() => {
vi.clearAllMocks();
deletes.length = 0;
reads.length = 0;
mocks.user.thirdPartyStripeSubscriptionId = "sub_org_1";
mocks.getCurrentUser.mockResolvedValue({
id: "user-1",
email: "invitee@example.com",
activeOrganizationId: null,
defaultOrgId: null,
});
});

it("clears Pro when the declined seat was the last one", async () => {
const response = await declineProSeat([]);

expect(response.status).toBe(200);
expect(deletes).toEqual([
tables.organizationMembers,
tables.organizationInvites,
]);
expect(mocks.user.thirdPartyStripeSubscriptionId).toBeNull();
expect(userIsStillPro()).toBe(false);
});

it("keeps Pro when a manager-sponsored seat remains", async () => {
const response = await declineProSeat([{ stripeSubscriptionId: null }]);

expect(response.status).toBe(200);
expect(mocks.user.thirdPartyStripeSubscriptionId).toBe("sub_org_1");
expect(userIsStillPro()).toBe(true);
});

it("moves Pro onto a remaining paid seat", async () => {
const response = await declineProSeat([
{ stripeSubscriptionId: null },
{ stripeSubscriptionId: "sub_other" },
]);

expect(response.status).toBe(200);
expect(deletes).toEqual([
tables.organizationMembers,
tables.organizationInvites,
]);
expect(mocks.user.thirdPartyStripeSubscriptionId).toBe("sub_other");
expect(userIsStillPro()).toBe(true);
});

it("locks the invite, the membership and the user row", async () => {
await declineProSeat([{ stripeSubscriptionId: "sub_other" }]);

expect(lockRows.mock.calls).toEqual([
["update", tables.organizationInvites],
["update", tables.organizationMembers],
["update", tables.users],
]);
});

it("reads the remaining seats before any other plain read", async () => {
await declineProSeat([{ stripeSubscriptionId: "sub_other" }]);

expect(reads.find((read) => !read.locked)).toEqual({
table: tables.organizationMembers,
joined: true,
locked: false,
});
});
});
2 changes: 1 addition & 1 deletion apps/web/__tests__/unit/invite-decline.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,7 @@ describe("POST /api/invite/decline", () => {
organizationId: "org-1",
},
]);
mockDb.limit.mockResolvedValueOnce([]);
mockDb.limit.mockReturnValueOnce(mockDb);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Invite tests skip Pro release

The updated test still returns no membership, so it never reaches the new releaseProSeatSubscription call. These tests can pass even if declining an invite fails to move Pro onto a remaining paid seat.

Add a case with hasProSeat: true and a remaining paid seat. Extend the mock to support the helper's joins, then assert that the subscription changes while the invite and membership are deleted.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/web/__tests__/unit/invite-decline.test.ts
Line: 157

Comment:
**Invite tests skip Pro release**

The updated test still returns no membership, so it never reaches the new `releaseProSeatSubscription` call. These tests can pass even if declining an invite fails to move Pro onto a remaining paid seat.

Add a case with `hasProSeat: true` and a remaining paid seat. Extend the mock to support the helper's joins, then assert that the subscription changes while the invite and membership are deleted.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!


const response = await POST(makeRequest({ inviteId: "invite-1" }));

Expand Down
3 changes: 2 additions & 1 deletion apps/web/__tests__/unit/remove-organization-member.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,8 +98,9 @@ async function removeSeat(
hasProSeat: true,
},
],
[],
[{ id: "teammate" }],
remainingSeats,
[],
];
const { removeOrganizationMember } = await import(
"@/actions/organization/remove-member"
Expand Down
Loading
Loading