import { describe, it, expect, vi, beforeEach } from "vitest"; import { initTRPC, TRPCError } from "@trpc/server"; import { wrap } from "@typeschema/valibot"; import { UpdateUserSchema, InviteMemberSchema, RemoveMemberSchema, UpdateRoleSchema } from "../schemas/user"; vi.mock("~/server/services/user.service", () => ({ getUserById: vi.fn(), updateUser: vi.fn(), deleteUser: vi.fn(), })); vi.mock("~/server/services/family.service", () => ({ getFamilyGroup: vi.fn(), inviteMember: vi.fn(), removeMember: vi.fn(), updateMemberRole: vi.fn(), })); import { getUserById, updateUser, deleteUser } from "~/server/services/user.service"; import { getFamilyGroup, inviteMember, removeMember, updateMemberRole } from "~/server/services/family.service"; const mockGetUserById = vi.mocked(getUserById); const mockUpdateUser = vi.mocked(updateUser); const mockDeleteUser = vi.mocked(deleteUser); const mockGetFamilyGroup = vi.mocked(getFamilyGroup); const mockInviteMember = vi.mocked(inviteMember); const mockRemoveMember = vi.mocked(removeMember); const mockUpdateMemberRole = vi.mocked(updateMemberRole); type User = { id: string; email: string; name: string | null; image: string | null; role: "user" | "family_admin" | "family_member" | "support"; emailVerified: Date | null; deletedAt: Date | null; stripeCustomerId: string | null; createdAt: Date; updatedAt: Date; }; type Ctx = { db: object; user: User | null; apiKey: string | null }; function createCaller(user: User | null) { const t = initTRPC.context().create(); const isAuthed = t.middleware(({ ctx, next }) => { if (!ctx.user) throw new TRPCError({ code: "UNAUTHORIZED" }); return next({ ctx: { ...ctx, user: ctx.user } }); }); const router = t.router({ me: t.procedure.use(isAuthed).query(async ({ ctx }) => { return mockGetUserById(ctx.user.id); }), update: t.procedure.use(isAuthed) .input(wrap(UpdateUserSchema)) .mutation(async ({ ctx, input }) => { return mockUpdateUser(ctx.user.id, input as Record); }), delete: t.procedure.use(isAuthed) .mutation(async ({ ctx }) => { await mockDeleteUser(ctx.user.id); return { success: true }; }), listFamilyMembers: t.procedure.use(isAuthed) .query(async ({ ctx }) => { const group = await mockGetFamilyGroup(ctx.user.id); return group.members; }), inviteFamilyMember: t.procedure.use(isAuthed) .input(wrap(InviteMemberSchema)) .mutation(async ({ ctx, input }) => { const group = await mockGetFamilyGroup(ctx.user.id); const callerMember = group.members.find( (m: { userId: string }) => m.userId === ctx.user.id, ); if (!callerMember || (callerMember.role !== "owner" && callerMember.role !== "admin")) { throw new TRPCError({ code: "FORBIDDEN" }); } const i = input as { email: string; role: "admin" | "member" }; return mockInviteMember(group.id, i.email, ctx.user.id, i.role); }), removeFamilyMember: t.procedure.use(isAuthed) .input(wrap(RemoveMemberSchema)) .mutation(async ({ ctx, input }) => { const group = await mockGetFamilyGroup(ctx.user.id); const i = input as { userId: string }; await mockRemoveMember(group.id, i.userId, ctx.user.id); return { success: true }; }), updateFamilyMemberRole: t.procedure.use(isAuthed) .input(wrap(UpdateRoleSchema)) .mutation(async ({ ctx, input }) => { const group = await mockGetFamilyGroup(ctx.user.id); const i = input as { userId: string; role: "owner" | "admin" | "member" }; return mockUpdateMemberRole(group.id, i.userId, i.role, ctx.user.id); }), }); const caller = t.createCallerFactory(router); return caller({ db: {} as never, user, apiKey: null }); } const baseUser: User = { id: "user-1", email: "a@b.com", name: "Test", image: null, role: "user", emailVerified: null, deletedAt: null, stripeCustomerId: null, createdAt: new Date(), updatedAt: new Date(), }; function makeUser(overrides: Partial = {}): User { return { ...baseUser, ...overrides }; } const now = new Date(); beforeEach(() => { vi.clearAllMocks(); }); describe("user.me", () => { it("returns authenticated user", async () => { mockGetUserById.mockResolvedValue({ ...makeUser(), accounts: [], sessions: [], deviceTokens: [], familyGroups: [], familyGroupOwned: [], subscriptions: [], }); const api = createCaller(makeUser()); expect((await api.me()).id).toBe("user-1"); }); it("rejects unauthenticated", async () => { const api = createCaller(null); await expect(api.me()).rejects.toThrow(TRPCError); }); }); describe("user.update", () => { it("updates name", async () => { mockUpdateUser.mockResolvedValue(makeUser({ name: "New" })); const api = createCaller(makeUser()); expect((await api.update({ name: "New" })).name).toBe("New"); }); it("updates email", async () => { mockUpdateUser.mockResolvedValue(makeUser({ email: "new@b.com" })); const api = createCaller(makeUser()); expect((await api.update({ email: "new@b.com" })).email).toBe("new@b.com"); }); }); describe("user.delete", () => { it("deletes user", async () => { mockDeleteUser.mockResolvedValue(makeUser({ deletedAt: now })); const api = createCaller(makeUser()); expect((await api.delete()).success).toBe(true); }); }); describe("user.listFamilyMembers", () => { it("returns family members", async () => { mockGetFamilyGroup.mockResolvedValue({ id: "g1", name: "Fam", ownerId: "u1", createdAt: now, updatedAt: now, members: [{ id: "m1", groupId: "g1", userId: "u1", role: "owner", joinedAt: now, createdAt: now, updatedAt: now, user: { id: "u1", email: "a@b.com", name: "A", image: null, role: "user" } as unknown as User, }], owner: makeUser(), }); const api = createCaller(makeUser()); const result = await api.listFamilyMembers(); expect(result).toHaveLength(1); expect(result[0].role).toBe("owner"); }); }); describe("user.inviteFamilyMember", () => { it("creates invitation", async () => { mockGetFamilyGroup.mockResolvedValue({ id: "g1", name: "Fam", ownerId: "user-1", createdAt: now, updatedAt: now, members: [{ id: "m1", groupId: "g1", userId: "user-1", role: "owner", joinedAt: now, createdAt: now, updatedAt: now, user: { id: "user-1", email: "a@b.com", name: "A", image: null, role: "user" } as unknown as User, }], owner: makeUser(), }); mockInviteMember.mockResolvedValue({ id: "i1", groupId: "g1", email: "new@b.com", role: "member", invitedBy: "user-1", status: "pending", expiresAt: now, createdAt: now, updatedAt: now, }); const api = createCaller(makeUser()); expect((await api.inviteFamilyMember({ email: "new@b.com" })).status).toBe("pending"); }); it("rejects non-admin", async () => { mockGetFamilyGroup.mockResolvedValue({ id: "g1", name: "Fam", ownerId: "u1", createdAt: now, updatedAt: now, members: [{ id: "m1", groupId: "g1", userId: "u2", role: "member", joinedAt: now, createdAt: now, updatedAt: now, user: { id: "u2", email: "b@b.com", name: "B", image: null, role: "user" } as unknown as User, }], owner: makeUser(), }); const api = createCaller(makeUser({ id: "u2" })); await expect(api.inviteFamilyMember({ email: "new@b.com" })).rejects.toThrow(TRPCError); }); }); describe("user.removeFamilyMember", () => { it("removes a member", async () => { mockGetFamilyGroup.mockResolvedValue({ id: "g1", name: "Fam", ownerId: "u1", createdAt: now, updatedAt: now, members: [{ id: "m1", groupId: "g1", userId: "u1", role: "owner", joinedAt: now, createdAt: now, updatedAt: now, user: { id: "u1", email: "a@b.com", name: "A", image: null, role: "user" } as unknown as User, }], owner: makeUser(), }); mockRemoveMember.mockResolvedValue(undefined); const api = createCaller(makeUser()); expect((await api.removeFamilyMember({ userId: "u3" })).success).toBe(true); }); }); describe("user.updateFamilyMemberRole", () => { it("updates role", async () => { mockGetFamilyGroup.mockResolvedValue({ id: "g1", name: "Fam", ownerId: "u1", createdAt: now, updatedAt: now, members: [{ id: "m1", groupId: "g1", userId: "u1", role: "owner", joinedAt: now, createdAt: now, updatedAt: now, user: { id: "u1", email: "a@b.com", name: "A", image: null, role: "user" } as unknown as User, }], owner: makeUser(), }); mockUpdateMemberRole.mockResolvedValue({ id: "m2", groupId: "g1", userId: "u2", role: "admin", joinedAt: now, createdAt: now, updatedAt: now, }); const api = createCaller(makeUser()); expect( (await api.updateFamilyMemberRole({ userId: "u2", role: "admin" })).role, ).toBe("admin"); }); });