diff --git a/__tests__/lib/trpc/routers/auth.test.ts b/__tests__/lib/trpc/routers/auth.test.ts new file mode 100644 index 00000000..c1e1dc0b --- /dev/null +++ b/__tests__/lib/trpc/routers/auth.test.ts @@ -0,0 +1,272 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { createMockTRPCContext } from '@/__tests__/setup/mocks'; +import { expectTRPCError } from '@/__tests__/setup/utils'; + +import { TEST_OTP } from '@/lib/auth/constants'; +import { auth } from '@/lib/auth/providers'; +import { + clearSignInAttempt, + createSignInAttempt, + getCurrentSignInAttempt, + isTestEmail, +} from '@/lib/auth/utils'; +import { db } from '@/lib/db/drizzle'; +import { createUser, getUserByEmail } from '@/lib/services'; +import { createCaller } from '@/lib/trpc/server'; + +vi.mock('@/lib/auth/providers', () => ({ + auth: { + api: { + sendVerificationOTP: vi.fn(), + }, + }, +})); + +vi.mock('@/lib/auth/utils', () => ({ + clearSignInAttempt: vi.fn(), + createSignInAttempt: vi.fn(), + getCurrentSignInAttempt: vi.fn(), + isTestEmail: vi.fn(() => false), +})); + +vi.mock('@/lib/services', () => ({ + createUser: vi.fn(), + getUserByEmail: vi.fn(), +})); + +vi.mock('@/lib/db/drizzle', () => ({ + db: { + update: vi.fn(), + }, +})); + +describe('Auth Router', () => { + const email = 'someone@example.com'; + const existingUser = { + id: 'user_123', + email, + emailVerified: true, + displayName: 'Someone', + createdAt: new Date('2026-01-01'), + updatedAt: new Date('2026-01-01'), + }; + + // db.update(verifications).set({ value }).where(...) + const setSpy = vi.fn(); + const whereSpy = vi.fn(); + + // The whole router is public: every caller below is deliberately unauthenticated. + const caller = () => createCaller(createMockTRPCContext({ userId: null })); + + beforeEach(() => { + vi.clearAllMocks(); + + whereSpy.mockResolvedValue(undefined); + setSpy.mockReturnValue({ where: whereSpy }); + vi.mocked(db.update).mockReturnValue({ set: setSpy } as never); + + vi.mocked(isTestEmail).mockReturnValue(false); + vi.mocked(auth.api.sendVerificationOTP).mockResolvedValue({ success: true } as never); + vi.mocked(createSignInAttempt).mockResolvedValue(email); + }); + + describe('requestOtp - sign-in', () => { + it('sends an OTP and records the attempt for an existing user', async () => { + vi.mocked(getUserByEmail).mockResolvedValue(existingUser as never); + + const result = await (await caller()).auth.requestOtp({ email, type: 'sign-in' }); + + expect(result).toEqual({ success: true }); + expect(auth.api.sendVerificationOTP).toHaveBeenCalledWith({ + body: { email, type: 'sign-in' }, + }); + expect(createSignInAttempt).toHaveBeenCalledWith(email); + }); + + it('rejects sign-in for an unknown email with NOT_FOUND', async () => { + // getUserByEmail is typed as non-nullable even though it returns null at runtime. + vi.mocked(getUserByEmail).mockResolvedValue(null as never); + + await expectTRPCError( + (await caller()).auth.requestOtp({ email, type: 'sign-in' }), + 'NOT_FOUND', + 'User not found' + ); + + // The distinct NOT_FOUND response is what makes this endpoint enumerable. + expect(auth.api.sendVerificationOTP).not.toHaveBeenCalled(); + expect(createSignInAttempt).not.toHaveBeenCalled(); + }); + + it('pins the verification code to the fixed test OTP for test emails', async () => { + vi.mocked(getUserByEmail).mockResolvedValue(existingUser as never); + vi.mocked(isTestEmail).mockReturnValue(true); + + await (await caller()).auth.requestOtp({ email, type: 'sign-in' }); + + expect(db.update).toHaveBeenCalled(); + expect(setSpy).toHaveBeenCalledWith({ value: TEST_OTP }); + expect(whereSpy).toHaveBeenCalled(); + }); + + it('does not touch the verifications table for a normal email', async () => { + vi.mocked(getUserByEmail).mockResolvedValue(existingUser as never); + + await (await caller()).auth.requestOtp({ email, type: 'sign-in' }); + + expect(db.update).not.toHaveBeenCalled(); + }); + + it('is reachable without a session', async () => { + vi.mocked(getUserByEmail).mockResolvedValue(existingUser as never); + + await expect((await caller()).auth.requestOtp({ email, type: 'sign-in' })).resolves.toEqual({ + success: true, + }); + }); + }); + + describe('requestOtp - sign-up', () => { + it('creates an unverified user and sends the verification OTP', async () => { + // getUserByEmail is typed as non-nullable even though it returns null at runtime. + vi.mocked(getUserByEmail).mockResolvedValue(null as never); + vi.mocked(createUser).mockResolvedValue(existingUser as never); + + const result = await ( + await caller() + ).auth.requestOtp({ + email, + type: 'email-verification', + terms: true, + marketing: true, + }); + + expect(result).toEqual({ success: true }); + expect(createUser).toHaveBeenCalledWith({ + email, + emailVerified: false, + displayName: '', + notificationSettings: { + emailNotifications: false, + marketingEmails: true, + securityAlerts: false, + }, + }); + expect(auth.api.sendVerificationOTP).toHaveBeenCalledWith({ + body: { email, type: 'email-verification' }, + }); + expect(createSignInAttempt).toHaveBeenCalledWith(email); + }); + + it('defaults marketing consent to false when it is omitted', async () => { + // getUserByEmail is typed as non-nullable even though it returns null at runtime. + vi.mocked(getUserByEmail).mockResolvedValue(null as never); + vi.mocked(createUser).mockResolvedValue(existingUser as never); + + await (await caller()).auth.requestOtp({ email, type: 'email-verification', terms: true }); + + expect(createUser).toHaveBeenCalledWith( + expect.objectContaining({ + notificationSettings: expect.objectContaining({ marketingEmails: false }), + }) + ); + }); + + it('rejects sign-up when the email is already registered', async () => { + vi.mocked(getUserByEmail).mockResolvedValue(existingUser as never); + + await expectTRPCError( + (await caller()).auth.requestOtp({ email, type: 'email-verification', terms: true }), + 'BAD_REQUEST', + 'User already exists' + ); + + expect(createUser).not.toHaveBeenCalled(); + expect(auth.api.sendVerificationOTP).not.toHaveBeenCalled(); + }); + + it('rejects sign-up when terms are not accepted', async () => { + await expectTRPCError( + (await caller()).auth.requestOtp({ + email, + type: 'email-verification', + terms: false, + }), + 'BAD_REQUEST', + 'You must agree to the terms of service' + ); + + expect(getUserByEmail).not.toHaveBeenCalled(); + }); + }); + + describe('requestOtp - input validation', () => { + it('rejects a malformed email address', async () => { + await expectTRPCError( + (await caller()).auth.requestOtp({ email: 'not-an-email', type: 'sign-in' }), + 'BAD_REQUEST', + 'Invalid email address' + ); + expect(getUserByEmail).not.toHaveBeenCalled(); + }); + + it('rejects an unknown OTP type', async () => { + await expectTRPCError( + // @ts-expect-error - deliberately invalid discriminator + (await caller()).auth.requestOtp({ email, type: 'password-reset' }), + 'BAD_REQUEST' + ); + expect(getUserByEmail).not.toHaveBeenCalled(); + }); + }); + + describe('getCurrentSignInAttempt', () => { + it('returns the pending email when an attempt cookie exists', async () => { + vi.mocked(getCurrentSignInAttempt).mockResolvedValue({ email }); + + const result = await (await caller()).auth.getCurrentSignInAttempt(); + + expect(result).toEqual({ success: true, email }); + }); + + it('reports failure without an email when no attempt exists', async () => { + vi.mocked(getCurrentSignInAttempt).mockResolvedValue(null); + + const result = await (await caller()).auth.getCurrentSignInAttempt(); + + expect(result).toEqual({ success: false }); + }); + + it('wraps a cookie read failure as INTERNAL_SERVER_ERROR', async () => { + vi.mocked(getCurrentSignInAttempt).mockRejectedValue(new Error('cookies unavailable')); + + await expectTRPCError( + (await caller()).auth.getCurrentSignInAttempt(), + 'INTERNAL_SERVER_ERROR', + 'Failed to fetch sign-in attempt' + ); + }); + }); + + describe('clearSignInAttempt', () => { + it('clears the attempt cookie', async () => { + vi.mocked(clearSignInAttempt).mockResolvedValue(undefined); + + const result = await (await caller()).auth.clearSignInAttempt(); + + expect(result).toEqual({ success: true }); + expect(clearSignInAttempt).toHaveBeenCalled(); + }); + + it('wraps a cookie write failure as INTERNAL_SERVER_ERROR', async () => { + vi.mocked(clearSignInAttempt).mockRejectedValue(new Error('cookies unavailable')); + + await expectTRPCError( + (await caller()).auth.clearSignInAttempt(), + 'INTERNAL_SERVER_ERROR', + 'Failed to clear sign-in attempt' + ); + }); + }); +}); diff --git a/__tests__/lib/trpc/routers/chat.test.ts b/__tests__/lib/trpc/routers/chat.test.ts new file mode 100644 index 00000000..e2829135 --- /dev/null +++ b/__tests__/lib/trpc/routers/chat.test.ts @@ -0,0 +1,411 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { createMockTRPCContext } from '@/__tests__/setup/mocks'; +import { expectTRPCError } from '@/__tests__/setup/utils'; + +import { db } from '@/lib/db/drizzle'; +import { createCaller } from '@/lib/trpc/server'; + +vi.mock('@/lib/db/drizzle', () => ({ + db: { + query: { + orgMemberships: { findFirst: vi.fn() }, + chatSessions: { findFirst: vi.fn() }, + }, + select: vi.fn(), + insert: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + }, +})); + +describe('Chat Router', () => { + const userId = 'user_123'; + const organizationId = '22222222-2222-4222-8222-222222222222'; + const otherOrganizationId = '33333333-3333-4333-8333-333333333333'; + const chatSessionId = '44444444-4444-4444-8444-444444444444'; + + const mockSession = { + id: chatSessionId, + organizationId, + userId, + title: 'Quarterly review', + createdAt: new Date('2026-01-01'), + updatedAt: new Date('2026-01-02'), + }; + + const membership = { + id: 'membership_1', + organizationId, + userId, + role: 'member' as const, + createdAt: new Date('2026-01-01'), + }; + + const memberCaller = () => createCaller(createMockTRPCContext({ userId })); + const anonCaller = () => createCaller(createMockTRPCContext({ userId: null })); + + /** db.select({ count }).from().where() -> rows */ + const mockCountQuery = (rows: unknown[]) => ({ + from: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue(rows) }), + }); + + /** db.select().from().where().orderBy().limit().offset() -> rows */ + const mockPaginatedQuery = (rows: unknown[]) => ({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + orderBy: vi.fn().mockReturnValue({ + limit: vi.fn().mockReturnValue({ offset: vi.fn().mockResolvedValue(rows) }), + }), + }), + }), + }); + + /** db.select().from().where().orderBy() -> rows */ + const mockOrderedQuery = (rows: unknown[]) => ({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ orderBy: vi.fn().mockResolvedValue(rows) }), + }), + }); + + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(db.query.orgMemberships.findFirst).mockResolvedValue(membership); + }); + + describe('orgProcedure authorization', () => { + it('rejects an unauthenticated caller', async () => { + const caller = await anonCaller(); + + await expectTRPCError(caller.chat.listSessions({ organizationId }), 'UNAUTHORIZED'); + expect(db.query.orgMemberships.findFirst).not.toHaveBeenCalled(); + }); + + it('rejects a caller with no membership in the requested organization', async () => { + vi.mocked(db.query.orgMemberships.findFirst).mockResolvedValue(undefined); + + const caller = await memberCaller(); + + await expectTRPCError( + caller.chat.listSessions({ organizationId: otherOrganizationId }), + 'FORBIDDEN', + 'do not have access to this organization' + ); + expect(db.select).not.toHaveBeenCalled(); + }); + }); + + describe('listSessions', () => { + it('returns the caller own sessions with pagination metadata', async () => { + vi.mocked(db.select) + .mockReturnValueOnce(mockCountQuery([{ count: 3 }]) as never) + .mockReturnValueOnce(mockPaginatedQuery([mockSession]) as never); + + const caller = await memberCaller(); + const result = await caller.chat.listSessions({ organizationId, page: 2, pageSize: 2 }); + + expect(result).toEqual({ + sessions: [mockSession], + total: 3, + page: 2, + pageSize: 2, + totalPages: 2, + }); + }); + + it('applies the schema defaults when page and pageSize are omitted', async () => { + vi.mocked(db.select) + .mockReturnValueOnce(mockCountQuery([{ count: 0 }]) as never) + .mockReturnValueOnce(mockPaginatedQuery([]) as never); + + const caller = await memberCaller(); + const result = await caller.chat.listSessions({ organizationId }); + + expect(result.page).toBe(1); + expect(result.pageSize).toBe(20); + expect(result.totalPages).toBe(0); + }); + + it('rejects a pageSize above the 100 maximum', async () => { + const caller = await memberCaller(); + + await expectTRPCError( + caller.chat.listSessions({ organizationId, pageSize: 101 }), + 'BAD_REQUEST' + ); + expect(db.select).not.toHaveBeenCalled(); + }); + }); + + describe('getSession', () => { + it('returns a session owned by the caller', async () => { + vi.mocked(db.query.chatSessions.findFirst).mockResolvedValue(mockSession as never); + + const caller = await memberCaller(); + const result = await caller.chat.getSession({ organizationId, chatSessionId }); + + expect(result).toEqual(mockSession); + }); + + it('returns NOT_FOUND for a session belonging to another member', async () => { + // The query filters on userId too, so another member's session simply does not resolve. + vi.mocked(db.query.chatSessions.findFirst).mockResolvedValue(undefined); + + const caller = await memberCaller(); + + await expectTRPCError( + caller.chat.getSession({ organizationId, chatSessionId }), + 'NOT_FOUND', + 'Chat session not found' + ); + }); + + it('rejects a non-uuid chatSessionId', async () => { + const caller = await memberCaller(); + + await expectTRPCError( + caller.chat.getSession({ organizationId, chatSessionId: 'nope' }), + 'BAD_REQUEST' + ); + expect(db.query.chatSessions.findFirst).not.toHaveBeenCalled(); + }); + }); + + describe('getMessages', () => { + it('parses stored parts and metadata into UI messages', async () => { + vi.mocked(db.query.chatSessions.findFirst).mockResolvedValue(mockSession as never); + vi.mocked(db.select).mockReturnValueOnce( + mockOrderedQuery([ + { + id: 'message_1', + role: 'assistant', + parts: JSON.stringify([{ type: 'text', text: 'Hello' }]), + metadata: JSON.stringify({ + sources: [{ documentId: 'doc_1', title: 'Report', url: '/api/documents/doc_1' }], + }), + createdAt: new Date('2026-01-03'), + }, + ]) as never + ); + + const caller = await memberCaller(); + const result = await caller.chat.getMessages({ organizationId, chatSessionId }); + + expect(result.messages).toEqual([ + { + id: 'message_1', + role: 'assistant', + parts: [{ type: 'text', text: 'Hello' }], + metadata: { + sources: [{ documentId: 'doc_1', title: 'Report', url: '/api/documents/doc_1' }], + }, + createdAt: new Date('2026-01-03'), + }, + ]); + }); + + it('leaves metadata undefined when the row has none', async () => { + vi.mocked(db.query.chatSessions.findFirst).mockResolvedValue(mockSession as never); + vi.mocked(db.select).mockReturnValueOnce( + mockOrderedQuery([ + { + id: 'message_1', + role: 'user', + parts: JSON.stringify([{ type: 'text', text: 'Hi' }]), + metadata: null, + createdAt: new Date('2026-01-03'), + }, + ]) as never + ); + + const caller = await memberCaller(); + const result = await caller.chat.getMessages({ organizationId, chatSessionId }); + + expect(result.messages[0].metadata).toBeUndefined(); + }); + + it('returns NOT_FOUND without reading messages when the session does not resolve', async () => { + vi.mocked(db.query.chatSessions.findFirst).mockResolvedValue(undefined); + + const caller = await memberCaller(); + + await expectTRPCError( + caller.chat.getMessages({ organizationId, chatSessionId }), + 'NOT_FOUND', + 'Chat session not found' + ); + expect(db.select).not.toHaveBeenCalled(); + }); + + it('fails with INTERNAL_SERVER_ERROR when stored parts are not valid JSON', async () => { + vi.mocked(db.query.chatSessions.findFirst).mockResolvedValue(mockSession as never); + vi.mocked(db.select).mockReturnValueOnce( + mockOrderedQuery([ + { + id: 'message_1', + role: 'assistant', + parts: '{not json', + metadata: null, + createdAt: new Date('2026-01-03'), + }, + ]) as never + ); + + const caller = await memberCaller(); + + await expectTRPCError( + caller.chat.getMessages({ organizationId, chatSessionId }), + 'INTERNAL_SERVER_ERROR', + 'Failed to parse message data' + ); + }); + + it('fails with INTERNAL_SERVER_ERROR when metadata does not match the source schema', async () => { + vi.mocked(db.query.chatSessions.findFirst).mockResolvedValue(mockSession as never); + vi.mocked(db.select).mockReturnValueOnce( + mockOrderedQuery([ + { + id: 'message_1', + role: 'assistant', + parts: JSON.stringify([{ type: 'text', text: 'Hello' }]), + metadata: JSON.stringify({ sources: [{ documentId: 'doc_1' }] }), + createdAt: new Date('2026-01-03'), + }, + ]) as never + ); + + const caller = await memberCaller(); + + await expectTRPCError( + caller.chat.getMessages({ organizationId, chatSessionId }), + 'INTERNAL_SERVER_ERROR', + 'Failed to parse message data' + ); + }); + }); + + describe('createSession', () => { + it('creates a session owned by the caller in the requested organization', async () => { + const valuesSpy = vi.fn().mockReturnValue({ + returning: vi.fn().mockResolvedValue([mockSession]), + }); + vi.mocked(db.insert).mockReturnValue({ values: valuesSpy } as never); + + const caller = await memberCaller(); + const result = await caller.chat.createSession({ organizationId, title: 'Quarterly review' }); + + expect(result).toEqual(mockSession); + expect(valuesSpy).toHaveBeenCalledWith({ + organizationId, + userId, + title: 'Quarterly review', + }); + }); + + it('defaults the title to "New Chat"', async () => { + const valuesSpy = vi.fn().mockReturnValue({ + returning: vi.fn().mockResolvedValue([{ ...mockSession, title: 'New Chat' }]), + }); + vi.mocked(db.insert).mockReturnValue({ values: valuesSpy } as never); + + const caller = await memberCaller(); + await caller.chat.createSession({ organizationId }); + + expect(valuesSpy).toHaveBeenCalledWith(expect.objectContaining({ title: 'New Chat' })); + }); + + it('rejects an empty title', async () => { + const caller = await memberCaller(); + + await expectTRPCError( + caller.chat.createSession({ organizationId, title: '' }), + 'BAD_REQUEST' + ); + expect(db.insert).not.toHaveBeenCalled(); + }); + + it('rejects a non-member', async () => { + vi.mocked(db.query.orgMemberships.findFirst).mockResolvedValue(undefined); + + const caller = await memberCaller(); + + await expectTRPCError(caller.chat.createSession({ organizationId }), 'FORBIDDEN'); + expect(db.insert).not.toHaveBeenCalled(); + }); + }); + + describe('deleteSession', () => { + it('deletes the session and reports success', async () => { + const whereSpy = vi.fn().mockResolvedValue(undefined); + vi.mocked(db.delete).mockReturnValue({ where: whereSpy } as never); + + const caller = await memberCaller(); + const result = await caller.chat.deleteSession({ organizationId, chatSessionId }); + + expect(result).toEqual({ success: true }); + expect(whereSpy).toHaveBeenCalledTimes(1); + }); + + it('reports success even when the session does not exist', async () => { + // Documents current behavior: the delete is not preceded by an existence check. + const whereSpy = vi.fn().mockResolvedValue(undefined); + vi.mocked(db.delete).mockReturnValue({ where: whereSpy } as never); + + const caller = await memberCaller(); + + await expect(caller.chat.deleteSession({ organizationId, chatSessionId })).resolves.toEqual({ + success: true, + }); + expect(db.query.chatSessions.findFirst).not.toHaveBeenCalled(); + }); + + it('rejects a non-member', async () => { + vi.mocked(db.query.orgMemberships.findFirst).mockResolvedValue(undefined); + + const caller = await memberCaller(); + + await expectTRPCError( + caller.chat.deleteSession({ organizationId, chatSessionId }), + 'FORBIDDEN' + ); + expect(db.delete).not.toHaveBeenCalled(); + }); + }); + + describe('updateSession', () => { + it('renames the session and bumps updatedAt', async () => { + const setSpy = vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue(undefined) }); + vi.mocked(db.update).mockReturnValue({ set: setSpy } as never); + + const caller = await memberCaller(); + const result = await caller.chat.updateSession({ + organizationId, + chatSessionId, + title: 'Renamed', + }); + + expect(result).toEqual({ success: true }); + expect(setSpy).toHaveBeenCalledWith({ title: 'Renamed', updatedAt: expect.any(Date) }); + }); + + it('rejects a title longer than 255 characters', async () => { + const caller = await memberCaller(); + + await expectTRPCError( + caller.chat.updateSession({ organizationId, chatSessionId, title: 'x'.repeat(256) }), + 'BAD_REQUEST' + ); + expect(db.update).not.toHaveBeenCalled(); + }); + + it('rejects an unauthenticated caller', async () => { + const caller = await anonCaller(); + + await expectTRPCError( + caller.chat.updateSession({ organizationId, chatSessionId, title: 'Renamed' }), + 'UNAUTHORIZED' + ); + expect(db.update).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/__tests__/lib/trpc/routers/documents.test.ts b/__tests__/lib/trpc/routers/documents.test.ts new file mode 100644 index 00000000..1a8d3f98 --- /dev/null +++ b/__tests__/lib/trpc/routers/documents.test.ts @@ -0,0 +1,530 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { createMockTRPCContext } from '@/__tests__/setup/mocks'; +import { expectTRPCError } from '@/__tests__/setup/utils'; + +import { deleteDocumentFromFileSearchStore, deleteFileSearchStore } from '@/lib/ai/rag'; +import { db } from '@/lib/db/drizzle'; +import { addIndexDocumentJob } from '@/lib/queue/queues/documents'; +import { deleteDocument, getPresignedDownloadUrl, uploadDocument } from '@/lib/storage'; +import { createCaller } from '@/lib/trpc/server'; + +vi.mock('@/lib/db/drizzle', () => ({ + db: { + query: { + orgMemberships: { findFirst: vi.fn() }, + documents: { findFirst: vi.fn() }, + organizations: { findFirst: vi.fn() }, + }, + select: vi.fn(), + insert: vi.fn(), + delete: vi.fn(), + }, +})); + +vi.mock('@/lib/storage', () => ({ + uploadDocument: vi.fn(), + deleteDocument: vi.fn(), + getPresignedDownloadUrl: vi.fn(), +})); + +vi.mock('@/lib/ai/rag', () => ({ + deleteDocumentFromFileSearchStore: vi.fn(), + deleteFileSearchStore: vi.fn(), +})); + +vi.mock('@/lib/queue/queues/documents', () => ({ + addIndexDocumentJob: vi.fn(), +})); + +describe('Documents Router', () => { + const userId = 'user_123'; + const otherUserId = 'user_456'; + const organizationId = '22222222-2222-4222-8222-222222222222'; + const otherOrganizationId = '33333333-3333-4333-8333-333333333333'; + const documentId = '44444444-4444-4444-8444-444444444444'; + + const mockDocument = { + id: documentId, + organizationId, + userId, + displayName: 'Quarterly report.pdf', + mimeType: 'application/pdf', + sizeBytes: '1024', + storageUrl: 'https://s3.example.com/org/doc.pdf', + status: 'ready' as const, + documentResourceName: null, + fileSearchStoreName: null, + createdAt: new Date('2026-01-01'), + updatedAt: new Date('2026-01-01'), + }; + + const membershipAs = (role: 'owner' | 'admin' | 'member') => ({ + id: 'membership_1', + organizationId, + userId, + role, + createdAt: new Date('2026-01-01'), + }); + + const memberCaller = () => createCaller(createMockTRPCContext({ userId })); + const anonCaller = () => createCaller(createMockTRPCContext({ userId: null })); + + /** db.select({ count }).from().where() -> rows */ + const mockCountQuery = (rows: unknown[]) => ({ + from: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue(rows) }), + }); + + /** db.select().from().leftJoin().where().orderBy().limit().offset() -> rows */ + const mockJoinedQuery = (rows: unknown[]) => ({ + from: vi.fn().mockReturnValue({ + leftJoin: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + orderBy: vi.fn().mockReturnValue({ + limit: vi.fn().mockReturnValue({ offset: vi.fn().mockResolvedValue(rows) }), + }), + }), + }), + }), + }); + + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(db.query.orgMemberships.findFirst).mockResolvedValue(membershipAs('member')); + }); + + describe('orgProcedure authorization', () => { + it('rejects an unauthenticated caller', async () => { + const caller = await anonCaller(); + + await expectTRPCError(caller.documents.list({ organizationId }), 'UNAUTHORIZED'); + expect(db.query.orgMemberships.findFirst).not.toHaveBeenCalled(); + }); + + it('rejects a caller with no membership in the requested organization', async () => { + vi.mocked(db.query.orgMemberships.findFirst).mockResolvedValue(undefined); + + const caller = await memberCaller(); + + await expectTRPCError( + caller.documents.list({ organizationId: otherOrganizationId }), + 'FORBIDDEN', + 'do not have access to this organization' + ); + expect(db.select).not.toHaveBeenCalled(); + }); + }); + + describe('list', () => { + const documentRow = { + id: documentId, + displayName: 'Quarterly report.pdf', + sizeBytes: '1024', + status: 'ready', + createdAt: new Date('2026-01-01'), + userDisplayName: 'John Doe', + storageUrl: 'https://s3.example.com/org/doc.pdf', + }; + + it('returns documents with pagination metadata', async () => { + vi.mocked(db.select) + .mockReturnValueOnce( + mockCountQuery([{ count: documentId }, { count: documentId }]) as never + ) + .mockReturnValueOnce(mockJoinedQuery([documentRow]) as never); + + const caller = await memberCaller(); + const result = await caller.documents.list({ organizationId, pageSize: 1 }); + + expect(result).toEqual({ + documents: [documentRow], + total: 2, + page: 1, + pageSize: 1, + totalPages: 2, + }); + }); + + it('adds a name filter when a search query is supplied', async () => { + const countQuery = mockCountQuery([]); + vi.mocked(db.select) + .mockReturnValueOnce(countQuery as never) + .mockReturnValueOnce(mockJoinedQuery([]) as never); + + const caller = await memberCaller(); + await caller.documents.list({ organizationId, searchQuery: ' report ' }); + + // Two conditions (organization scope + ilike on displayName) are combined into the where(). + expect(countQuery.from).toHaveBeenCalled(); + }); + + it('ignores a whitespace-only search query', async () => { + vi.mocked(db.select) + .mockReturnValueOnce(mockCountQuery([]) as never) + .mockReturnValueOnce(mockJoinedQuery([]) as never); + + const caller = await memberCaller(); + const result = await caller.documents.list({ organizationId, searchQuery: ' ' }); + + expect(result.total).toBe(0); + }); + + it('rejects a page number of zero', async () => { + const caller = await memberCaller(); + + await expectTRPCError(caller.documents.list({ organizationId, page: 0 }), 'BAD_REQUEST'); + expect(db.select).not.toHaveBeenCalled(); + }); + }); + + describe('getDownloadUrl', () => { + it('returns a presigned url for a document in the organization', async () => { + vi.mocked(db.query.documents.findFirst).mockResolvedValue(mockDocument as never); + vi.mocked(getPresignedDownloadUrl).mockResolvedValue('https://signed.example.com/doc.pdf'); + + const caller = await memberCaller(); + const result = await caller.documents.getDownloadUrl({ id: documentId, organizationId }); + + expect(result).toEqual({ + url: 'https://signed.example.com/doc.pdf', + displayName: 'Quarterly report.pdf', + }); + expect(getPresignedDownloadUrl).toHaveBeenCalledWith(mockDocument.storageUrl); + }); + + it('returns NOT_FOUND for a document outside the organization', async () => { + vi.mocked(db.query.documents.findFirst).mockResolvedValue(undefined); + + const caller = await memberCaller(); + + await expectTRPCError( + caller.documents.getDownloadUrl({ id: documentId, organizationId }), + 'NOT_FOUND', + 'Document not found' + ); + expect(getPresignedDownloadUrl).not.toHaveBeenCalled(); + }); + + it('rejects a non-uuid document id', async () => { + const caller = await memberCaller(); + + await expectTRPCError( + caller.documents.getDownloadUrl({ id: 'nope', organizationId }), + 'BAD_REQUEST' + ); + }); + }); + + describe('upload', () => { + const validInput = { + organizationId, + displayName: 'notes.txt', + mimeType: 'text/plain', + sizeBytes: '1024', + fileData: 'data:text/plain;base64,aGVsbG8=', + }; + + const mockInsert = () => { + const valuesSpy = vi.fn().mockReturnValue({ + returning: vi.fn().mockResolvedValue([{ id: documentId, status: 'in_progress' }]), + }); + vi.mocked(db.insert).mockReturnValue({ values: valuesSpy } as never); + return valuesSpy; + }; + + it('uploads to storage, records the row and queues indexing', async () => { + vi.mocked(db.query.organizations.findFirst).mockResolvedValue({ + id: organizationId, + } as never); + vi.mocked(uploadDocument).mockResolvedValue('https://s3.example.com/org/notes.txt'); + const valuesSpy = mockInsert(); + + const caller = await memberCaller(); + const result = await caller.documents.upload(validInput); + + expect(result).toEqual({ id: documentId, status: 'in_progress' }); + expect(uploadDocument).toHaveBeenCalledWith(expect.any(File), organizationId); + expect(valuesSpy).toHaveBeenCalledWith({ + organizationId, + userId, + displayName: 'notes.txt', + mimeType: 'text/plain', + sizeBytes: '1024', + storageUrl: 'https://s3.example.com/org/notes.txt', + status: 'in_progress', + }); + expect(addIndexDocumentJob).toHaveBeenCalledWith({ + documentId, + organizationId, + storageUrl: 'https://s3.example.com/org/notes.txt', + displayName: 'notes.txt', + mimeType: 'text/plain', + fileData: validInput.fileData, + }); + }); + + it('accepts raw base64 without a data-url prefix', async () => { + vi.mocked(db.query.organizations.findFirst).mockResolvedValue({ + id: organizationId, + } as never); + vi.mocked(uploadDocument).mockResolvedValue('https://s3.example.com/org/notes.txt'); + mockInsert(); + + const caller = await memberCaller(); + await caller.documents.upload({ ...validInput, fileData: 'aGVsbG8=' }); + + expect(uploadDocument).toHaveBeenCalledWith(expect.any(File), organizationId); + }); + + it('returns NOT_FOUND when the organization row is missing', async () => { + vi.mocked(db.query.organizations.findFirst).mockResolvedValue(undefined); + + const caller = await memberCaller(); + + await expectTRPCError( + caller.documents.upload(validInput), + 'NOT_FOUND', + 'Organization not found' + ); + expect(uploadDocument).not.toHaveBeenCalled(); + expect(addIndexDocumentJob).not.toHaveBeenCalled(); + }); + + it('rejects an unsupported mime type', async () => { + const caller = await memberCaller(); + + await expectTRPCError( + caller.documents.upload({ ...validInput, mimeType: 'image/png' }), + 'BAD_REQUEST', + 'Unsupported file type' + ); + expect(uploadDocument).not.toHaveBeenCalled(); + }); + + it('rejects the mime type that getContentTypeByExtension returns for .ts files', async () => { + // Documents a mismatch: EXTENSION_TO_MIME_TYPE maps .ts to text/typescript, + // which is absent from SUPPORTED_MIME_TYPES (only application/typescript is listed). + const caller = await memberCaller(); + + await expectTRPCError( + caller.documents.upload({ ...validInput, mimeType: 'text/typescript' }), + 'BAD_REQUEST', + 'Unsupported file type' + ); + }); + + it('rejects a zero-byte file', async () => { + const caller = await memberCaller(); + + await expectTRPCError( + caller.documents.upload({ ...validInput, sizeBytes: '0' }), + 'BAD_REQUEST' + ); + }); + + it('rejects a file above the 100MB limit', async () => { + const caller = await memberCaller(); + + await expectTRPCError( + caller.documents.upload({ ...validInput, sizeBytes: String(100 * 1024 * 1024 + 1) }), + 'BAD_REQUEST' + ); + }); + + it('rejects an empty display name', async () => { + const caller = await memberCaller(); + + await expectTRPCError( + caller.documents.upload({ ...validInput, displayName: '' }), + 'BAD_REQUEST', + 'Title is required' + ); + }); + + it('rejects a non-member', async () => { + vi.mocked(db.query.orgMemberships.findFirst).mockResolvedValue(undefined); + + const caller = await memberCaller(); + + await expectTRPCError(caller.documents.upload(validInput), 'FORBIDDEN'); + expect(uploadDocument).not.toHaveBeenCalled(); + }); + }); + + describe('delete', () => { + const mockDeleteChain = () => { + const whereSpy = vi.fn().mockResolvedValue(undefined); + vi.mocked(db.delete).mockReturnValue({ where: whereSpy } as never); + return whereSpy; + }; + + it('lets the document owner delete their own document', async () => { + vi.mocked(db.query.documents.findFirst).mockResolvedValue(mockDocument as never); + const whereSpy = mockDeleteChain(); + + const caller = await memberCaller(); + const result = await caller.documents.delete({ id: documentId, organizationId }); + + expect(result).toEqual({ success: true }); + expect(whereSpy).toHaveBeenCalled(); + expect(deleteDocument).toHaveBeenCalledWith(mockDocument.storageUrl); + }); + + it('lets an organization owner delete another member document', async () => { + vi.mocked(db.query.orgMemberships.findFirst).mockResolvedValue(membershipAs('owner')); + vi.mocked(db.query.documents.findFirst).mockResolvedValue({ + ...mockDocument, + userId: otherUserId, + } as never); + mockDeleteChain(); + + const caller = await memberCaller(); + + await expect(caller.documents.delete({ id: documentId, organizationId })).resolves.toEqual({ + success: true, + }); + }); + + it('lets an organization admin delete another member document', async () => { + vi.mocked(db.query.orgMemberships.findFirst).mockResolvedValue(membershipAs('admin')); + vi.mocked(db.query.documents.findFirst).mockResolvedValue({ + ...mockDocument, + userId: otherUserId, + } as never); + mockDeleteChain(); + + const caller = await memberCaller(); + + await expect(caller.documents.delete({ id: documentId, organizationId })).resolves.toEqual({ + success: true, + }); + }); + + it('forbids a plain member from deleting another member document', async () => { + vi.mocked(db.query.orgMemberships.findFirst).mockResolvedValue(membershipAs('member')); + vi.mocked(db.query.documents.findFirst).mockResolvedValue({ + ...mockDocument, + userId: otherUserId, + } as never); + mockDeleteChain(); + + const caller = await memberCaller(); + + await expectTRPCError( + caller.documents.delete({ id: documentId, organizationId }), + 'FORBIDDEN', + 'do not have permission to delete this document' + ); + expect(db.delete).not.toHaveBeenCalled(); + expect(deleteDocument).not.toHaveBeenCalled(); + }); + + it('derives the permission role from the membership row, not the session role claim', async () => { + // Session claims owner; the membership row says member. The row must win. + vi.mocked(db.query.orgMemberships.findFirst).mockResolvedValue(membershipAs('member')); + vi.mocked(db.query.documents.findFirst).mockResolvedValue({ + ...mockDocument, + userId: otherUserId, + } as never); + mockDeleteChain(); + + const caller = await createCaller(createMockTRPCContext({ userId, orgRole: 'owner' })); + + await expectTRPCError( + caller.documents.delete({ id: documentId, organizationId }), + 'FORBIDDEN' + ); + }); + + it('returns NOT_FOUND for a document outside the organization', async () => { + vi.mocked(db.query.documents.findFirst).mockResolvedValue(undefined); + + const caller = await memberCaller(); + + await expectTRPCError( + caller.documents.delete({ id: documentId, organizationId }), + 'NOT_FOUND', + 'Document not found' + ); + expect(db.delete).not.toHaveBeenCalled(); + }); + + it('removes the file search store once the last indexed document is gone', async () => { + vi.mocked(db.query.documents.findFirst) + .mockResolvedValueOnce({ + ...mockDocument, + documentResourceName: 'files/doc-1', + fileSearchStoreName: 'stores/org-1', + } as never) + .mockResolvedValueOnce(undefined); + mockDeleteChain(); + + const caller = await memberCaller(); + await caller.documents.delete({ id: documentId, organizationId }); + + expect(deleteDocumentFromFileSearchStore).toHaveBeenCalledWith({ name: 'files/doc-1' }); + expect(deleteFileSearchStore).toHaveBeenCalledWith({ name: 'stores/org-1' }); + }); + + it('keeps the file search store while other documents still reference it', async () => { + vi.mocked(db.query.documents.findFirst) + .mockResolvedValueOnce({ + ...mockDocument, + documentResourceName: 'files/doc-1', + fileSearchStoreName: 'stores/org-1', + } as never) + .mockResolvedValueOnce({ id: 'another-doc' } as never); + mockDeleteChain(); + + const caller = await memberCaller(); + await caller.documents.delete({ id: documentId, organizationId }); + + expect(deleteDocumentFromFileSearchStore).toHaveBeenCalled(); + expect(deleteFileSearchStore).not.toHaveBeenCalled(); + }); + + it('skips file search cleanup for a document that never finished indexing', async () => { + vi.mocked(db.query.documents.findFirst).mockResolvedValue({ + ...mockDocument, + status: 'in_progress', + documentResourceName: 'files/doc-1', + fileSearchStoreName: 'stores/org-1', + } as never); + mockDeleteChain(); + + const caller = await memberCaller(); + await caller.documents.delete({ id: documentId, organizationId }); + + expect(deleteDocumentFromFileSearchStore).not.toHaveBeenCalled(); + expect(deleteFileSearchStore).not.toHaveBeenCalled(); + }); + + it('still succeeds when file search cleanup throws', async () => { + vi.mocked(db.query.documents.findFirst) + .mockResolvedValueOnce({ + ...mockDocument, + documentResourceName: 'files/doc-1', + fileSearchStoreName: 'stores/org-1', + } as never) + .mockResolvedValueOnce(undefined); + mockDeleteChain(); + vi.mocked(deleteDocumentFromFileSearchStore).mockRejectedValue(new Error('gemini down')); + vi.mocked(deleteFileSearchStore).mockRejectedValue(new Error('gemini down')); + + const caller = await memberCaller(); + + await expect(caller.documents.delete({ id: documentId, organizationId })).resolves.toEqual({ + success: true, + }); + }); + + it('rejects an unauthenticated caller', async () => { + const caller = await anonCaller(); + + await expectTRPCError( + caller.documents.delete({ id: documentId, organizationId }), + 'UNAUTHORIZED' + ); + }); + }); +}); diff --git a/__tests__/lib/trpc/routers/orders.test.ts b/__tests__/lib/trpc/routers/orders.test.ts new file mode 100644 index 00000000..1a910c9b --- /dev/null +++ b/__tests__/lib/trpc/routers/orders.test.ts @@ -0,0 +1,462 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { createMockTRPCContext } from '@/__tests__/setup/mocks'; +import { expectTRPCError } from '@/__tests__/setup/utils'; + +import { db } from '@/lib/db/drizzle'; +import { ERRORS } from '@/lib/services/constants'; +import * as orderService from '@/lib/services/order-service'; +import { createCaller } from '@/lib/trpc/server'; + +vi.mock('@/lib/db/drizzle', () => ({ + db: { + query: { + orgMemberships: { + findFirst: vi.fn(), + }, + }, + }, +})); + +vi.mock('@/lib/services/order-service', () => ({ + listOrders: vi.fn(), + getOrderById: vi.fn(), + getOrderHistory: vi.fn(), + createOrder: vi.fn(), + updateOrder: vi.fn(), + deleteOrder: vi.fn(), + exportOrders: vi.fn(), +})); + +describe('Orders Router', () => { + const userId = 'user_123'; + const organizationId = '22222222-2222-4222-8222-222222222222'; + const otherOrganizationId = '33333333-3333-4333-8333-333333333333'; + const orderId = '44444444-4444-4444-8444-444444444444'; + + const mockOrder = { + id: orderId, + customerName: 'Acme Corp', + status: 'pending' as const, + currency: 'USD', + amount: '99.99', + orderDate: new Date('2026-01-01'), + notes: null, + createdAt: new Date('2026-01-01'), + updatedAt: new Date('2026-01-01'), + organizationId, + userId, + userDisplayName: 'John Doe', + userEmail: 'john@example.com', + }; + + const membership = { + id: 'membership_1', + organizationId, + userId, + role: 'member' as const, + createdAt: new Date('2026-01-01'), + }; + + const memberCaller = () => createCaller(createMockTRPCContext({ userId })); + const anonCaller = () => createCaller(createMockTRPCContext({ userId: null })); + + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(db.query.orgMemberships.findFirst).mockResolvedValue(membership); + }); + + describe('orgProcedure authorization', () => { + it('rejects an unauthenticated caller before any membership lookup', async () => { + const caller = await anonCaller(); + + await expectTRPCError(caller.orders.get({ id: orderId, organizationId }), 'UNAUTHORIZED'); + expect(db.query.orgMemberships.findFirst).not.toHaveBeenCalled(); + }); + + it('rejects a caller who is not a member of the requested organization', async () => { + vi.mocked(db.query.orgMemberships.findFirst).mockResolvedValue(undefined); + + const caller = await memberCaller(); + + await expectTRPCError( + caller.orders.get({ id: orderId, organizationId: otherOrganizationId }), + 'FORBIDDEN', + 'do not have access to this organization' + ); + expect(orderService.getOrderById).not.toHaveBeenCalled(); + }); + + it('uses the organization from the validated input, not from the session context', async () => { + vi.mocked(orderService.getOrderById).mockResolvedValue(mockOrder); + + // Session points at a different active organization than the input does. + const caller = await createCaller( + createMockTRPCContext({ userId, activeOrganizationId: otherOrganizationId }) + ); + await caller.orders.get({ id: orderId, organizationId }); + + expect(orderService.getOrderById).toHaveBeenCalledWith({ orderId, organizationId }); + }); + }); + + describe('list', () => { + // listOrders selects a joined projection, not the raw order row. + const listResult = { + orders: [ + { + id: orderId, + customerName: 'Acme Corp', + status: 'pending' as const, + amount: '99.99', + orderDate: new Date('2026-01-01'), + notes: null, + createdAt: new Date('2026-01-01'), + updatedAt: new Date('2026-01-01'), + organizationName: 'Acme', + organizationSlug: 'acme', + userDisplayName: 'John Doe', + userEmail: 'john@example.com', + }, + ], + total: 1, + page: 1, + limit: 10, + totalPages: 1, + }; + + it('forwards every filter to the service with defaults applied', async () => { + vi.mocked(orderService.listOrders).mockResolvedValue(listResult); + + const caller = await memberCaller(); + const result = await caller.orders.list({ + organizationId, + statuses: ['pending'], + searchQuery: 'acme', + minAmount: 10, + maxAmount: 100, + }); + + expect(result).toEqual(listResult); + expect(orderService.listOrders).toHaveBeenCalledWith({ + organizationId, + statuses: ['pending'], + searchQuery: 'acme', + dateFrom: undefined, + dateTo: undefined, + minAmount: 10, + maxAmount: 100, + page: 1, + limit: 10, + sortBy: 'orderDate', + sortOrder: 'desc', + }); + }); + + it('rejects an unknown order status', async () => { + const caller = await memberCaller(); + + await expectTRPCError( + caller.orders.list({ organizationId, statuses: ['not-a-status'] }), + 'BAD_REQUEST' + ); + expect(orderService.listOrders).not.toHaveBeenCalled(); + }); + + it('rejects a limit above the 100 maximum', async () => { + const caller = await memberCaller(); + + await expectTRPCError(caller.orders.list({ organizationId, limit: 500 }), 'BAD_REQUEST'); + expect(orderService.listOrders).not.toHaveBeenCalled(); + }); + + it('fails with an unmapped INTERNAL_SERVER_ERROR when input is omitted entirely', async () => { + // The zod schema is `.optional()`, but orgProcedure dereferences the raw input to read + // organizationId. Documents current behavior: this is a crash, not a clean BAD_REQUEST. + const caller = await memberCaller(); + + await expectTRPCError(caller.orders.list(), 'INTERNAL_SERVER_ERROR'); + expect(orderService.listOrders).not.toHaveBeenCalled(); + }); + }); + + describe('get', () => { + it('returns the order scoped to the caller organization', async () => { + vi.mocked(orderService.getOrderById).mockResolvedValue(mockOrder); + + const caller = await memberCaller(); + const result = await caller.orders.get({ id: orderId, organizationId }); + + expect(result).toEqual(mockOrder); + expect(orderService.getOrderById).toHaveBeenCalledWith({ orderId, organizationId }); + }); + + it('surfaces NOT_FOUND for an order in another organization', async () => { + vi.mocked(orderService.getOrderById).mockRejectedValue( + new Error('Order not found', { cause: ERRORS.NOT_FOUND }) + ); + + const caller = await memberCaller(); + + await expectTRPCError( + caller.orders.get({ id: orderId, organizationId }), + 'NOT_FOUND', + 'Order not found' + ); + }); + + it('rejects a non-uuid order id', async () => { + const caller = await memberCaller(); + + await expectTRPCError(caller.orders.get({ id: 'nope', organizationId }), 'BAD_REQUEST'); + }); + }); + + describe('getHistory', () => { + it('returns the status history for the order', async () => { + const history = [ + { + id: 'history_1', + status: 'pending' as const, + notes: 'Order created', + createdAt: new Date('2026-01-01'), + userDisplayName: 'John Doe', + userEmail: 'john@example.com', + }, + ]; + vi.mocked(orderService.getOrderHistory).mockResolvedValue(history); + + const caller = await memberCaller(); + const result = await caller.orders.getHistory({ orderId, organizationId }); + + expect(result).toEqual(history); + expect(orderService.getOrderHistory).toHaveBeenCalledWith({ orderId, organizationId }); + }); + + it('surfaces NOT_FOUND when the order does not exist', async () => { + vi.mocked(orderService.getOrderHistory).mockRejectedValue( + new Error('Order not found', { cause: ERRORS.NOT_FOUND }) + ); + + const caller = await memberCaller(); + + await expectTRPCError(caller.orders.getHistory({ orderId, organizationId }), 'NOT_FOUND'); + }); + + it('rejects a non-member', async () => { + vi.mocked(db.query.orgMemberships.findFirst).mockResolvedValue(undefined); + + const caller = await memberCaller(); + + await expectTRPCError(caller.orders.getHistory({ orderId, organizationId }), 'FORBIDDEN'); + }); + }); + + describe('create', () => { + it('creates an order attributed to the caller and their organization', async () => { + vi.mocked(orderService.createOrder).mockResolvedValue(mockOrder); + + const caller = await memberCaller(); + const orderDate = new Date('2026-02-01'); + const result = await caller.orders.create({ + organizationId, + customerName: 'Acme Corp', + amount: '99.99', + status: 'processing', + orderDate, + notes: 'rush', + }); + + expect(result).toEqual(mockOrder); + expect(orderService.createOrder).toHaveBeenCalledWith({ + customerName: 'Acme Corp', + userId, + organizationId, + amount: '99.99', + status: 'processing', + orderDate, + notes: 'rush', + }); + }); + + it('omits optional fields that were not supplied', async () => { + vi.mocked(orderService.createOrder).mockResolvedValue(mockOrder); + + const caller = await memberCaller(); + await caller.orders.create({ + organizationId, + customerName: 'Acme Corp', + amount: '10.00', + }); + + expect(orderService.createOrder).toHaveBeenCalledWith({ + customerName: 'Acme Corp', + userId, + organizationId, + amount: '10.00', + }); + }); + + it('rejects a malformed amount', async () => { + const caller = await memberCaller(); + + await expectTRPCError( + caller.orders.create({ organizationId, customerName: 'Acme', amount: '12.345' }), + 'BAD_REQUEST' + ); + expect(orderService.createOrder).not.toHaveBeenCalled(); + }); + + it('rejects a zero amount', async () => { + const caller = await memberCaller(); + + await expectTRPCError( + caller.orders.create({ organizationId, customerName: 'Acme', amount: '0' }), + 'BAD_REQUEST' + ); + }); + + it('rejects a blank customer name', async () => { + const caller = await memberCaller(); + + await expectTRPCError( + caller.orders.create({ organizationId, customerName: ' ', amount: '10.00' }), + 'BAD_REQUEST' + ); + }); + + it('rejects a non-member', async () => { + vi.mocked(db.query.orgMemberships.findFirst).mockResolvedValue(undefined); + + const caller = await memberCaller(); + + await expectTRPCError( + caller.orders.create({ organizationId, customerName: 'Acme', amount: '10.00' }), + 'FORBIDDEN' + ); + expect(orderService.createOrder).not.toHaveBeenCalled(); + }); + }); + + describe('update', () => { + it('forwards the changed fields together with the caller identity', async () => { + vi.mocked(orderService.updateOrder).mockResolvedValue(mockOrder); + + const caller = await memberCaller(); + await caller.orders.update({ + id: orderId, + organizationId, + customerName: 'Renamed', + status: 'shipped', + }); + + expect(orderService.updateOrder).toHaveBeenCalledWith({ + orderId, + organizationId, + userId, + customerName: 'Renamed', + amount: undefined, + status: 'shipped', + }); + }); + + it('surfaces NOT_FOUND when the order is outside the organization', async () => { + vi.mocked(orderService.updateOrder).mockRejectedValue( + new Error('Order not found', { cause: ERRORS.NOT_FOUND }) + ); + + const caller = await memberCaller(); + + await expectTRPCError( + caller.orders.update({ id: orderId, organizationId, status: 'shipped' }), + 'NOT_FOUND' + ); + }); + + it('rejects notes longer than 1000 characters', async () => { + const caller = await memberCaller(); + + await expectTRPCError( + caller.orders.update({ id: orderId, organizationId, notes: 'x'.repeat(1001) }), + 'BAD_REQUEST' + ); + expect(orderService.updateOrder).not.toHaveBeenCalled(); + }); + + it('rejects an unauthenticated caller', async () => { + const caller = await anonCaller(); + + await expectTRPCError( + caller.orders.update({ id: orderId, organizationId, status: 'shipped' }), + 'UNAUTHORIZED' + ); + }); + }); + + describe('delete', () => { + it('deletes an order in the caller organization', async () => { + vi.mocked(orderService.deleteOrder).mockResolvedValue({ success: true }); + + const caller = await memberCaller(); + const result = await caller.orders.delete({ id: orderId, organizationId }); + + expect(result).toEqual({ success: true }); + expect(orderService.deleteOrder).toHaveBeenCalledWith({ orderId, organizationId }); + }); + + it('surfaces NOT_FOUND when the order does not exist', async () => { + vi.mocked(orderService.deleteOrder).mockRejectedValue( + new Error('Order not found', { cause: ERRORS.NOT_FOUND }) + ); + + const caller = await memberCaller(); + + await expectTRPCError(caller.orders.delete({ id: orderId, organizationId }), 'NOT_FOUND'); + }); + + it('rejects a non-member', async () => { + vi.mocked(db.query.orgMemberships.findFirst).mockResolvedValue(undefined); + + const caller = await memberCaller(); + + await expectTRPCError(caller.orders.delete({ id: orderId, organizationId }), 'FORBIDDEN'); + expect(orderService.deleteOrder).not.toHaveBeenCalled(); + }); + }); + + describe('export', () => { + it('exports orders for the organization in the requested format', async () => { + const exported = { + data: 'id,customer\n1,Acme', + filename: 'orders.csv', + mimeType: 'text/csv', + }; + vi.mocked(orderService.exportOrders).mockResolvedValue(exported); + + const caller = await memberCaller(); + const result = await caller.orders.export({ organizationId, type: 'csv' }); + + expect(result).toEqual(exported); + expect(orderService.exportOrders).toHaveBeenCalledWith({ organizationId, type: 'csv' }); + }); + + it('rejects an unsupported export type', async () => { + const caller = await memberCaller(); + + await expectTRPCError( + // @ts-expect-error - deliberately invalid export type + caller.orders.export({ organizationId, type: 'pdf' }), + 'BAD_REQUEST' + ); + expect(orderService.exportOrders).not.toHaveBeenCalled(); + }); + + it('rejects a non-member', async () => { + vi.mocked(db.query.orgMemberships.findFirst).mockResolvedValue(undefined); + + const caller = await memberCaller(); + + await expectTRPCError(caller.orders.export({ organizationId, type: 'excel' }), 'FORBIDDEN'); + }); + }); +}); diff --git a/__tests__/lib/trpc/routers/organizations.test.ts b/__tests__/lib/trpc/routers/organizations.test.ts new file mode 100644 index 00000000..912e7880 --- /dev/null +++ b/__tests__/lib/trpc/routers/organizations.test.ts @@ -0,0 +1,666 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { createMockTRPCContext } from '@/__tests__/setup/mocks'; +import { expectTRPCError } from '@/__tests__/setup/utils'; + +import * as configService from '@/lib/services/config-service'; +import { ERRORS } from '@/lib/services/constants'; +import * as invitationService from '@/lib/services/invitation-service'; +import * as memberService from '@/lib/services/member-service'; +import * as organizationService from '@/lib/services/organization-service'; +import { createCaller } from '@/lib/trpc/server'; + +vi.mock('next/headers', () => ({ + headers: vi.fn(async () => new Headers({ cookie: 'session=abc' })), +})); + +vi.mock('@/lib/services/organization-service', () => ({ + getUserOrganizations: vi.fn(), + getOrganizationById: vi.fn(), + getOrganizationBySlug: vi.fn(), + createOrganization: vi.fn(), + updateOrganization: vi.fn(), + deleteOrganization: vi.fn(), + uploadOrganizationLogo: vi.fn(), + deleteOrganizationLogo: vi.fn(), +})); + +vi.mock('@/lib/services/member-service', () => ({ + getOrganizationMembers: vi.fn(), + updateMemberRole: vi.fn(), + removeMember: vi.fn(), + leaveOrganization: vi.fn(), +})); + +vi.mock('@/lib/services/invitation-service', () => ({ + createInvitation: vi.fn(), + cancelInvitation: vi.fn(), +})); + +vi.mock('@/lib/services/config-service', () => ({ + isGoogleApiKeyConfigured: vi.fn(), +})); + +describe('Organizations Router', () => { + const userId = '11111111-1111-4111-8111-111111111111'; + const otherUserId = '99999999-9999-4999-8999-999999999999'; + const organizationId = '22222222-2222-4222-8222-222222222222'; + const invitationId = '33333333-3333-4333-8333-333333333333'; + const memberId = '44444444-4444-4444-8444-444444444444'; + + const mockOrganization = { + id: organizationId, + name: 'Acme', + slug: 'acme', + logo: null, + createdAt: new Date('2026-01-01'), + metadata: null, + }; + + const authedCaller = () => createCaller(createMockTRPCContext({ userId })); + const anonCaller = () => createCaller(createMockTRPCContext({ userId: null })); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('authorization model', () => { + it('rejects every unauthenticated caller across read and write procedures', async () => { + const caller = await anonCaller(); + + await expectTRPCError(caller.organizations.getUserOrganizations({ userId }), 'UNAUTHORIZED'); + await expectTRPCError( + caller.organizations.getOrganization({ organizationId }), + 'UNAUTHORIZED' + ); + await expectTRPCError(caller.organizations.getOrgMembers({ organizationId }), 'UNAUTHORIZED'); + await expectTRPCError( + caller.organizations.createOrganization({ name: 'Acme' }), + 'UNAUTHORIZED' + ); + await expectTRPCError( + caller.organizations.deleteOrganization({ organizationId }), + 'UNAUTHORIZED' + ); + await expectTRPCError(caller.organizations.checkGoogleApiKey(), 'UNAUTHORIZED'); + + expect(organizationService.getUserOrganizations).not.toHaveBeenCalled(); + expect(organizationService.deleteOrganization).not.toHaveBeenCalled(); + }); + + it('performs no membership check of its own - a logged-in non-member reaches the service', async () => { + // Every procedure here is protectedProcedure, not orgProcedure/orgOwnerProcedure. + // Membership and role enforcement live entirely in the service layer via Better Auth headers. + vi.mocked(organizationService.getOrganizationById).mockResolvedValue( + mockOrganization as never + ); + + const caller = await authedCaller(); + await caller.organizations.getOrganization({ organizationId }); + + expect(organizationService.getOrganizationById).toHaveBeenCalledWith({ + organizationId, + headers: expect.any(Headers), + }); + }); + + it('surfaces the FORBIDDEN a service raises for a non-owner deleting an organization', async () => { + vi.mocked(organizationService.deleteOrganization).mockRejectedValue( + new Error('Only organization owners can delete the organization', { + cause: ERRORS.FORBIDDEN, + }) + ); + + const caller = await authedCaller(); + + await expectTRPCError( + caller.organizations.deleteOrganization({ organizationId }), + 'FORBIDDEN', + 'Only organization owners can delete the organization' + ); + }); + }); + + describe('getUserOrganizations', () => { + it('returns the organizations for the requested user', async () => { + vi.mocked(organizationService.getUserOrganizations).mockResolvedValue([ + mockOrganization, + ] as never); + + const caller = await authedCaller(); + const result = await caller.organizations.getUserOrganizations({ userId }); + + expect(result).toEqual([mockOrganization]); + expect(organizationService.getUserOrganizations).toHaveBeenCalledWith({ + userId, + headers: expect.any(Headers), + }); + }); + + it('forwards a userId from the input without comparing it to the session user', async () => { + // Documents current behavior: the router never checks input.userId === ctx.userId. + vi.mocked(organizationService.getUserOrganizations).mockResolvedValue([] as never); + + const caller = await authedCaller(); + await caller.organizations.getUserOrganizations({ userId: otherUserId }); + + expect(organizationService.getUserOrganizations).toHaveBeenCalledWith( + expect.objectContaining({ userId: otherUserId }) + ); + }); + + it('rejects a non-uuid userId', async () => { + const caller = await authedCaller(); + + await expectTRPCError( + caller.organizations.getUserOrganizations({ userId: 'nope' }), + 'BAD_REQUEST', + 'Invalid user ID' + ); + expect(organizationService.getUserOrganizations).not.toHaveBeenCalled(); + }); + }); + + describe('getOrganization / getOrganizationBySlug', () => { + it('returns an organization by id', async () => { + vi.mocked(organizationService.getOrganizationById).mockResolvedValue( + mockOrganization as never + ); + + const caller = await authedCaller(); + + await expect(caller.organizations.getOrganization({ organizationId })).resolves.toEqual( + mockOrganization + ); + }); + + it('returns an organization by slug', async () => { + vi.mocked(organizationService.getOrganizationBySlug).mockResolvedValue( + mockOrganization as never + ); + + const caller = await authedCaller(); + const result = await caller.organizations.getOrganizationBySlug({ + organizationSlug: 'acme', + }); + + expect(result).toEqual(mockOrganization); + expect(organizationService.getOrganizationBySlug).toHaveBeenCalledWith({ + slug: 'acme', + headers: expect.any(Headers), + }); + }); + + it('maps an unknown slug to NOT_FOUND', async () => { + vi.mocked(organizationService.getOrganizationBySlug).mockRejectedValue( + new Error('Organization ghost not found', { cause: ERRORS.NOT_FOUND }) + ); + + const caller = await authedCaller(); + + await expectTRPCError( + caller.organizations.getOrganizationBySlug({ organizationSlug: 'ghost' }), + 'NOT_FOUND', + 'Organization ghost not found' + ); + }); + + it('rejects an empty slug', async () => { + const caller = await authedCaller(); + + await expectTRPCError( + caller.organizations.getOrganizationBySlug({ organizationSlug: '' }), + 'BAD_REQUEST', + 'Organization slug is required' + ); + }); + }); + + describe('createOrganization', () => { + it('creates an organization from a name only', async () => { + vi.mocked(organizationService.createOrganization).mockResolvedValue( + mockOrganization as never + ); + + const caller = await authedCaller(); + const result = await caller.organizations.createOrganization({ name: 'Acme' }); + + expect(result).toEqual(mockOrganization); + expect(organizationService.createOrganization).toHaveBeenCalledWith({ + name: 'Acme', + headers: expect.any(Headers), + }); + }); + + it('rejects a name longer than 100 characters', async () => { + const caller = await authedCaller(); + + await expectTRPCError( + caller.organizations.createOrganization({ name: 'x'.repeat(101) }), + 'BAD_REQUEST', + 'Name too long' + ); + expect(organizationService.createOrganization).not.toHaveBeenCalled(); + }); + + it('maps a creation failure to BAD_REQUEST', async () => { + vi.mocked(organizationService.createOrganization).mockRejectedValue( + new Error('Slug already taken', { cause: ERRORS.BAD_REQUEST }) + ); + + const caller = await authedCaller(); + + await expectTRPCError( + caller.organizations.createOrganization({ name: 'Acme' }), + 'BAD_REQUEST', + 'Slug already taken' + ); + }); + }); + + describe('updateOrganization', () => { + it('splits the organization id out of the update payload', async () => { + vi.mocked(organizationService.updateOrganization).mockResolvedValue( + mockOrganization as never + ); + + const caller = await authedCaller(); + await caller.organizations.updateOrganization({ organizationId, name: 'Acme Inc' }); + + expect(organizationService.updateOrganization).toHaveBeenCalledWith({ + organizationId, + data: { name: 'Acme Inc' }, + headers: expect.any(Headers), + }); + }); + + it('rejects a non-uuid organization id', async () => { + const caller = await authedCaller(); + + await expectTRPCError( + caller.organizations.updateOrganization({ organizationId: 'nope', name: 'Acme' }), + 'BAD_REQUEST', + 'Invalid organization ID' + ); + expect(organizationService.updateOrganization).not.toHaveBeenCalled(); + }); + }); + + describe('organization logo', () => { + it('forwards the upload payload together with the request headers', async () => { + vi.mocked(organizationService.uploadOrganizationLogo).mockResolvedValue({ + success: true, + message: 'Organization logo uploaded successfully', + }); + + const caller = await authedCaller(); + await caller.organizations.uploadOrganizationLogo({ + fileBase64: 'data:image/png;base64,aGVsbG8=', + fileName: 'logo.png', + mimeType: 'image/png', + }); + + expect(organizationService.uploadOrganizationLogo).toHaveBeenCalledWith({ + fileBase64: 'data:image/png;base64,aGVsbG8=', + fileName: 'logo.png', + mimeType: 'image/png', + headers: expect.any(Headers), + }); + }); + + it('rejects an unsupported logo mime type', async () => { + const caller = await authedCaller(); + + await expectTRPCError( + caller.organizations.uploadOrganizationLogo({ + fileBase64: 'data:image/gif;base64,aGVsbG8=', + fileName: 'logo.gif', + // @ts-expect-error - deliberately unsupported mime type + mimeType: 'image/gif', + }), + 'BAD_REQUEST' + ); + expect(organizationService.uploadOrganizationLogo).not.toHaveBeenCalled(); + }); + + it('deletes the logo of the active organization without taking any input', async () => { + vi.mocked(organizationService.deleteOrganizationLogo).mockResolvedValue({ + success: true, + message: 'Organization logo deleted successfully', + }); + + const caller = await authedCaller(); + const result = await caller.organizations.deleteOrganizationLogo(); + + expect(result.success).toBe(true); + expect(organizationService.deleteOrganizationLogo).toHaveBeenCalledWith({ + headers: expect.any(Headers), + }); + }); + + it('rejects an unauthenticated logo delete', async () => { + const caller = await anonCaller(); + + await expectTRPCError(caller.organizations.deleteOrganizationLogo(), 'UNAUTHORIZED'); + expect(organizationService.deleteOrganizationLogo).not.toHaveBeenCalled(); + }); + }); + + describe('getOrgMembers', () => { + it('returns the member list for an organization', async () => { + const members = { members: [{ id: memberId, role: 'owner' }], total: 1 }; + vi.mocked(memberService.getOrganizationMembers).mockResolvedValue(members as never); + + const caller = await authedCaller(); + const result = await caller.organizations.getOrgMembers({ organizationId }); + + expect(result).toEqual(members); + expect(memberService.getOrganizationMembers).toHaveBeenCalledWith({ + organizationId, + headers: expect.any(Headers), + }); + }); + + it('accepts the default offset but rejects the same value passed explicitly', async () => { + // `offset: z.number().int().positive().default(0)` - the default bypasses validation + // while an explicit 0 fails `positive()`. Documents current behavior. + vi.mocked(memberService.getOrganizationMembers).mockResolvedValue({ members: [] } as never); + + const caller = await authedCaller(); + + await expect(caller.organizations.getOrgMembers({ organizationId })).resolves.toBeDefined(); + await expectTRPCError( + caller.organizations.getOrgMembers({ organizationId, offset: 0 }), + 'BAD_REQUEST' + ); + }); + + it('rejects an unknown sort direction', async () => { + const caller = await authedCaller(); + + await expectTRPCError( + // @ts-expect-error - deliberately invalid sort direction + caller.organizations.getOrgMembers({ organizationId, sortDirection: 'sideways' }), + 'BAD_REQUEST' + ); + }); + }); + + describe('inviteMember', () => { + it('creates an invitation with the requested role', async () => { + vi.mocked(invitationService.createInvitation).mockResolvedValue({ + id: invitationId, + } as never); + + const caller = await authedCaller(); + await caller.organizations.inviteMember({ + organizationId, + email: 'new@example.com', + role: 'admin', + }); + + expect(invitationService.createInvitation).toHaveBeenCalledWith({ + email: 'new@example.com', + organizationId, + role: 'admin', + headers: expect.any(Headers), + }); + }); + + it('defaults the invited role to member', async () => { + vi.mocked(invitationService.createInvitation).mockResolvedValue({ + id: invitationId, + } as never); + + const caller = await authedCaller(); + await caller.organizations.inviteMember({ organizationId, email: 'new@example.com' }); + + expect(invitationService.createInvitation).toHaveBeenCalledWith( + expect.objectContaining({ role: 'member' }) + ); + }); + + it('rejects a malformed email address', async () => { + const caller = await authedCaller(); + + await expectTRPCError( + caller.organizations.inviteMember({ organizationId, email: 'not-an-email' }), + 'BAD_REQUEST', + 'Invalid email address' + ); + expect(invitationService.createInvitation).not.toHaveBeenCalled(); + }); + + it('maps a duplicate invitation to BAD_REQUEST', async () => { + vi.mocked(invitationService.createInvitation).mockRejectedValue( + new Error('User already has an invitation to this organization', { + cause: ERRORS.BAD_REQUEST, + }) + ); + + const caller = await authedCaller(); + + await expectTRPCError( + caller.organizations.inviteMember({ organizationId, email: 'new@example.com' }), + 'BAD_REQUEST', + 'already has an invitation' + ); + }); + }); + + describe('cancelInvitation', () => { + it('cancels an invitation by id', async () => { + vi.mocked(invitationService.cancelInvitation).mockResolvedValue({ success: true } as never); + + const caller = await authedCaller(); + const result = await caller.organizations.cancelInvitation({ invitationId }); + + expect(result).toEqual({ success: true }); + expect(invitationService.cancelInvitation).toHaveBeenCalledWith({ + invitationId, + headers: expect.any(Headers), + }); + }); + + it('rejects a non-uuid invitation id', async () => { + const caller = await authedCaller(); + + await expectTRPCError( + caller.organizations.cancelInvitation({ invitationId: 'nope' }), + 'BAD_REQUEST', + 'Invalid invitation ID' + ); + }); + }); + + describe('removeMember', () => { + it('removes a member by id or email', async () => { + vi.mocked(memberService.removeMember).mockResolvedValue({ + success: true, + message: 'Member removed', + }); + + const caller = await authedCaller(); + await caller.organizations.removeMember({ + organizationId, + memberIdOrEmail: 'member@example.com', + }); + + expect(memberService.removeMember).toHaveBeenCalledWith({ + organizationId, + memberIdOrEmail: 'member@example.com', + headers: expect.any(Headers), + }); + }); + + it('rejects an empty member identifier', async () => { + const caller = await authedCaller(); + + await expectTRPCError( + caller.organizations.removeMember({ organizationId, memberIdOrEmail: '' }), + 'BAD_REQUEST', + 'User ID or email is required' + ); + expect(memberService.removeMember).not.toHaveBeenCalled(); + }); + + it('surfaces a FORBIDDEN raised by the service', async () => { + vi.mocked(memberService.removeMember).mockRejectedValue( + new Error('Only owners and admins can remove members', { cause: ERRORS.FORBIDDEN }) + ); + + const caller = await authedCaller(); + + await expectTRPCError( + caller.organizations.removeMember({ organizationId, memberIdOrEmail: memberId }), + 'FORBIDDEN' + ); + }); + }); + + describe('updateMemberRole', () => { + it('updates a member role', async () => { + vi.mocked(memberService.updateMemberRole).mockResolvedValue({ + success: true, + message: 'Role updated', + }); + + const caller = await authedCaller(); + const result = await caller.organizations.updateMemberRole({ + organizationId, + memberId, + role: 'owner', + }); + + expect(result.success).toBe(true); + expect(memberService.updateMemberRole).toHaveBeenCalledWith({ + organizationId, + memberId, + role: 'owner', + headers: expect.any(Headers), + }); + }); + + it('rejects a role outside the organization role enum', async () => { + const caller = await authedCaller(); + + await expectTRPCError( + // @ts-expect-error - deliberately invalid role + caller.organizations.updateMemberRole({ organizationId, memberId, role: 'superuser' }), + 'BAD_REQUEST' + ); + expect(memberService.updateMemberRole).not.toHaveBeenCalled(); + }); + }); + + describe('leaveOrganization', () => { + it('leaves using the session user, not a user id from the input', async () => { + vi.mocked(memberService.leaveOrganization).mockResolvedValue({ + success: true, + message: 'Left organization', + }); + + const caller = await authedCaller(); + await caller.organizations.leaveOrganization({ + organizationId, + // @ts-expect-error - a hostile client trying to make someone else leave + userId: otherUserId, + }); + + expect(memberService.leaveOrganization).toHaveBeenCalledWith({ + organizationId, + userId, + headers: expect.any(Headers), + }); + }); + + it('surfaces a FORBIDDEN raised when the sole owner tries to leave', async () => { + vi.mocked(memberService.leaveOrganization).mockRejectedValue( + new Error('The sole owner cannot leave the organization', { cause: ERRORS.FORBIDDEN }) + ); + + const caller = await authedCaller(); + + await expectTRPCError( + caller.organizations.leaveOrganization({ organizationId }), + 'FORBIDDEN' + ); + }); + }); + + describe('deleteOrganization', () => { + it('deletes using the session user as the actor', async () => { + vi.mocked(organizationService.deleteOrganization).mockResolvedValue({ + success: true, + message: 'Organization deleted', + }); + + const caller = await authedCaller(); + const result = await caller.organizations.deleteOrganization({ organizationId }); + + expect(result.success).toBe(true); + expect(organizationService.deleteOrganization).toHaveBeenCalledWith({ + organizationId, + userId, + headers: expect.any(Headers), + }); + }); + + it('maps a missing organization to NOT_FOUND', async () => { + vi.mocked(organizationService.deleteOrganization).mockRejectedValue( + new Error('Organization not found', { cause: ERRORS.NOT_FOUND }) + ); + + const caller = await authedCaller(); + + await expectTRPCError( + caller.organizations.deleteOrganization({ organizationId }), + 'NOT_FOUND' + ); + }); + + it('rejects a non-uuid organization id', async () => { + const caller = await authedCaller(); + + await expectTRPCError( + caller.organizations.deleteOrganization({ organizationId: 'nope' }), + 'BAD_REQUEST', + 'Invalid organization ID' + ); + }); + }); + + describe('checkGoogleApiKey', () => { + it('reports the key as configured', async () => { + vi.mocked(configService.isGoogleApiKeyConfigured).mockResolvedValue(true); + + const caller = await authedCaller(); + + await expect(caller.organizations.checkGoogleApiKey()).resolves.toEqual({ + configured: true, + }); + }); + + it('reports the key as missing', async () => { + vi.mocked(configService.isGoogleApiKeyConfigured).mockResolvedValue(false); + + const caller = await authedCaller(); + + await expect(caller.organizations.checkGoogleApiKey()).resolves.toEqual({ + configured: false, + }); + }); + + it('does not wrap a config lookup failure in handleApiError', async () => { + // This procedure has no try/catch, so a service failure escapes as a raw + // INTERNAL_SERVER_ERROR rather than a mapped error. Documents current behavior. + vi.mocked(configService.isGoogleApiKeyConfigured).mockRejectedValue( + new Error('config table unavailable', { cause: ERRORS.NOT_FOUND }) + ); + + const caller = await authedCaller(); + + await expectTRPCError(caller.organizations.checkGoogleApiKey(), 'INTERNAL_SERVER_ERROR'); + }); + }); +}); diff --git a/__tests__/lib/trpc/routers/tasks.test.ts b/__tests__/lib/trpc/routers/tasks.test.ts new file mode 100644 index 00000000..42839fb1 --- /dev/null +++ b/__tests__/lib/trpc/routers/tasks.test.ts @@ -0,0 +1,264 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { createMockTRPCContext } from '@/__tests__/setup/mocks'; +import { expectTRPCError } from '@/__tests__/setup/utils'; + +import { ERRORS } from '@/lib/services/constants'; +import * as taskService from '@/lib/services/task-service'; +import { createCaller } from '@/lib/trpc/server'; + +vi.mock('@/lib/services/task-service', () => ({ + listTasks: vi.fn(), + createTask: vi.fn(), + updateTask: vi.fn(), + deleteTask: vi.fn(), +})); + +describe('Tasks Router', () => { + const userId = 'user_123'; + const otherUserId = 'user_456'; + const taskId = '11111111-1111-4111-8111-111111111111'; + const orgId = '22222222-2222-4222-8222-222222222222'; + + const mockTask = { + id: taskId, + userId, + organizationId: null, + title: 'Write tests', + description: 'Cover the routers', + completed: false, + priority: 'medium' as const, + dueDate: null, + isOverdue: false, + createdAt: new Date('2026-01-01'), + updatedAt: new Date('2026-01-01'), + }; + + const authedCaller = () => createCaller(createMockTRPCContext({ userId })); + const anonCaller = () => createCaller(createMockTRPCContext({ userId: null })); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('list', () => { + it('scopes the query to the caller from context, not to any client-supplied id', async () => { + vi.mocked(taskService.listTasks).mockResolvedValue([mockTask]); + + const caller = await authedCaller(); + const result = await caller.tasks.list({ completed: false, priority: 'high' }); + + expect(result).toEqual([mockTask]); + expect(taskService.listTasks).toHaveBeenCalledWith({ + userId, + organizationId: undefined, + completed: false, + priority: 'high', + searchQuery: undefined, + }); + }); + + it('passes an explicit null organizationId through so personal tasks can be isolated', async () => { + vi.mocked(taskService.listTasks).mockResolvedValue([]); + + const caller = await authedCaller(); + await caller.tasks.list({ organizationId: null }); + + expect(taskService.listTasks).toHaveBeenCalledWith( + expect.objectContaining({ userId, organizationId: null }) + ); + }); + + it('rejects an unauthenticated caller', async () => { + const caller = await anonCaller(); + + await expectTRPCError(caller.tasks.list(), 'UNAUTHORIZED'); + expect(taskService.listTasks).not.toHaveBeenCalled(); + }); + + it('rejects a non-uuid organizationId filter', async () => { + const caller = await authedCaller(); + + await expectTRPCError(caller.tasks.list({ organizationId: 'not-a-uuid' }), 'BAD_REQUEST'); + expect(taskService.listTasks).not.toHaveBeenCalled(); + }); + }); + + describe('create', () => { + it('creates a task owned by the authenticated caller', async () => { + vi.mocked(taskService.createTask).mockResolvedValue(mockTask); + + const caller = await authedCaller(); + const result = await caller.tasks.create({ + title: 'Write tests', + description: 'Cover the routers', + priority: 'medium', + }); + + expect(result).toEqual(mockTask); + expect(taskService.createTask).toHaveBeenCalledWith({ + userId, + organizationId: undefined, + title: 'Write tests', + description: 'Cover the routers', + priority: 'medium', + dueDate: undefined, + }); + }); + + it('ignores any attempt to set the owner from the input payload', async () => { + vi.mocked(taskService.createTask).mockResolvedValue(mockTask); + + const caller = await authedCaller(); + await caller.tasks.create({ + title: 'Write tests', + // @ts-expect-error - deliberately passing an unknown field a hostile client might send + userId: otherUserId, + }); + + expect(taskService.createTask).toHaveBeenCalledWith(expect.objectContaining({ userId })); + }); + + it('rejects an empty title', async () => { + const caller = await authedCaller(); + + await expectTRPCError(caller.tasks.create({ title: '' }), 'BAD_REQUEST'); + expect(taskService.createTask).not.toHaveBeenCalled(); + }); + + it('rejects an unauthenticated caller', async () => { + const caller = await anonCaller(); + + await expectTRPCError(caller.tasks.create({ title: 'Write tests' }), 'UNAUTHORIZED'); + expect(taskService.createTask).not.toHaveBeenCalled(); + }); + + it('maps a service failure to its error code', async () => { + vi.mocked(taskService.createTask).mockRejectedValue( + new Error('Failed to create task', { cause: ERRORS.INTERNAL_SERVER_ERROR }) + ); + + const caller = await authedCaller(); + + await expectTRPCError( + caller.tasks.create({ title: 'Write tests' }), + 'INTERNAL_SERVER_ERROR', + 'Failed to create task' + ); + }); + }); + + describe('update', () => { + it('forwards only the fields present in the input', async () => { + vi.mocked(taskService.updateTask).mockResolvedValue({ ...mockTask, title: 'Renamed' }); + + const caller = await authedCaller(); + await caller.tasks.update({ id: taskId, title: 'Renamed' }); + + expect(taskService.updateTask).toHaveBeenCalledWith({ + id: taskId, + userId, + title: 'Renamed', + }); + }); + + it('converts the completed boolean into the string column value', async () => { + vi.mocked(taskService.updateTask).mockResolvedValue({ ...mockTask, completed: true }); + + const caller = await authedCaller(); + await caller.tasks.update({ id: taskId, completed: true }); + + expect(taskService.updateTask).toHaveBeenCalledWith({ + id: taskId, + userId, + completed: 'true', + }); + }); + + it('forwards a null organizationId to detach a task from its organization', async () => { + vi.mocked(taskService.updateTask).mockResolvedValue(mockTask); + + const caller = await authedCaller(); + await caller.tasks.update({ id: taskId, organizationId: null }); + + expect(taskService.updateTask).toHaveBeenCalledWith({ + id: taskId, + userId, + organizationId: null, + }); + }); + + it('forwards an arbitrary organizationId without checking membership (documents current behavior)', async () => { + vi.mocked(taskService.updateTask).mockResolvedValue(mockTask); + + const caller = await authedCaller(); + await caller.tasks.update({ id: taskId, organizationId: orgId }); + + // `tasks` uses protectedProcedure, so no membership check runs on organizationId. + expect(taskService.updateTask).toHaveBeenCalledWith( + expect.objectContaining({ organizationId: orgId }) + ); + }); + + it('surfaces NOT_FOUND when the task is missing or owned by someone else', async () => { + vi.mocked(taskService.updateTask).mockRejectedValue( + new Error('Task not found or you do not have permission to update it', { + cause: ERRORS.NOT_FOUND, + }) + ); + + const caller = await authedCaller(); + + await expectTRPCError( + caller.tasks.update({ id: taskId, title: 'Renamed' }), + 'NOT_FOUND', + 'do not have permission' + ); + }); + + it('rejects a non-uuid task id', async () => { + const caller = await authedCaller(); + + await expectTRPCError(caller.tasks.update({ id: 'nope', title: 'x' }), 'BAD_REQUEST'); + expect(taskService.updateTask).not.toHaveBeenCalled(); + }); + + it('rejects an unauthenticated caller', async () => { + const caller = await anonCaller(); + + await expectTRPCError(caller.tasks.update({ id: taskId, title: 'x' }), 'UNAUTHORIZED'); + expect(taskService.updateTask).not.toHaveBeenCalled(); + }); + }); + + describe('delete', () => { + it('deletes a task scoped to the authenticated caller', async () => { + vi.mocked(taskService.deleteTask).mockResolvedValue({ success: true }); + + const caller = await authedCaller(); + const result = await caller.tasks.delete({ id: taskId }); + + expect(result).toEqual({ success: true }); + expect(taskService.deleteTask).toHaveBeenCalledWith({ id: taskId, userId }); + }); + + it('surfaces NOT_FOUND when the task belongs to another user', async () => { + vi.mocked(taskService.deleteTask).mockRejectedValue( + new Error('Task not found or you do not have permission to delete it', { + cause: ERRORS.NOT_FOUND, + }) + ); + + const caller = await authedCaller(); + + await expectTRPCError(caller.tasks.delete({ id: taskId }), 'NOT_FOUND'); + }); + + it('rejects an unauthenticated caller', async () => { + const caller = await anonCaller(); + + await expectTRPCError(caller.tasks.delete({ id: taskId }), 'UNAUTHORIZED'); + expect(taskService.deleteTask).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/__tests__/setup/utils.ts b/__tests__/setup/utils.ts index e337a02e..439bd873 100644 --- a/__tests__/setup/utils.ts +++ b/__tests__/setup/utils.ts @@ -1,6 +1,39 @@ +import { TRPCError } from '@trpc/server'; +import { expect } from 'vitest'; + /** * Encode session data to base64 (same format Better Auth uses) */ export const encodeSessionCookie = (sessionData: Record): string => { return Buffer.from(JSON.stringify({ session: sessionData })).toString('base64'); }; + +/** + * Assert that a tRPC call rejects with a specific TRPCError code. + * + * Router tests care about the *code* (UNAUTHORIZED / FORBIDDEN / NOT_FOUND / BAD_REQUEST) + * far more than the message, because the code is the authorization outcome. + */ +export async function expectTRPCError( + promise: Promise, + code: TRPCError['code'], + message?: string | RegExp +): Promise { + const error = await promise.then( + () => null, + (rejection: unknown) => rejection + ); + + expect(error, 'expected the procedure to reject, but it resolved').toBeInstanceOf(TRPCError); + + const trpcError = error as TRPCError; + expect(trpcError.code).toBe(code); + + if (typeof message === 'string') { + expect(trpcError.message).toContain(message); + } else if (message instanceof RegExp) { + expect(trpcError.message).toMatch(message); + } + + return trpcError; +}