aboutsummaryrefslogtreecommitdiffstats
path: root/packages/trpc
diff options
context:
space:
mode:
Diffstat (limited to 'packages/trpc')
-rw-r--r--packages/trpc/email.ts63
-rw-r--r--packages/trpc/models/listInvitations.ts398
-rw-r--r--packages/trpc/models/lists.ts114
-rw-r--r--packages/trpc/routers/lists.ts92
-rw-r--r--packages/trpc/routers/sharedLists.test.ts1461
5 files changed, 1789 insertions, 339 deletions
diff --git a/packages/trpc/email.ts b/packages/trpc/email.ts
index edf8ec92..3c0b8b39 100644
--- a/packages/trpc/email.ts
+++ b/packages/trpc/email.ts
@@ -179,3 +179,66 @@ If you didn't request a password reset, please ignore this email. Your password
await transporter.sendMail(mailOptions);
}
+
+export async function sendListInvitationEmail(
+ email: string,
+ inviterName: string,
+ listName: string,
+ listId: string,
+) {
+ if (!serverConfig.email.smtp) {
+ // Silently fail if email is not configured
+ return;
+ }
+
+ const transporter = createTransport({
+ host: serverConfig.email.smtp.host,
+ port: serverConfig.email.smtp.port,
+ secure: serverConfig.email.smtp.secure,
+ auth:
+ serverConfig.email.smtp.user && serverConfig.email.smtp.password
+ ? {
+ user: serverConfig.email.smtp.user,
+ pass: serverConfig.email.smtp.password,
+ }
+ : undefined,
+ });
+
+ const inviteUrl = `${serverConfig.publicUrl}/dashboard/lists?pendingInvitation=${encodeURIComponent(listId)}`;
+
+ const mailOptions = {
+ from: serverConfig.email.smtp.from,
+ to: email,
+ subject: `${inviterName} invited you to collaborate on "${listName}"`,
+ html: `
+ <div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
+ <h2>You've been invited to collaborate on a list!</h2>
+ <p>${inviterName} has invited you to collaborate on the list <strong>"${listName}"</strong> in Karakeep.</p>
+ <p>Click the link below to view and accept or decline the invitation:</p>
+ <p>
+ <a href="${inviteUrl}" style="background-color: #007bff; color: white; padding: 10px 20px; text-decoration: none; border-radius: 5px; display: inline-block;">
+ View Invitation
+ </a>
+ </p>
+ <p>If the button doesn't work, you can copy and paste this link into your browser:</p>
+ <p><a href="${inviteUrl}">${inviteUrl}</a></p>
+ <p>You can accept or decline this invitation from your Karakeep dashboard.</p>
+ <p>If you weren't expecting this invitation, you can safely ignore this email or decline it in your dashboard.</p>
+ </div>
+ `,
+ text: `
+You've been invited to collaborate on a list!
+
+${inviterName} has invited you to collaborate on the list "${listName}" in Karakeep.
+
+View your invitation by visiting this link:
+${inviteUrl}
+
+You can accept or decline this invitation from your Karakeep dashboard.
+
+If you weren't expecting this invitation, you can safely ignore this email or decline it in your dashboard.
+ `,
+ };
+
+ await transporter.sendMail(mailOptions);
+}
diff --git a/packages/trpc/models/listInvitations.ts b/packages/trpc/models/listInvitations.ts
new file mode 100644
index 00000000..6bdc8ffa
--- /dev/null
+++ b/packages/trpc/models/listInvitations.ts
@@ -0,0 +1,398 @@
+import { TRPCError } from "@trpc/server";
+import { and, eq } from "drizzle-orm";
+
+import { listCollaborators, listInvitations } from "@karakeep/db/schema";
+
+import type { AuthedContext } from "..";
+
+type Role = "viewer" | "editor";
+type InvitationStatus = "pending" | "declined";
+
+interface InvitationData {
+ id: string;
+ listId: string;
+ userId: string;
+ role: Role;
+ status: InvitationStatus;
+ invitedAt: Date;
+ invitedEmail: string | null;
+ invitedBy: string | null;
+ listOwnerUserId: string;
+}
+
+export class ListInvitation {
+ protected constructor(
+ protected ctx: AuthedContext,
+ protected invitation: InvitationData,
+ ) {}
+
+ get id() {
+ return this.invitation.id;
+ }
+
+ /**
+ * Load an invitation by ID
+ * Can be accessed by:
+ * - The invited user (userId matches)
+ * - The list owner (via list ownership check)
+ */
+ static async fromId(
+ ctx: AuthedContext,
+ invitationId: string,
+ ): Promise<ListInvitation> {
+ const invitation = await ctx.db.query.listInvitations.findFirst({
+ where: eq(listInvitations.id, invitationId),
+ with: {
+ list: {
+ columns: {
+ userId: true,
+ },
+ },
+ },
+ });
+
+ if (!invitation) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "Invitation not found",
+ });
+ }
+
+ // Check if user has access to this invitation
+ const isInvitedUser = invitation.userId === ctx.user.id;
+ const isListOwner = invitation.list.userId === ctx.user.id;
+
+ if (!isInvitedUser && !isListOwner) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "Invitation not found",
+ });
+ }
+
+ return new ListInvitation(ctx, {
+ id: invitation.id,
+ listId: invitation.listId,
+ userId: invitation.userId,
+ role: invitation.role,
+ status: invitation.status,
+ invitedAt: invitation.invitedAt,
+ invitedEmail: invitation.invitedEmail,
+ invitedBy: invitation.invitedBy,
+ listOwnerUserId: invitation.list.userId,
+ });
+ }
+
+ /**
+ * Ensure the current user is the invited user
+ */
+ ensureIsInvitedUser() {
+ if (this.invitation.userId !== this.ctx.user.id) {
+ throw new TRPCError({
+ code: "FORBIDDEN",
+ message: "Only the invited user can perform this action",
+ });
+ }
+ }
+
+ /**
+ * Ensure the current user is the list owner
+ */
+ ensureIsListOwner() {
+ if (this.invitation.listOwnerUserId !== this.ctx.user.id) {
+ throw new TRPCError({
+ code: "FORBIDDEN",
+ message: "Only the list owner can perform this action",
+ });
+ }
+ }
+
+ /**
+ * Accept the invitation
+ */
+ async accept(): Promise<void> {
+ this.ensureIsInvitedUser();
+
+ if (this.invitation.status !== "pending") {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "Only pending invitations can be accepted",
+ });
+ }
+
+ await this.ctx.db.transaction(async (tx) => {
+ await tx
+ .delete(listInvitations)
+ .where(eq(listInvitations.id, this.invitation.id));
+
+ await tx
+ .insert(listCollaborators)
+ .values({
+ listId: this.invitation.listId,
+ userId: this.invitation.userId,
+ role: this.invitation.role,
+ addedBy: this.invitation.invitedBy,
+ })
+ .onConflictDoNothing();
+ });
+ }
+
+ /**
+ * Decline the invitation
+ */
+ async decline(): Promise<void> {
+ this.ensureIsInvitedUser();
+
+ if (this.invitation.status !== "pending") {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "Only pending invitations can be declined",
+ });
+ }
+
+ await this.ctx.db
+ .update(listInvitations)
+ .set({
+ status: "declined",
+ })
+ .where(eq(listInvitations.id, this.invitation.id));
+ }
+
+ /**
+ * Revoke the invitation (owner only)
+ */
+ async revoke(): Promise<void> {
+ this.ensureIsListOwner();
+
+ await this.ctx.db
+ .delete(listInvitations)
+ .where(eq(listInvitations.id, this.invitation.id));
+ }
+
+ /**
+ * @returns the invitation ID
+ */
+ static async inviteByEmail(
+ ctx: AuthedContext,
+ params: {
+ email: string;
+ role: Role;
+ listId: string;
+ listName: string;
+ listType: "manual" | "smart";
+ listOwnerId: string;
+ inviterUserId: string;
+ inviterName: string | null;
+ },
+ ): Promise<string> {
+ const {
+ email,
+ role,
+ listId,
+ listName,
+ listType,
+ listOwnerId,
+ inviterUserId,
+ inviterName,
+ } = params;
+
+ const user = await ctx.db.query.users.findFirst({
+ where: (users, { eq }) => eq(users.email, email),
+ });
+
+ if (!user) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "No user found with that email address",
+ });
+ }
+
+ if (user.id === listOwnerId) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "Cannot add the list owner as a collaborator",
+ });
+ }
+
+ if (listType !== "manual") {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "Only manual lists can have collaborators",
+ });
+ }
+
+ const existingCollaborator = await ctx.db.query.listCollaborators.findFirst(
+ {
+ where: and(
+ eq(listCollaborators.listId, listId),
+ eq(listCollaborators.userId, user.id),
+ ),
+ },
+ );
+
+ if (existingCollaborator) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "User is already a collaborator on this list",
+ });
+ }
+
+ const existingInvitation = await ctx.db.query.listInvitations.findFirst({
+ where: and(
+ eq(listInvitations.listId, listId),
+ eq(listInvitations.userId, user.id),
+ ),
+ });
+
+ if (existingInvitation) {
+ if (existingInvitation.status === "pending") {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "User already has a pending invitation for this list",
+ });
+ } else if (existingInvitation.status === "declined") {
+ await ctx.db
+ .update(listInvitations)
+ .set({
+ status: "pending",
+ role,
+ invitedAt: new Date(),
+ invitedEmail: email,
+ invitedBy: inviterUserId,
+ })
+ .where(eq(listInvitations.id, existingInvitation.id));
+
+ await this.sendInvitationEmail({
+ email,
+ inviterName,
+ listName,
+ listId,
+ });
+ return existingInvitation.id;
+ }
+ }
+
+ const res = await ctx.db
+ .insert(listInvitations)
+ .values({
+ listId,
+ userId: user.id,
+ role,
+ status: "pending",
+ invitedEmail: email,
+ invitedBy: inviterUserId,
+ })
+ .returning();
+
+ await this.sendInvitationEmail({
+ email,
+ inviterName,
+ listName,
+ listId,
+ });
+ return res[0].id;
+ }
+
+ static async pendingForUser(ctx: AuthedContext) {
+ const invitations = await ctx.db.query.listInvitations.findMany({
+ where: and(
+ eq(listInvitations.userId, ctx.user.id),
+ eq(listInvitations.status, "pending"),
+ ),
+ with: {
+ list: {
+ columns: {
+ id: true,
+ name: true,
+ icon: true,
+ description: true,
+ rssToken: false,
+ },
+ with: {
+ user: {
+ columns: {
+ id: true,
+ name: true,
+ email: true,
+ },
+ },
+ },
+ },
+ },
+ });
+
+ return invitations.map((inv) => ({
+ id: inv.id,
+ listId: inv.listId,
+ role: inv.role,
+ invitedAt: inv.invitedAt,
+ list: {
+ id: inv.list.id,
+ name: inv.list.name,
+ icon: inv.list.icon,
+ description: inv.list.description,
+ owner: inv.list.user
+ ? {
+ id: inv.list.user.id,
+ name: inv.list.user.name,
+ email: inv.list.user.email,
+ }
+ : null,
+ },
+ }));
+ }
+
+ static async invitationsForList(
+ ctx: AuthedContext,
+ params: { listId: string },
+ ) {
+ const invitations = await ctx.db.query.listInvitations.findMany({
+ where: eq(listInvitations.listId, params.listId),
+ with: {
+ user: {
+ columns: {
+ id: true,
+ name: true,
+ email: true,
+ },
+ },
+ },
+ });
+
+ return invitations.map((invitation) => ({
+ id: invitation.id,
+ listId: invitation.listId,
+ userId: invitation.userId,
+ role: invitation.role,
+ status: invitation.status,
+ invitedAt: invitation.invitedAt,
+ addedAt: invitation.invitedAt,
+ user: {
+ id: invitation.user.id,
+ // Don't show the actual user's name for any invitation (pending or declined)
+ // This protects user privacy until they accept
+ name: "Pending User",
+ email: invitation.user.email || "",
+ },
+ }));
+ }
+
+ static async sendInvitationEmail(params: {
+ email: string;
+ inviterName: string | null;
+ listName: string;
+ listId: string;
+ }) {
+ try {
+ const { sendListInvitationEmail } = await import("../email");
+ await sendListInvitationEmail(
+ params.email,
+ params.inviterName || "A user",
+ params.listName,
+ params.listId,
+ );
+ } catch (error) {
+ // Log the error but don't fail the invitation
+ console.error("Failed to send list invitation email:", error);
+ }
+ }
+}
diff --git a/packages/trpc/models/lists.ts b/packages/trpc/models/lists.ts
index 2250819f..a0d9ca23 100644
--- a/packages/trpc/models/lists.ts
+++ b/packages/trpc/models/lists.ts
@@ -26,6 +26,7 @@ import { AuthedContext, Context } from "..";
import { buildImpersonatingAuthedContext } from "../lib/impersonate";
import { getBookmarkIdsFromMatcher } from "../lib/search";
import { Bookmark } from "./bookmarks";
+import { ListInvitation } from "./listInvitations";
interface ListCollaboratorEntry {
membershipId: string;
@@ -536,7 +537,7 @@ export abstract class List {
throw new TRPCError({ code: "NOT_FOUND" });
}
invariant(result[0].userId === this.ctx.user.id);
- // Fetch current collaborators count to update hasCollaborators
+ // Fetch current collaborators to update hasCollaborators
const collaboratorsCount =
await this.ctx.db.query.listCollaborators.findMany({
where: eq(listCollaborators.listId, this.list.id),
@@ -596,61 +597,24 @@ export abstract class List {
/**
* Add a collaborator to this list by email.
+ * Creates a pending invitation that must be accepted by the user.
+ * Returns the invitation ID.
*/
async addCollaboratorByEmail(
email: string,
role: "viewer" | "editor",
- ): Promise<void> {
+ ): Promise<string> {
this.ensureCanManage();
- // Look up the user by email
- const user = await this.ctx.db.query.users.findFirst({
- where: (users, { eq }) => eq(users.email, email),
- });
-
- if (!user) {
- throw new TRPCError({
- code: "NOT_FOUND",
- message: "No user found with that email address",
- });
- }
-
- // Check that the user is not adding themselves
- if (user.id === this.list.userId) {
- throw new TRPCError({
- code: "BAD_REQUEST",
- message: "Cannot add the list owner as a collaborator",
- });
- }
-
- // Check that the collaborator is not already added
- const existing = await this.ctx.db.query.listCollaborators.findFirst({
- where: and(
- eq(listCollaborators.listId, this.list.id),
- eq(listCollaborators.userId, user.id),
- ),
- });
-
- if (existing) {
- throw new TRPCError({
- code: "BAD_REQUEST",
- message: "User is already a collaborator on this list",
- });
- }
-
- // Only manual lists can be collaborative
- if (this.list.type !== "manual") {
- throw new TRPCError({
- code: "BAD_REQUEST",
- message: "Only manual lists can have collaborators",
- });
- }
-
- await this.ctx.db.insert(listCollaborators).values({
- listId: this.list.id,
- userId: user.id,
+ return await ListInvitation.inviteByEmail(this.ctx, {
+ email,
role,
- addedBy: this.ctx.user.id,
+ listId: this.list.id,
+ listName: this.list.name,
+ listType: this.list.type,
+ listOwnerId: this.list.userId,
+ inviterUserId: this.ctx.user.id,
+ inviterName: this.ctx.user.name ?? null,
});
}
@@ -738,23 +702,34 @@ export abstract class List {
}
/**
- * Get all collaborators for this list.
+ * Get all collaborators for this list, including pending invitations.
+ * For privacy, pending invitations show masked user info unless the invitation has been accepted.
*/
async getCollaborators() {
this.ensureCanView();
- const collaborators = await this.ctx.db.query.listCollaborators.findMany({
- where: eq(listCollaborators.listId, this.list.id),
- with: {
- user: {
- columns: {
- id: true,
- name: true,
- email: true,
+ const isOwner = this.list.userId === this.ctx.user.id;
+
+ const [collaborators, invitations] = await Promise.all([
+ this.ctx.db.query.listCollaborators.findMany({
+ where: eq(listCollaborators.listId, this.list.id),
+ with: {
+ user: {
+ columns: {
+ id: true,
+ name: true,
+ email: true,
+ },
},
},
- },
- });
+ }),
+ // Only show invitations for the owner
+ isOwner
+ ? ListInvitation.invitationsForList(this.ctx, {
+ listId: this.list.id,
+ })
+ : [],
+ ]);
// Get the owner information
const owner = await this.ctx.db.query.users.findFirst({
@@ -766,14 +741,24 @@ export abstract class List {
},
});
- return {
- collaborators: collaborators.map((c) => ({
+ const collaboratorEntries = collaborators.map((c) => {
+ return {
id: c.id,
userId: c.userId,
role: c.role,
+ status: "accepted" as const,
addedAt: c.addedAt,
- user: c.user,
- })),
+ invitedAt: c.addedAt,
+ user: {
+ id: c.user.id,
+ name: c.user.name,
+ email: c.user.email,
+ },
+ };
+ });
+
+ return {
+ collaborators: [...collaboratorEntries, ...invitations],
owner: owner
? {
id: owner.id,
@@ -786,6 +771,7 @@ export abstract class List {
/**
* Get all lists shared with the user (as a collaborator).
+ * Only includes lists where the invitation has been accepted.
*/
static async getSharedWithUser(
ctx: AuthedContext,
diff --git a/packages/trpc/routers/lists.ts b/packages/trpc/routers/lists.ts
index c9a19f30..5eb0baff 100644
--- a/packages/trpc/routers/lists.ts
+++ b/packages/trpc/routers/lists.ts
@@ -10,6 +10,7 @@ import {
import type { AuthedContext } from "../index";
import { authedProcedure, createRateLimitMiddleware, router } from "../index";
+import { ListInvitation } from "../models/listInvitations";
import { List } from "../models/lists";
import { ensureBookmarkOwnership } from "./bookmarks";
@@ -47,6 +48,22 @@ export const ensureListAtLeastOwner = experimental_trpcMiddleware<{
});
});
+export const ensureInvitationAccess = experimental_trpcMiddleware<{
+ ctx: AuthedContext;
+ input: { invitationId: string };
+}>().create(async (opts) => {
+ const invitation = await ListInvitation.fromId(
+ opts.ctx,
+ opts.input.invitationId,
+ );
+ return opts.next({
+ ctx: {
+ ...opts.ctx,
+ invitation,
+ },
+ });
+});
+
export const listsAppRouter = router({
create: authedProcedure
.input(zNewBookmarkListSchema)
@@ -218,6 +235,11 @@ export const listsAppRouter = router({
role: z.enum(["viewer", "editor"]),
}),
)
+ .output(
+ z.object({
+ invitationId: z.string(),
+ }),
+ )
.use(
createRateLimitMiddleware({
name: "lists.addCollaborator",
@@ -228,7 +250,12 @@ export const listsAppRouter = router({
.use(ensureListAtLeastViewer)
.use(ensureListAtLeastOwner)
.mutation(async ({ input, ctx }) => {
- await ctx.list.addCollaboratorByEmail(input.email, input.role);
+ return {
+ invitationId: await ctx.list.addCollaboratorByEmail(
+ input.email,
+ input.role,
+ ),
+ };
}),
removeCollaborator: authedProcedure
.input(
@@ -268,7 +295,9 @@ export const listsAppRouter = router({
id: z.string(),
userId: z.string(),
role: z.enum(["viewer", "editor"]),
+ status: z.enum(["pending", "accepted", "declined"]),
addedAt: z.date(),
+ invitedAt: z.date(),
user: z.object({
id: z.string(),
name: z.string(),
@@ -290,6 +319,67 @@ export const listsAppRouter = router({
return await ctx.list.getCollaborators();
}),
+ acceptInvitation: authedProcedure
+ .input(
+ z.object({
+ invitationId: z.string(),
+ }),
+ )
+ .use(ensureInvitationAccess)
+ .mutation(async ({ ctx }) => {
+ await ctx.invitation.accept();
+ }),
+
+ declineInvitation: authedProcedure
+ .input(
+ z.object({
+ invitationId: z.string(),
+ }),
+ )
+ .use(ensureInvitationAccess)
+ .mutation(async ({ ctx }) => {
+ await ctx.invitation.decline();
+ }),
+
+ revokeInvitation: authedProcedure
+ .input(
+ z.object({
+ invitationId: z.string(),
+ }),
+ )
+ .use(ensureInvitationAccess)
+ .mutation(async ({ ctx }) => {
+ await ctx.invitation.revoke();
+ }),
+
+ getPendingInvitations: authedProcedure
+ .output(
+ z.array(
+ z.object({
+ id: z.string(),
+ listId: z.string(),
+ role: z.enum(["viewer", "editor"]),
+ invitedAt: z.date(),
+ list: z.object({
+ id: z.string(),
+ name: z.string(),
+ icon: z.string(),
+ description: z.string().nullable(),
+ owner: z
+ .object({
+ id: z.string(),
+ name: z.string(),
+ email: z.string(),
+ })
+ .nullable(),
+ }),
+ }),
+ ),
+ )
+ .query(async ({ ctx }) => {
+ return ListInvitation.pendingForUser(ctx);
+ }),
+
leaveList: authedProcedure
.input(
z.object({
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);
});
});
});