From 5f0934acc0f7dde119be9f0a42a42742ec128377 Mon Sep 17 00:00:00 2001 From: Mohamed Bassem Date: Sun, 23 Nov 2025 00:54:38 +0000 Subject: 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 --- packages/trpc/routers/sharedLists.test.ts | 1461 +++++++++++++++++++++++------ 1 file changed, 1187 insertions(+), 274 deletions(-) (limited to 'packages/trpc/routers/sharedLists.test.ts') 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(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("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("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("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("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("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("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("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("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("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("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("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("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("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("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("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("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("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("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("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("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("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("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("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("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("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); }); }); }); -- cgit v1.2.3-70-g09d2