aboutsummaryrefslogtreecommitdiffstats
path: root/packages/trpc/routers/sharedLists.test.ts
diff options
context:
space:
mode:
authorMohamed Bassem <me@mbassem.com>2025-11-23 00:54:38 +0000
committerGitHub <noreply@github.com>2025-11-23 00:54:38 +0000
commit5f0934acc0f7dde119be9f0a42a42742ec128377 (patch)
treef13bd90961eab0c694eed101db0eea96e0fc4725 /packages/trpc/routers/sharedLists.test.ts
parentdaee8e7a4f764f188e1773a9def1542513bf66e1 (diff)
downloadkarakeep-5f0934acc0f7dde119be9f0a42a42742ec128377.tar.zst
feat: Add invitation approval for shared lists (#2152)
* feat: Add invitation approval system for collaborative lists - Add database schema changes to support pending invitations - Add status field (pending/accepted/declined) to listCollaborators - Add invitedAt and invitedEmail fields for tracking - Add index on status for efficient queries - Update List model with invitation workflow methods - Modify addCollaboratorByEmail to create pending invitations - Add acceptInvitation() for users to accept invites - Add declineInvitation() for users to decline invites - Add revokeInvitation() for owners to revoke pending invites - Add getPendingInvitations() to get user's pending invites - Implement privacy protection for pending invitations - Mask user names as "Pending User" until invitation is accepted - Only show email to list owner for pending invitations - Update getSharedWithUser to only include accepted collaborations - Ensures lists only appear after invitation is accepted * feat: Add tRPC procedures and email notifications for list invitations - Add new tRPC procedures for invitation workflow - acceptInvitation: Allow users to accept pending invitations - declineInvitation: Allow users to decline invitations - revokeInvitation: Allow owners to revoke pending invitations - getPendingInvitations: Get all pending invitations for current user - Update getCollaborators output schema - Add status, invitedAt fields to collaborator objects - Support privacy-masked user info for pending invitations - Add sendListInvitationEmail function - Email notification when user is invited to collaborate - Includes list name, inviter name, and link to view invitation - Gracefully handles missing SMTP configuration - Integrate email sending into invitation workflow - Send email when new invitation is created - Send email when declined invitation is renewed - Catch and log errors without failing the invitation * feat: Add UI for list invitation approval workflow - Update ManageCollaboratorsModal to support pending invitations - Show "Pending" badge for pending invitations - Add revoke button for owners to cancel pending invitations - Update success message to reflect invitation sent - Disable role change and remove buttons for pending invitations - Create PendingInvitationsCard component - Display all pending invitations for the current user - Show list name, description, inviter, and role - Provide Accept and Decline buttons - Auto-hide when no pending invitations exist - Add PendingInvitationsCard to lists page - Show at the top of the lists page - Only renders when user has pending invitations * fix: Add missing translation keys and fix TypeScript errors - Add translation keys for invitation system - lists.collaborators.invitation_sent - lists.collaborators.pending - lists.collaborators.revoke - lists.collaborators.invitation_revoked - lists.collaborators.failed_to_revoke - lists.invitations.* (all invitation-related keys) - Fix TypeScript errors in email sending - Handle optional user.name with fallback to 'A user' * wip * fixes * more fixes * fix revoke * more improvements * comment fix * fix email url * fix schemas * split pending invites into components * more fixes * test * test fixes --------- Co-authored-by: Claude <noreply@anthropic.com>
Diffstat (limited to 'packages/trpc/routers/sharedLists.test.ts')
-rw-r--r--packages/trpc/routers/sharedLists.test.ts1461
1 files changed, 1187 insertions, 274 deletions
diff --git a/packages/trpc/routers/sharedLists.test.ts b/packages/trpc/routers/sharedLists.test.ts
index 3b95a033..58a24d46 100644
--- a/packages/trpc/routers/sharedLists.test.ts
+++ b/packages/trpc/routers/sharedLists.test.ts
@@ -2,11 +2,35 @@ import { beforeEach, describe, expect, test } from "vitest";
import { BookmarkTypes } from "@karakeep/shared/types/bookmarks";
-import type { CustomTestContext } from "../testUtils";
+import type { APICallerType, CustomTestContext } from "../testUtils";
import { defaultBeforeEach } from "../testUtils";
beforeEach<CustomTestContext>(defaultBeforeEach(true));
+/**
+ * Helper function to add a collaborator and have them accept the invitation
+ */
+async function addAndAcceptCollaborator(
+ ownerApi: APICallerType,
+ collaboratorApi: APICallerType,
+ listId: string,
+ role: "viewer" | "editor",
+) {
+ const collaboratorUser = await collaboratorApi.users.whoami();
+
+ // Owner invites the collaborator
+ const { invitationId } = await ownerApi.lists.addCollaborator({
+ listId,
+ email: collaboratorUser.email!,
+ role,
+ });
+
+ // Collaborator accepts the invitation
+ await collaboratorApi.lists.acceptInvitation({
+ invitationId,
+ });
+}
+
describe("Shared Lists", () => {
describe("List Collaboration Management", () => {
test<CustomTestContext>("should allow owner to add a collaborator by email", async ({
@@ -26,12 +50,13 @@ describe("Shared Lists", () => {
const collaboratorUser = await collaboratorApi.users.whoami();
const collaboratorEmail = collaboratorUser.email!;
- // Add collaborator
- await ownerApi.lists.addCollaborator({
- listId: list.id,
- email: collaboratorEmail,
- role: "viewer",
- });
+ // Add collaborator (creates pending invitation)
+ await addAndAcceptCollaborator(
+ ownerApi,
+ collaboratorApi,
+ list.id,
+ "viewer",
+ );
// Verify collaborator was added
const { collaborators, owner } = await ownerApi.lists.getCollaborators({
@@ -84,13 +109,27 @@ describe("Shared Lists", () => {
const collaboratorEmail = (await collaboratorApi.users.whoami()).email!;
- await ownerApi.lists.addCollaborator({
+ const { invitationId } = await ownerApi.lists.addCollaborator({
listId: list.id,
email: collaboratorEmail,
role: "viewer",
});
- // Try to add same collaborator again
+ // Try to add same collaborator again (should fail - pending invitation exists)
+ await expect(
+ ownerApi.lists.addCollaborator({
+ listId: list.id,
+ email: collaboratorEmail,
+ role: "editor",
+ }),
+ ).rejects.toThrow("User already has a pending invitation for this list");
+
+ // Accept the invitation
+ await collaboratorApi.lists.acceptInvitation({
+ invitationId,
+ });
+
+ // Try to add them again after they're a collaborator
await expect(
ownerApi.lists.addCollaborator({
listId: list.id,
@@ -114,11 +153,12 @@ describe("Shared Lists", () => {
const collaboratorUser = await collaboratorApi.users.whoami();
- await ownerApi.lists.addCollaborator({
- listId: list.id,
- email: collaboratorUser.email!,
- role: "viewer",
- });
+ await addAndAcceptCollaborator(
+ ownerApi,
+ collaboratorApi,
+ list.id,
+ "viewer",
+ );
// Update role to editor
await ownerApi.lists.updateCollaboratorRole({
@@ -150,11 +190,12 @@ describe("Shared Lists", () => {
const collaboratorUser = await collaboratorApi.users.whoami();
- await ownerApi.lists.addCollaborator({
- listId: list.id,
- email: collaboratorUser.email!,
- role: "viewer",
- });
+ await addAndAcceptCollaborator(
+ ownerApi,
+ collaboratorApi,
+ list.id,
+ "viewer",
+ );
// Remove collaborator
await ownerApi.lists.removeCollaborator({
@@ -223,11 +264,12 @@ describe("Shared Lists", () => {
const collaboratorUser = await collaboratorApi.users.whoami();
- await ownerApi.lists.addCollaborator({
- listId: list.id,
- email: collaboratorUser.email!,
- role: "editor",
- });
+ await addAndAcceptCollaborator(
+ ownerApi,
+ collaboratorApi,
+ list.id,
+ "editor",
+ );
// Collaborator adds their own bookmark
const collabBookmark = await collaboratorApi.bookmarks.createBookmark({
@@ -279,13 +321,12 @@ describe("Shared Lists", () => {
type: "manual",
});
- const collaboratorEmail = (await collaboratorApi.users.whoami()).email!;
-
- await ownerApi.lists.addCollaborator({
- listId: list.id,
- email: collaboratorEmail,
- role: "viewer",
- });
+ await addAndAcceptCollaborator(
+ ownerApi,
+ collaboratorApi,
+ list.id,
+ "viewer",
+ );
// Collaborator leaves the list
await collaboratorApi.lists.leaveList({
@@ -331,13 +372,12 @@ describe("Shared Lists", () => {
bookmarkId: ownerBookmark.id,
});
- const collaboratorEmail = (await collaboratorApi.users.whoami()).email!;
-
- await ownerApi.lists.addCollaborator({
- listId: list.id,
- email: collaboratorEmail,
- role: "editor",
- });
+ await addAndAcceptCollaborator(
+ ownerApi,
+ collaboratorApi,
+ list.id,
+ "editor",
+ );
// Collaborator adds their own bookmark
const collabBookmark = await collaboratorApi.bookmarks.createBookmark({
@@ -434,13 +474,12 @@ describe("Shared Lists", () => {
type: "manual",
});
- const collaboratorEmail = (await collaboratorApi.users.whoami()).email!;
-
- await ownerApi.lists.addCollaborator({
- listId: list.id,
- email: collaboratorEmail,
- role: "viewer",
- });
+ await addAndAcceptCollaborator(
+ ownerApi,
+ collaboratorApi,
+ list.id,
+ "viewer",
+ );
const { lists: allLists } = await collaboratorApi.lists.list();
const sharedLists = allLists.filter(
@@ -464,13 +503,12 @@ describe("Shared Lists", () => {
type: "manual",
});
- const collaboratorEmail = (await collaboratorApi.users.whoami()).email!;
-
- await ownerApi.lists.addCollaborator({
- listId: list.id,
- email: collaboratorEmail,
- role: "viewer",
- });
+ await addAndAcceptCollaborator(
+ ownerApi,
+ collaboratorApi,
+ list.id,
+ "viewer",
+ );
const retrievedList = await collaboratorApi.lists.get({
listId: list.id,
@@ -494,13 +532,12 @@ describe("Shared Lists", () => {
type: "manual",
});
- const collaboratorEmail = (await collaboratorApi.users.whoami()).email!;
-
- await ownerApi.lists.addCollaborator({
- listId: list.id,
- email: collaboratorEmail,
- role: "viewer",
- });
+ await addAndAcceptCollaborator(
+ ownerApi,
+ collaboratorApi,
+ list.id,
+ "viewer",
+ );
await expect(
thirdUserApi.lists.get({
@@ -539,13 +576,12 @@ describe("Shared Lists", () => {
type: "manual",
});
- const collaboratorEmail = (await collaboratorApi.users.whoami()).email!;
-
- await ownerApi.lists.addCollaborator({
- listId: list.id,
- email: collaboratorEmail,
- role: "editor",
- });
+ await addAndAcceptCollaborator(
+ ownerApi,
+ collaboratorApi,
+ list.id,
+ "editor",
+ );
const retrievedList = await collaboratorApi.lists.get({
listId: list.id,
@@ -579,13 +615,12 @@ describe("Shared Lists", () => {
bookmarkId: bookmark.id,
});
- // Share list with collaborator
- const collaboratorEmail = (await collaboratorApi.users.whoami()).email!;
- await ownerApi.lists.addCollaborator({
- listId: list.id,
- email: collaboratorEmail,
- role: "viewer",
- });
+ await addAndAcceptCollaborator(
+ ownerApi,
+ collaboratorApi,
+ list.id,
+ "viewer",
+ );
// Collaborator fetches bookmarks from shared list
const bookmarks = await collaboratorApi.bookmarks.getBookmarks({
@@ -625,12 +660,12 @@ describe("Shared Lists", () => {
bookmarkId: bookmark.id,
});
- const collaboratorEmail = (await collaboratorApi.users.whoami()).email!;
- await ownerApi.lists.addCollaborator({
- listId: list.id,
- email: collaboratorEmail,
- role: "viewer",
- });
+ await addAndAcceptCollaborator(
+ ownerApi,
+ collaboratorApi,
+ list.id,
+ "viewer",
+ );
const ownerView = await ownerApi.bookmarks.getBookmarks({
listId: list.id,
@@ -680,12 +715,12 @@ describe("Shared Lists", () => {
bookmarkId: bookmark.id,
});
- const collaboratorEmail = (await collaboratorApi.users.whoami()).email!;
- await ownerApi.lists.addCollaborator({
- listId: list.id,
- email: collaboratorEmail,
- role: "viewer",
- });
+ await addAndAcceptCollaborator(
+ ownerApi,
+ collaboratorApi,
+ list.id,
+ "viewer",
+ );
// Collaborator gets individual bookmark
const response = await collaboratorApi.bookmarks.getBookmark({
@@ -717,12 +752,12 @@ describe("Shared Lists", () => {
bookmarkId: sharedBookmark.id,
});
- const collaboratorEmail = (await collaboratorApi.users.whoami()).email!;
- await ownerApi.lists.addCollaborator({
- listId: list.id,
- email: collaboratorEmail,
- role: "viewer",
- });
+ await addAndAcceptCollaborator(
+ ownerApi,
+ collaboratorApi,
+ list.id,
+ "viewer",
+ );
// Collaborator creates their own bookmark
const ownBookmark = await collaboratorApi.bookmarks.createBookmark({
@@ -761,12 +796,12 @@ describe("Shared Lists", () => {
bookmarkId: bookmark.id,
});
- const collaboratorEmail = (await collaboratorApi.users.whoami()).email!;
- await ownerApi.lists.addCollaborator({
- listId: list.id,
- email: collaboratorEmail,
- role: "viewer",
- });
+ await addAndAcceptCollaborator(
+ ownerApi,
+ collaboratorApi,
+ list.id,
+ "viewer",
+ );
// Don't add thirdUserApi as a collaborator
// Third user tries to access the bookmark
@@ -801,12 +836,12 @@ describe("Shared Lists", () => {
});
// Share list with collaborator as editor
- const collaboratorEmail = (await collaboratorApi.users.whoami()).email!;
- await ownerApi.lists.addCollaborator({
- listId: list.id,
- email: collaboratorEmail,
- role: "editor",
- });
+ await addAndAcceptCollaborator(
+ ownerApi,
+ collaboratorApi,
+ list.id,
+ "editor",
+ );
// Collaborator adds their own bookmark
const collabBookmark = await collaboratorApi.bookmarks.createBookmark({
@@ -846,12 +881,12 @@ describe("Shared Lists", () => {
type: "manual",
});
- const collaboratorEmail = (await collaboratorApi.users.whoami()).email!;
- await ownerApi.lists.addCollaborator({
- listId: list.id,
- email: collaboratorEmail,
- role: "viewer",
- });
+ await addAndAcceptCollaborator(
+ ownerApi,
+ collaboratorApi,
+ list.id,
+ "viewer",
+ );
// Viewer creates their own bookmark
const bookmark = await collaboratorApi.bookmarks.createBookmark({
@@ -880,12 +915,12 @@ describe("Shared Lists", () => {
type: "manual",
});
- const collaboratorEmail = (await collaboratorApi.users.whoami()).email!;
- await ownerApi.lists.addCollaborator({
- listId: list.id,
- email: collaboratorEmail,
- role: "editor",
- });
+ await addAndAcceptCollaborator(
+ ownerApi,
+ collaboratorApi,
+ list.id,
+ "editor",
+ );
// Editor creates their own bookmark
const bookmark = await collaboratorApi.bookmarks.createBookmark({
@@ -930,12 +965,12 @@ describe("Shared Lists", () => {
bookmarkId: bookmark.id,
});
- const collaboratorEmail = (await collaboratorApi.users.whoami()).email!;
- await ownerApi.lists.addCollaborator({
- listId: list.id,
- email: collaboratorEmail,
- role: "viewer",
- });
+ await addAndAcceptCollaborator(
+ ownerApi,
+ collaboratorApi,
+ list.id,
+ "viewer",
+ );
// Viewer tries to remove bookmark
await expect(
@@ -968,12 +1003,12 @@ describe("Shared Lists", () => {
bookmarkId: bookmark.id,
});
- const collaboratorEmail = (await collaboratorApi.users.whoami()).email!;
- await ownerApi.lists.addCollaborator({
- listId: list.id,
- email: collaboratorEmail,
- role: "editor",
- });
+ await addAndAcceptCollaborator(
+ ownerApi,
+ collaboratorApi,
+ list.id,
+ "editor",
+ );
// Editor removes bookmark
await collaboratorApi.lists.removeFromList({
@@ -1011,12 +1046,12 @@ describe("Shared Lists", () => {
bookmarkId: bookmark.id,
});
- const collaboratorEmail = (await collaboratorApi.users.whoami()).email!;
- await ownerApi.lists.addCollaborator({
- listId: list.id,
- email: collaboratorEmail,
- role: "editor",
- });
+ await addAndAcceptCollaborator(
+ ownerApi,
+ collaboratorApi,
+ list.id,
+ "editor",
+ );
// Collaborator tries to edit owner's bookmark
await expect(
@@ -1049,12 +1084,12 @@ describe("Shared Lists", () => {
bookmarkId: bookmark.id,
});
- const collaboratorEmail = (await collaboratorApi.users.whoami()).email!;
- await ownerApi.lists.addCollaborator({
- listId: list.id,
- email: collaboratorEmail,
- role: "editor",
- });
+ await addAndAcceptCollaborator(
+ ownerApi,
+ collaboratorApi,
+ list.id,
+ "editor",
+ );
// Collaborator tries to delete owner's bookmark
await expect(
@@ -1078,12 +1113,12 @@ describe("Shared Lists", () => {
type: "manual",
});
- const collaboratorEmail = (await collaboratorApi.users.whoami()).email!;
- await ownerApi.lists.addCollaborator({
- listId: list.id,
- email: collaboratorEmail,
- role: "editor",
- });
+ await addAndAcceptCollaborator(
+ ownerApi,
+ collaboratorApi,
+ list.id,
+ "editor",
+ );
// Collaborator tries to edit list
await expect(
@@ -1106,12 +1141,12 @@ describe("Shared Lists", () => {
type: "manual",
});
- const collaboratorEmail = (await collaboratorApi.users.whoami()).email!;
- await ownerApi.lists.addCollaborator({
- listId: list.id,
- email: collaboratorEmail,
- role: "editor",
- });
+ await addAndAcceptCollaborator(
+ ownerApi,
+ collaboratorApi,
+ list.id,
+ "editor",
+ );
// Collaborator tries to delete list
await expect(
@@ -1134,12 +1169,12 @@ describe("Shared Lists", () => {
type: "manual",
});
- const collaboratorEmail = (await collaboratorApi.users.whoami()).email!;
- await ownerApi.lists.addCollaborator({
- listId: list.id,
- email: collaboratorEmail,
- role: "editor",
- });
+ await addAndAcceptCollaborator(
+ ownerApi,
+ collaboratorApi,
+ list.id,
+ "editor",
+ );
const thirdUserEmail = (await thirdUserApi.users.whoami()).email!;
@@ -1166,12 +1201,12 @@ describe("Shared Lists", () => {
type: "manual",
});
- const collaboratorEmail = (await collaboratorApi.users.whoami()).email!;
- await ownerApi.lists.addCollaborator({
- listId: list.id,
- email: collaboratorEmail,
- role: "viewer",
- });
+ await addAndAcceptCollaborator(
+ ownerApi,
+ collaboratorApi,
+ list.id,
+ "viewer",
+ );
// Collaborator can view collaborators
const { collaborators, owner } =
@@ -1215,11 +1250,13 @@ describe("Shared Lists", () => {
});
const collaboratorUser = await collaboratorApi.users.whoami();
- await ownerApi.lists.addCollaborator({
- listId: list.id,
- email: collaboratorUser.email!,
- role: "viewer",
- });
+
+ await addAndAcceptCollaborator(
+ ownerApi,
+ collaboratorApi,
+ list.id,
+ "viewer",
+ );
// Verify collaborator has access to list
const bookmarksBefore = await collaboratorApi.bookmarks.getBookmarks({
@@ -1266,12 +1303,12 @@ describe("Shared Lists", () => {
type: "manual",
});
- const collaboratorEmail = (await collaboratorApi.users.whoami()).email!;
- await ownerApi.lists.addCollaborator({
- listId: list.id,
- email: collaboratorEmail,
- role: "viewer",
- });
+ await addAndAcceptCollaborator(
+ ownerApi,
+ collaboratorApi,
+ list.id,
+ "viewer",
+ );
// Collaborator leaves
await collaboratorApi.lists.leaveList({
@@ -1332,17 +1369,18 @@ describe("Shared Lists", () => {
type: "manual",
});
- const collaboratorEmail = (await collaboratorApi.users.whoami()).email!;
- await ownerApi.lists.addCollaborator({
- listId: list1.id,
- email: collaboratorEmail,
- role: "editor",
- });
- await ownerApi.lists.addCollaborator({
- listId: list2.id,
- email: collaboratorEmail,
- role: "editor",
- });
+ await addAndAcceptCollaborator(
+ ownerApi,
+ collaboratorApi,
+ list1.id,
+ "editor",
+ );
+ await addAndAcceptCollaborator(
+ ownerApi,
+ collaboratorApi,
+ list2.id,
+ "editor",
+ );
// Collaborator tries to merge the shared list into another list
await expect(
@@ -1366,12 +1404,12 @@ describe("Shared Lists", () => {
type: "manual",
});
- const collaboratorEmail = (await collaboratorApi.users.whoami()).email!;
- await ownerApi.lists.addCollaborator({
- listId: list.id,
- email: collaboratorEmail,
- role: "editor",
- });
+ await addAndAcceptCollaborator(
+ ownerApi,
+ collaboratorApi,
+ list.id,
+ "editor",
+ );
// Collaborator tries to generate RSS token
await expect(
@@ -1422,12 +1460,12 @@ describe("Shared Lists", () => {
bookmarkId: bookmark.id,
});
- const collaboratorEmail = (await collaboratorApi.users.whoami()).email!;
- await ownerApi.lists.addCollaborator({
- listId: list.id,
- email: collaboratorEmail,
- role: "viewer",
- });
+ await addAndAcceptCollaborator(
+ ownerApi,
+ collaboratorApi,
+ list.id,
+ "viewer",
+ );
// Collaborator cannot use getListsOfBookmark for owner's bookmark
// This is expected - only bookmark owners can query which lists contain their bookmarks
@@ -1435,7 +1473,7 @@ describe("Shared Lists", () => {
collaboratorApi.lists.getListsOfBookmark({
bookmarkId: bookmark.id,
}),
- ).rejects.toThrow();
+ ).rejects.toThrow("User is not allowed to access resource");
});
test<CustomTestContext>("should allow collaborator to use getListsOfBookmark for their own bookmarks in shared lists", async ({
@@ -1450,12 +1488,12 @@ describe("Shared Lists", () => {
type: "manual",
});
- const collaboratorEmail = (await collaboratorApi.users.whoami()).email!;
- await ownerApi.lists.addCollaborator({
- listId: list.id,
- email: collaboratorEmail,
- role: "editor",
- });
+ await addAndAcceptCollaborator(
+ ownerApi,
+ collaboratorApi,
+ list.id,
+ "editor",
+ );
// Collaborator creates their own bookmark and adds it to the shared list
const bookmark = await collaboratorApi.bookmarks.createBookmark({
@@ -1504,12 +1542,12 @@ describe("Shared Lists", () => {
});
// Add a collaborator
- const collaboratorEmail = (await collaboratorApi.users.whoami()).email!;
- await ownerApi.lists.addCollaborator({
- listId: list.id,
- email: collaboratorEmail,
- role: "viewer",
- });
+ await addAndAcceptCollaborator(
+ ownerApi,
+ collaboratorApi,
+ list.id,
+ "viewer",
+ );
// Owner queries which lists contain their bookmark
const { lists } = await ownerApi.lists.getListsOfBookmark({
@@ -1587,12 +1625,12 @@ describe("Shared Lists", () => {
bookmarkId: bookmark2.id,
});
- const collaboratorEmail = (await collaboratorApi.users.whoami()).email!;
- await ownerApi.lists.addCollaborator({
- listId: list.id,
- email: collaboratorEmail,
- role: "viewer",
- });
+ await addAndAcceptCollaborator(
+ ownerApi,
+ collaboratorApi,
+ list.id,
+ "viewer",
+ );
// Collaborator gets stats
const { stats } = await collaboratorApi.lists.stats();
@@ -1605,7 +1643,7 @@ describe("Shared Lists", () => {
apiCallers,
}) => {
const ownerApi = apiCallers[0];
- const editorApi = apiCallers[1];
+ const collaboratorApi = apiCallers[1];
const list = await ownerApi.lists.create({
name: "Shared List",
@@ -1613,21 +1651,21 @@ describe("Shared Lists", () => {
type: "manual",
});
- const editorEmail = (await editorApi.users.whoami()).email!;
- await ownerApi.lists.addCollaborator({
- listId: list.id,
- email: editorEmail,
- role: "editor",
- });
+ await addAndAcceptCollaborator(
+ ownerApi,
+ collaboratorApi,
+ list.id,
+ "editor",
+ );
// Editor creates their own bookmark
- const bookmark = await editorApi.bookmarks.createBookmark({
+ const bookmark = await collaboratorApi.bookmarks.createBookmark({
type: BookmarkTypes.TEXT,
text: "Editor's bookmark",
});
// Editor should be able to add their bookmark to the shared list
- await editorApi.lists.addToList({
+ await collaboratorApi.lists.addToList({
listId: list.id,
bookmarkId: bookmark.id,
});
@@ -1653,12 +1691,7 @@ describe("Shared Lists", () => {
type: "manual",
});
- const viewerEmail = (await viewerApi.users.whoami()).email!;
- await ownerApi.lists.addCollaborator({
- listId: list.id,
- email: viewerEmail,
- role: "viewer",
- });
+ await addAndAcceptCollaborator(ownerApi, viewerApi, list.id, "viewer");
// Viewer creates their own bookmark
const bookmark = await viewerApi.bookmarks.createBookmark({
@@ -1688,12 +1721,7 @@ describe("Shared Lists", () => {
type: "manual",
});
- const editorEmail = (await editorApi.users.whoami()).email!;
- await ownerApi.lists.addCollaborator({
- listId: list.id,
- email: editorEmail,
- role: "editor",
- });
+ await addAndAcceptCollaborator(ownerApi, editorApi, list.id, "editor");
// Third user creates a bookmark (or owner if only 2 users)
const bookmark = await thirdUserApi.bookmarks.createBookmark({
@@ -1722,12 +1750,7 @@ describe("Shared Lists", () => {
type: "manual",
});
- const editorEmail = (await editorApi.users.whoami()).email!;
- await ownerApi.lists.addCollaborator({
- listId: list.id,
- email: editorEmail,
- role: "editor",
- });
+ await addAndAcceptCollaborator(ownerApi, editorApi, list.id, "editor");
// Editor tries to change list name
await expect(
@@ -1774,13 +1797,12 @@ describe("Shared Lists", () => {
type: "manual",
});
- const collaboratorEmail = (await collaboratorApi.users.whoami()).email!;
-
- await ownerApi.lists.addCollaborator({
- listId: list.id,
- email: collaboratorEmail,
- role: "viewer",
- });
+ await addAndAcceptCollaborator(
+ ownerApi,
+ collaboratorApi,
+ list.id,
+ "viewer",
+ );
// Fetch the list again to get updated hasCollaborators
const updatedList = await ownerApi.lists.get({
@@ -1802,13 +1824,12 @@ describe("Shared Lists", () => {
type: "manual",
});
- const collaboratorEmail = (await collaboratorApi.users.whoami()).email!;
-
- await ownerApi.lists.addCollaborator({
- listId: list.id,
- email: collaboratorEmail,
- role: "viewer",
- });
+ await addAndAcceptCollaborator(
+ ownerApi,
+ collaboratorApi,
+ list.id,
+ "viewer",
+ );
// Collaborator fetches the list
const sharedList = await collaboratorApi.lists.get({
@@ -1832,11 +1853,12 @@ describe("Shared Lists", () => {
const collaboratorUser = await collaboratorApi.users.whoami();
- await ownerApi.lists.addCollaborator({
- listId: list.id,
- email: collaboratorUser.email!,
- role: "viewer",
- });
+ await addAndAcceptCollaborator(
+ ownerApi,
+ collaboratorApi,
+ list.id,
+ "viewer",
+ );
// Remove the collaborator
await ownerApi.lists.removeCollaborator({
@@ -1872,13 +1894,12 @@ describe("Shared Lists", () => {
type: "manual",
});
- const collaboratorEmail = (await collaboratorApi.users.whoami()).email!;
-
- await ownerApi.lists.addCollaborator({
- listId: list2.id,
- email: collaboratorEmail,
- role: "viewer",
- });
+ await addAndAcceptCollaborator(
+ ownerApi,
+ collaboratorApi,
+ list2.id,
+ "viewer",
+ );
// Get all lists
const { lists } = await ownerApi.lists.list();
@@ -1902,6 +1923,233 @@ describe("Shared Lists", () => {
type: "manual",
});
+ await addAndAcceptCollaborator(
+ ownerApi,
+ collaboratorApi,
+ list.id,
+ "editor",
+ );
+
+ // Collaborator gets all lists
+ const { lists } = await collaboratorApi.lists.list();
+
+ const sharedList = lists.find((l) => l.id === list.id);
+
+ expect(sharedList?.hasCollaborators).toBe(true);
+ expect(sharedList?.userRole).toBe("editor");
+ });
+ });
+
+ describe("List Invitations", () => {
+ test<CustomTestContext>("should create pending invitation when adding collaborator", async ({
+ apiCallers,
+ }) => {
+ const ownerApi = apiCallers[0];
+ const collaboratorApi = apiCallers[1];
+
+ const list = await ownerApi.lists.create({
+ name: "Test List",
+ icon: "📚",
+ type: "manual",
+ });
+
+ const collaboratorEmail = (await collaboratorApi.users.whoami()).email!;
+
+ // Add collaborator (creates pending invitation)
+ await ownerApi.lists.addCollaborator({
+ listId: list.id,
+ email: collaboratorEmail,
+ role: "viewer",
+ });
+
+ // Check that collaborator has a pending invitation
+ const pendingInvitations =
+ await collaboratorApi.lists.getPendingInvitations();
+
+ expect(pendingInvitations).toHaveLength(1);
+ expect(pendingInvitations[0].listId).toBe(list.id);
+ expect(pendingInvitations[0].role).toBe("viewer");
+ });
+
+ test<CustomTestContext>("should allow collaborator to accept invitation", async ({
+ apiCallers,
+ }) => {
+ const ownerApi = apiCallers[0];
+ const collaboratorApi = apiCallers[1];
+
+ const list = await ownerApi.lists.create({
+ name: "Test List",
+ icon: "📚",
+ type: "manual",
+ });
+
+ const collaboratorEmail = (await collaboratorApi.users.whoami()).email!;
+
+ const { invitationId } = await ownerApi.lists.addCollaborator({
+ listId: list.id,
+ email: collaboratorEmail,
+ role: "viewer",
+ });
+
+ // Accept the invitation
+ await collaboratorApi.lists.acceptInvitation({
+ invitationId,
+ });
+
+ // Verify collaborator was added
+ const { collaborators } = await ownerApi.lists.getCollaborators({
+ listId: list.id,
+ });
+
+ expect(collaborators).toHaveLength(1);
+ expect(collaborators[0].user.email).toBe(collaboratorEmail);
+ expect(collaborators[0].status).toBe("accepted");
+
+ // Verify no more pending invitations
+ const pendingInvitations =
+ await collaboratorApi.lists.getPendingInvitations();
+
+ expect(pendingInvitations).toHaveLength(0);
+ });
+
+ test<CustomTestContext>("should allow collaborator to decline invitation", async ({
+ apiCallers,
+ }) => {
+ const ownerApi = apiCallers[0];
+ const collaboratorApi = apiCallers[1];
+
+ const list = await ownerApi.lists.create({
+ name: "Test List",
+ icon: "📚",
+ type: "manual",
+ });
+
+ const collaboratorEmail = (await collaboratorApi.users.whoami()).email!;
+
+ const { invitationId } = await ownerApi.lists.addCollaborator({
+ listId: list.id,
+ email: collaboratorEmail,
+ role: "viewer",
+ });
+
+ // Decline the invitation
+ await collaboratorApi.lists.declineInvitation({
+ invitationId,
+ });
+
+ // Verify collaborator was not added
+ const { collaborators } = await ownerApi.lists.getCollaborators({
+ listId: list.id,
+ });
+
+ expect(collaborators).toHaveLength(1);
+ expect(collaborators[0].status).toBe("declined");
+
+ // Verify no pending invitations
+ const pendingInvitations =
+ await collaboratorApi.lists.getPendingInvitations();
+
+ expect(pendingInvitations).toHaveLength(0);
+ });
+
+ test<CustomTestContext>("should allow owner to revoke pending invitation", async ({
+ apiCallers,
+ }) => {
+ const ownerApi = apiCallers[0];
+ const collaboratorApi = apiCallers[1];
+
+ const list = await ownerApi.lists.create({
+ name: "Test List",
+ icon: "📚",
+ type: "manual",
+ });
+
+ const collaboratorUser = await collaboratorApi.users.whoami();
+
+ const { invitationId } = await ownerApi.lists.addCollaborator({
+ listId: list.id,
+ email: collaboratorUser.email!,
+ role: "viewer",
+ });
+
+ // Owner revokes the invitation
+ await ownerApi.lists.revokeInvitation({
+ invitationId,
+ });
+
+ // Verify invitation was revoked
+ const pendingInvitations =
+ await collaboratorApi.lists.getPendingInvitations();
+
+ expect(pendingInvitations).toHaveLength(0);
+ });
+
+ test<CustomTestContext>("should not allow access to list with pending invitation", async ({
+ apiCallers,
+ }) => {
+ const ownerApi = apiCallers[0];
+ const collaboratorApi = apiCallers[1];
+
+ const list = await ownerApi.lists.create({
+ name: "Test List",
+ icon: "📚",
+ type: "manual",
+ });
+
+ const bookmark = await ownerApi.bookmarks.createBookmark({
+ type: BookmarkTypes.TEXT,
+ text: "Test bookmark",
+ });
+
+ await ownerApi.lists.addToList({
+ listId: list.id,
+ bookmarkId: bookmark.id,
+ });
+
+ const collaboratorEmail = (await collaboratorApi.users.whoami()).email!;
+
+ // Add collaborator but don't accept invitation
+ await ownerApi.lists.addCollaborator({
+ listId: list.id,
+ email: collaboratorEmail,
+ role: "viewer",
+ });
+
+ // Collaborator should not be able to access the list yet
+ await expect(
+ collaboratorApi.lists.get({
+ listId: list.id,
+ }),
+ ).rejects.toThrow("List not found");
+
+ // Collaborator should not be able to access bookmarks in the list
+ await expect(
+ collaboratorApi.bookmarks.getBookmarks({
+ listId: list.id,
+ }),
+ ).rejects.toThrow("List not found");
+
+ // Collaborator should not be able to access individual bookmarks
+ await expect(
+ collaboratorApi.bookmarks.getBookmark({
+ bookmarkId: bookmark.id,
+ }),
+ ).rejects.toThrow("Bookmark not found");
+ });
+
+ test<CustomTestContext>("should show pending invitations with list details", async ({
+ apiCallers,
+ }) => {
+ const ownerApi = apiCallers[0];
+ const collaboratorApi = apiCallers[1];
+
+ const list = await ownerApi.lists.create({
+ name: "Test Shared List",
+ icon: "📚",
+ description: "A test list for sharing",
+ type: "manual",
+ });
+
const collaboratorEmail = (await collaboratorApi.users.whoami()).email!;
await ownerApi.lists.addCollaborator({
@@ -1910,13 +2158,678 @@ describe("Shared Lists", () => {
role: "editor",
});
- // Collaborator gets all lists
- const { lists } = await collaboratorApi.lists.list();
+ const pendingInvitations =
+ await collaboratorApi.lists.getPendingInvitations();
- const sharedList = lists.find((l) => l.id === list.id);
+ expect(pendingInvitations).toHaveLength(1);
+ const invitation = pendingInvitations[0];
- expect(sharedList?.hasCollaborators).toBe(true);
- expect(sharedList?.userRole).toBe("editor");
+ expect(invitation.listId).toBe(list.id);
+ expect(invitation.role).toBe("editor");
+ expect(invitation.list.name).toBe("Test Shared List");
+ expect(invitation.list.icon).toBe("📚");
+ expect(invitation.list.description).toBe("A test list for sharing");
+ expect(invitation.list.owner).toBeDefined();
+ });
+
+ test<CustomTestContext>("should show pending invitations in getCollaborators for owner", async ({
+ apiCallers,
+ }) => {
+ const ownerApi = apiCallers[0];
+ const collaboratorApi = apiCallers[1];
+
+ const list = await ownerApi.lists.create({
+ name: "Test List",
+ icon: "📚",
+ type: "manual",
+ });
+
+ const collaboratorEmail = (await collaboratorApi.users.whoami()).email!;
+
+ await ownerApi.lists.addCollaborator({
+ listId: list.id,
+ email: collaboratorEmail,
+ role: "viewer",
+ });
+
+ // Owner should see pending invitation in collaborators list
+ const { collaborators } = await ownerApi.lists.getCollaborators({
+ listId: list.id,
+ });
+
+ expect(collaborators).toHaveLength(1);
+ expect(collaborators[0].status).toBe("pending");
+ expect(collaborators[0].role).toBe("viewer");
+ expect(collaborators[0].user.email).toBe(collaboratorEmail);
+ });
+
+ test<CustomTestContext>("should update hasCollaborators after invitation is accepted", async ({
+ apiCallers,
+ }) => {
+ const ownerApi = apiCallers[0];
+ const collaboratorApi = apiCallers[1];
+
+ const list = await ownerApi.lists.create({
+ name: "Test List",
+ icon: "📚",
+ type: "manual",
+ });
+
+ const collaboratorEmail = (await collaboratorApi.users.whoami()).email!;
+
+ // hasCollaborators should be false initially
+ expect(list.hasCollaborators).toBe(false);
+
+ const { invitationId } = await ownerApi.lists.addCollaborator({
+ listId: list.id,
+ email: collaboratorEmail,
+ role: "viewer",
+ });
+
+ // hasCollaborators should be false after adding invitation (pending does not counts)
+ const listAfterInvite = await ownerApi.lists.get({
+ listId: list.id,
+ });
+ expect(listAfterInvite.hasCollaborators).toBe(false);
+
+ // Accept the invitation
+ await collaboratorApi.lists.acceptInvitation({
+ invitationId,
+ });
+
+ // hasCollaborators should still be true
+ const listAfterAccept = await ownerApi.lists.get({
+ listId: list.id,
+ });
+ expect(listAfterAccept.hasCollaborators).toBe(true);
+ });
+
+ test<CustomTestContext>("should update hasCollaborators after invitation is declined", async ({
+ apiCallers,
+ }) => {
+ const ownerApi = apiCallers[0];
+ const collaboratorApi = apiCallers[1];
+
+ const list = await ownerApi.lists.create({
+ name: "Test List",
+ icon: "📚",
+ type: "manual",
+ });
+
+ const collaboratorEmail = (await collaboratorApi.users.whoami()).email!;
+
+ const { invitationId } = await ownerApi.lists.addCollaborator({
+ listId: list.id,
+ email: collaboratorEmail,
+ role: "viewer",
+ });
+
+ // hasCollaborators should be false with pending invitation
+ const listAfterInvite = await ownerApi.lists.get({
+ listId: list.id,
+ });
+ expect(listAfterInvite.hasCollaborators).toBe(false);
+
+ // Decline the invitation
+ await collaboratorApi.lists.declineInvitation({
+ invitationId,
+ });
+
+ // hasCollaborators should be false after declining
+ const listAfterDecline = await ownerApi.lists.get({
+ listId: list.id,
+ });
+ expect(listAfterDecline.hasCollaborators).toBe(false);
+ });
+
+ test<CustomTestContext>("should not show declined invitations in pending list", async ({
+ apiCallers,
+ }) => {
+ const ownerApi = apiCallers[0];
+ const collaboratorApi = apiCallers[1];
+
+ const list = await ownerApi.lists.create({
+ name: "Test List",
+ icon: "📚",
+ type: "manual",
+ });
+
+ const collaboratorEmail = (await collaboratorApi.users.whoami()).email!;
+
+ const { invitationId } = await ownerApi.lists.addCollaborator({
+ listId: list.id,
+ email: collaboratorEmail,
+ role: "viewer",
+ });
+
+ // Decline the invitation
+ await collaboratorApi.lists.declineInvitation({
+ invitationId,
+ });
+
+ // Should not appear in pending invitations
+ const pendingInvitations =
+ await collaboratorApi.lists.getPendingInvitations();
+
+ expect(pendingInvitations).toHaveLength(0);
+ });
+
+ test<CustomTestContext>("should allow re-inviting after decline", async ({
+ apiCallers,
+ }) => {
+ const ownerApi = apiCallers[0];
+ const collaboratorApi = apiCallers[1];
+
+ const list = await ownerApi.lists.create({
+ name: "Test List",
+ icon: "📚",
+ type: "manual",
+ });
+
+ const collaboratorEmail = (await collaboratorApi.users.whoami()).email!;
+
+ // First invitation
+ const { invitationId } = await ownerApi.lists.addCollaborator({
+ listId: list.id,
+ email: collaboratorEmail,
+ role: "viewer",
+ });
+
+ // Decline it
+ await collaboratorApi.lists.declineInvitation({
+ invitationId,
+ });
+
+ // Re-invite with different role
+ await ownerApi.lists.addCollaborator({
+ listId: list.id,
+ email: collaboratorEmail,
+ role: "editor",
+ });
+
+ // Should have a new pending invitation
+ const pendingInvitations =
+ await collaboratorApi.lists.getPendingInvitations();
+
+ expect(pendingInvitations).toHaveLength(1);
+ expect(pendingInvitations[0].role).toBe("editor");
+ });
+
+ test<CustomTestContext>("should not allow accepting non-existent invitation", async ({
+ apiCallers,
+ }) => {
+ const collaboratorApi = apiCallers[1];
+
+ const fakeInvitationId = "non-existent-invitation-id";
+
+ await expect(
+ collaboratorApi.lists.acceptInvitation({
+ invitationId: fakeInvitationId,
+ }),
+ ).rejects.toThrow("Invitation not found");
+ });
+
+ test<CustomTestContext>("should not allow accepting already accepted invitation", async ({
+ apiCallers,
+ }) => {
+ const ownerApi = apiCallers[0];
+ const collaboratorApi = apiCallers[1];
+
+ const list = await ownerApi.lists.create({
+ name: "Test List",
+ icon: "📚",
+ type: "manual",
+ });
+
+ const collaboratorEmail = (await collaboratorApi.users.whoami()).email!;
+
+ const { invitationId } = await ownerApi.lists.addCollaborator({
+ listId: list.id,
+ email: collaboratorEmail,
+ role: "viewer",
+ });
+
+ // Accept once
+ await collaboratorApi.lists.acceptInvitation({
+ invitationId,
+ });
+
+ // Try to accept again (should fail since invitation is already accepted and deleted)
+ await expect(
+ collaboratorApi.lists.acceptInvitation({
+ invitationId,
+ }),
+ ).rejects.toThrow("Invitation not found");
+ });
+
+ test<CustomTestContext>("should show list in shared lists only after accepting invitation", async ({
+ apiCallers,
+ }) => {
+ const ownerApi = apiCallers[0];
+ const collaboratorApi = apiCallers[1];
+
+ const list = await ownerApi.lists.create({
+ name: "Test List",
+ icon: "📚",
+ type: "manual",
+ });
+
+ const collaboratorEmail = (await collaboratorApi.users.whoami()).email!;
+
+ const { invitationId } = await ownerApi.lists.addCollaborator({
+ listId: list.id,
+ email: collaboratorEmail,
+ role: "viewer",
+ });
+
+ // List should not appear in collaborator's lists yet
+ const listsBefore = await collaboratorApi.lists.list();
+ expect(listsBefore.lists.find((l) => l.id === list.id)).toBeUndefined();
+
+ // Accept invitation
+ await collaboratorApi.lists.acceptInvitation({
+ invitationId,
+ });
+
+ // Now list should appear
+ const listsAfter = await collaboratorApi.lists.list();
+ const sharedList = listsAfter.lists.find((l) => l.id === list.id);
+ expect(sharedList).toBeDefined();
+ expect(sharedList?.userRole).toBe("viewer");
+ });
+
+ test<CustomTestContext>("should handle multiple pending invitations for different lists", async ({
+ apiCallers,
+ }) => {
+ const ownerApi = apiCallers[0];
+ const collaboratorApi = apiCallers[1];
+
+ const list1 = await ownerApi.lists.create({
+ name: "List 1",
+ icon: "📚",
+ type: "manual",
+ });
+
+ const list2 = await ownerApi.lists.create({
+ name: "List 2",
+ icon: "📖",
+ type: "manual",
+ });
+
+ const collaboratorEmail = (await collaboratorApi.users.whoami()).email!;
+
+ // Invite to both lists
+ const { invitationId: invitationId1 } =
+ await ownerApi.lists.addCollaborator({
+ listId: list1.id,
+ email: collaboratorEmail,
+ role: "viewer",
+ });
+
+ const { invitationId: invitationId2 } =
+ await ownerApi.lists.addCollaborator({
+ listId: list2.id,
+ email: collaboratorEmail,
+ role: "editor",
+ });
+
+ // Should have 2 pending invitations
+ const pendingInvitations =
+ await collaboratorApi.lists.getPendingInvitations();
+
+ expect(pendingInvitations).toHaveLength(2);
+
+ // Accept one
+ await collaboratorApi.lists.acceptInvitation({
+ invitationId: invitationId1,
+ });
+
+ // Should have 1 pending invitation left
+ const remainingInvitations =
+ await collaboratorApi.lists.getPendingInvitations();
+
+ expect(remainingInvitations).toHaveLength(1);
+ expect(remainingInvitations[0].id).toBe(invitationId2);
+ expect(remainingInvitations[0].listId).toBe(list2.id);
+ });
+
+ test<CustomTestContext>("should not allow collaborator to revoke invitation", async ({
+ apiCallers,
+ }) => {
+ const ownerApi = apiCallers[0];
+ const collaboratorApi = apiCallers[1];
+ const thirdUserApi = apiCallers[2];
+
+ const list = await ownerApi.lists.create({
+ name: "Test List",
+ icon: "📚",
+ type: "manual",
+ });
+
+ // Owner adds collaborator 1 and they accept
+ await addAndAcceptCollaborator(
+ ownerApi,
+ collaboratorApi,
+ list.id,
+ "editor",
+ );
+
+ // Owner invites third user
+ const thirdUserEmail = (await thirdUserApi.users.whoami()).email!;
+ const { invitationId } = await ownerApi.lists.addCollaborator({
+ listId: list.id,
+ email: thirdUserEmail,
+ role: "viewer",
+ });
+
+ // Collaborator tries to revoke the third user's invitation
+ // Collaborator cannot access the invitation at all (not the invitee, not the owner)
+ await expect(
+ collaboratorApi.lists.revokeInvitation({
+ invitationId,
+ }),
+ ).rejects.toThrow("Invitation not found");
+ });
+
+ test<CustomTestContext>("should not allow invited user to revoke their own invitation", async ({
+ apiCallers,
+ }) => {
+ const ownerApi = apiCallers[0];
+ const collaboratorApi = apiCallers[1];
+
+ const list = await ownerApi.lists.create({
+ name: "Test List",
+ icon: "📚",
+ type: "manual",
+ });
+
+ const collaboratorEmail = (await collaboratorApi.users.whoami()).email!;
+
+ const { invitationId } = await ownerApi.lists.addCollaborator({
+ listId: list.id,
+ email: collaboratorEmail,
+ role: "viewer",
+ });
+
+ // Invited user tries to revoke (should only be able to decline)
+ await expect(
+ collaboratorApi.lists.revokeInvitation({
+ invitationId,
+ }),
+ ).rejects.toThrow("Only the list owner can perform this action");
+ });
+
+ test<CustomTestContext>("should not allow non-owner/non-invitee to access invitation", async ({
+ apiCallers,
+ }) => {
+ const ownerApi = apiCallers[0];
+ const collaboratorApi = apiCallers[1];
+ const thirdUserApi = apiCallers[2];
+
+ const list = await ownerApi.lists.create({
+ name: "Test List",
+ icon: "📚",
+ type: "manual",
+ });
+
+ const collaboratorEmail = (await collaboratorApi.users.whoami()).email!;
+
+ const { invitationId } = await ownerApi.lists.addCollaborator({
+ listId: list.id,
+ email: collaboratorEmail,
+ role: "viewer",
+ });
+
+ // Third user (not owner, not invitee) tries to revoke invitation
+ await expect(
+ thirdUserApi.lists.revokeInvitation({
+ invitationId,
+ }),
+ ).rejects.toThrow("Invitation not found");
+
+ // Third user tries to accept invitation
+ await expect(
+ thirdUserApi.lists.acceptInvitation({
+ invitationId,
+ }),
+ ).rejects.toThrow("Invitation not found");
+
+ // Third user tries to decline invitation
+ await expect(
+ thirdUserApi.lists.declineInvitation({
+ invitationId,
+ }),
+ ).rejects.toThrow("Invitation not found");
+ });
+
+ test<CustomTestContext>("should not show invitations to collaborators in getCollaborators", async ({
+ apiCallers,
+ }) => {
+ const ownerApi = apiCallers[0];
+ const collaboratorApi = apiCallers[1];
+ const thirdUserApi = apiCallers[2];
+
+ const list = await ownerApi.lists.create({
+ name: "Test List",
+ icon: "📚",
+ type: "manual",
+ });
+
+ // Owner adds collaborator 1 and they accept
+ await addAndAcceptCollaborator(
+ ownerApi,
+ collaboratorApi,
+ list.id,
+ "viewer",
+ );
+
+ // Owner invites third user (pending invitation)
+ const thirdUserEmail = (await thirdUserApi.users.whoami()).email!;
+ await ownerApi.lists.addCollaborator({
+ listId: list.id,
+ email: thirdUserEmail,
+ role: "viewer",
+ });
+
+ // Owner should see 2 collaborators (1 accepted + 1 pending)
+ const ownerView = await ownerApi.lists.getCollaborators({
+ listId: list.id,
+ });
+ expect(ownerView.collaborators).toHaveLength(2);
+
+ // Collaborator should only see 1 (themselves, no pending invitations)
+ const collaboratorView = await collaboratorApi.lists.getCollaborators({
+ listId: list.id,
+ });
+ expect(collaboratorView.collaborators).toHaveLength(1);
+ expect(collaboratorView.collaborators[0].status).toBe("accepted");
+ });
+
+ test<CustomTestContext>("should allow owner to see both accepted collaborators and pending invitations", async ({
+ apiCallers,
+ }) => {
+ const ownerApi = apiCallers[0];
+ const collaboratorApi = apiCallers[1];
+ const thirdUserApi = apiCallers[2];
+
+ const list = await ownerApi.lists.create({
+ name: "Test List",
+ icon: "📚",
+ type: "manual",
+ });
+
+ // Add and accept one collaborator
+ const collaboratorEmail = (await collaboratorApi.users.whoami()).email!;
+ const { invitationId } = await ownerApi.lists.addCollaborator({
+ listId: list.id,
+ email: collaboratorEmail,
+ role: "editor",
+ });
+
+ await collaboratorApi.lists.acceptInvitation({
+ invitationId,
+ });
+
+ // Add pending invitation for third user
+ const thirdUserEmail = (await thirdUserApi.users.whoami()).email!;
+ await ownerApi.lists.addCollaborator({
+ listId: list.id,
+ email: thirdUserEmail,
+ role: "viewer",
+ });
+
+ // Owner should see both
+ const { collaborators } = await ownerApi.lists.getCollaborators({
+ listId: list.id,
+ });
+
+ expect(collaborators).toHaveLength(2);
+
+ const acceptedCollaborator = collaborators.find(
+ (c) => c.status === "accepted",
+ );
+ const pendingCollaborator = collaborators.find(
+ (c) => c.status === "pending",
+ );
+
+ expect(acceptedCollaborator).toBeDefined();
+ expect(acceptedCollaborator?.role).toBe("editor");
+ expect(acceptedCollaborator?.user.email).toBe(collaboratorEmail);
+
+ expect(pendingCollaborator).toBeDefined();
+ expect(pendingCollaborator?.role).toBe("viewer");
+ expect(pendingCollaborator?.user.email).toBe(thirdUserEmail);
+ });
+
+ test<CustomTestContext>("should not show invitee name for pending invitations", async ({
+ apiCallers,
+ }) => {
+ const ownerApi = apiCallers[0];
+ const collaboratorApi = apiCallers[1];
+
+ const list = await ownerApi.lists.create({
+ name: "Test List",
+ icon: "📚",
+ type: "manual",
+ });
+
+ const collaboratorUser = await collaboratorApi.users.whoami();
+ const collaboratorEmail = collaboratorUser.email!;
+ const collaboratorName = collaboratorUser.name;
+
+ await ownerApi.lists.addCollaborator({
+ listId: list.id,
+ email: collaboratorEmail,
+ role: "viewer",
+ });
+
+ // Owner checks pending invitations
+ const { collaborators } = await ownerApi.lists.getCollaborators({
+ listId: list.id,
+ });
+
+ const pendingInvitation = collaborators.find(
+ (c) => c.status === "pending",
+ );
+
+ expect(pendingInvitation).toBeDefined();
+ // Name should be masked as "Pending User"
+ expect(pendingInvitation?.user.name).toBe("Pending User");
+ // Name should NOT be the actual user's name
+ expect(pendingInvitation?.user.name).not.toBe(collaboratorName);
+ // Email should still be visible to owner
+ expect(pendingInvitation?.user.email).toBe(collaboratorEmail);
+ });
+
+ test<CustomTestContext>("should show invitee name after invitation is accepted", async ({
+ apiCallers,
+ }) => {
+ const ownerApi = apiCallers[0];
+ const collaboratorApi = apiCallers[1];
+
+ const list = await ownerApi.lists.create({
+ name: "Test List",
+ icon: "📚",
+ type: "manual",
+ });
+
+ const collaboratorUser = await collaboratorApi.users.whoami();
+ const collaboratorEmail = collaboratorUser.email!;
+ const collaboratorName = collaboratorUser.name;
+
+ const { invitationId } = await ownerApi.lists.addCollaborator({
+ listId: list.id,
+ email: collaboratorEmail,
+ role: "viewer",
+ });
+
+ // Before acceptance - name should be masked
+ const beforeAccept = await ownerApi.lists.getCollaborators({
+ listId: list.id,
+ });
+ const pendingInvitation = beforeAccept.collaborators.find(
+ (c) => c.status === "pending",
+ );
+ expect(pendingInvitation?.user.name).toBe("Pending User");
+
+ // Accept invitation
+ await collaboratorApi.lists.acceptInvitation({
+ invitationId,
+ });
+
+ // After acceptance - name should be visible
+ const afterAccept = await ownerApi.lists.getCollaborators({
+ listId: list.id,
+ });
+ const acceptedCollaborator = afterAccept.collaborators.find(
+ (c) => c.status === "accepted",
+ );
+ expect(acceptedCollaborator?.user.name).toBe(collaboratorName);
+ expect(acceptedCollaborator?.user.email).toBe(collaboratorEmail);
+ });
+
+ test<CustomTestContext>("should not show invitee name for declined invitations", async ({
+ apiCallers,
+ }) => {
+ const ownerApi = apiCallers[0];
+ const collaboratorApi = apiCallers[1];
+
+ const list = await ownerApi.lists.create({
+ name: "Test List",
+ icon: "📚",
+ type: "manual",
+ });
+
+ const collaboratorUser = await collaboratorApi.users.whoami();
+ const collaboratorEmail = collaboratorUser.email!;
+ const collaboratorName = collaboratorUser.name;
+
+ const { invitationId } = await ownerApi.lists.addCollaborator({
+ listId: list.id,
+ email: collaboratorEmail,
+ role: "viewer",
+ });
+
+ // Decline the invitation
+ await collaboratorApi.lists.declineInvitation({
+ invitationId,
+ });
+
+ // Owner checks declined invitations
+ const { collaborators } = await ownerApi.lists.getCollaborators({
+ listId: list.id,
+ });
+
+ const declinedInvitation = collaborators.find(
+ (c) => c.status === "declined",
+ );
+
+ expect(declinedInvitation).toBeDefined();
+ // Name should still be masked as "Pending User" even after decline
+ expect(declinedInvitation?.user.name).toBe("Pending User");
+ expect(declinedInvitation?.user.name).not.toBe(collaboratorName);
+ // Email should still be visible to owner
+ expect(declinedInvitation?.user.email).toBe(collaboratorEmail);
});
});
});