diff --git a/apps/server/trpc/router.test.ts b/apps/server/trpc/router.test.ts index c585ce5..2dfa4d1 100644 --- a/apps/server/trpc/router.test.ts +++ b/apps/server/trpc/router.test.ts @@ -14,6 +14,7 @@ import { } from './index.js'; import type { TRPCContext } from './context.js'; import type { EventBus } from '../events/types.js'; +import type { AccountRepository } from '../db/repositories/account-repository.js'; // Create caller factory for the app router const createCaller = createCallerFactory(appRouter); @@ -161,6 +162,79 @@ describe('tRPC Router', () => { }); }); + describe('addAccountByToken procedure', () => { + let mockRepo: AccountRepository; + + beforeEach(() => { + mockRepo = { + findByEmail: vi.fn(), + updateAccountAuth: vi.fn(), + create: vi.fn(), + } as unknown as AccountRepository; + vi.clearAllMocks(); + }); + + it('creates a new account and returns { upserted: false, account }', async () => { + const stubAccount = { id: 'new-id', email: 'user@example.com', provider: 'claude' }; + (mockRepo.findByEmail as ReturnType).mockResolvedValue(null); + (mockRepo.create as ReturnType).mockResolvedValue(stubAccount); + + const testCtx = createTestContext({ accountRepository: mockRepo }); + const testCaller = createCaller(testCtx); + const result = await testCaller.addAccountByToken({ email: 'user@example.com', token: 'my-token' }); + + expect(result).toEqual({ upserted: false, account: stubAccount }); + expect(mockRepo.create).toHaveBeenCalledWith({ + email: 'user@example.com', + provider: 'claude', + configJson: '{"hasCompletedOnboarding":true}', + credentials: '{"claudeAiOauth":{"accessToken":"my-token"}}', + }); + expect(mockRepo.updateAccountAuth).not.toHaveBeenCalled(); + }); + + it('updates existing account and returns { upserted: true, account }', async () => { + const existingAccount = { id: 'existing-id', email: 'user@example.com', provider: 'claude' }; + const updatedAccount = { id: 'existing-id', email: 'user@example.com', provider: 'claude', updated: true }; + (mockRepo.findByEmail as ReturnType).mockResolvedValue(existingAccount); + (mockRepo.updateAccountAuth as ReturnType).mockResolvedValue(updatedAccount); + + const testCtx = createTestContext({ accountRepository: mockRepo }); + const testCaller = createCaller(testCtx); + const result = await testCaller.addAccountByToken({ email: 'user@example.com', token: 'my-token' }); + + expect(result).toEqual({ upserted: true, account: updatedAccount }); + expect(mockRepo.updateAccountAuth).toHaveBeenCalledWith( + 'existing-id', + '{"hasCompletedOnboarding":true}', + '{"claudeAiOauth":{"accessToken":"my-token"}}', + ); + expect(mockRepo.create).not.toHaveBeenCalled(); + }); + + it('throws BAD_REQUEST when email is empty', async () => { + const testCtx = createTestContext({ accountRepository: mockRepo }); + const testCaller = createCaller(testCtx); + + await expect(testCaller.addAccountByToken({ email: '', provider: 'claude', token: 'tok' })) + .rejects.toMatchObject({ code: 'BAD_REQUEST' }); + expect(mockRepo.findByEmail).not.toHaveBeenCalled(); + expect(mockRepo.create).not.toHaveBeenCalled(); + expect(mockRepo.updateAccountAuth).not.toHaveBeenCalled(); + }); + + it('throws BAD_REQUEST when token is empty', async () => { + const testCtx = createTestContext({ accountRepository: mockRepo }); + const testCaller = createCaller(testCtx); + + await expect(testCaller.addAccountByToken({ email: 'user@example.com', provider: 'claude', token: '' })) + .rejects.toMatchObject({ code: 'BAD_REQUEST' }); + expect(mockRepo.findByEmail).not.toHaveBeenCalled(); + expect(mockRepo.create).not.toHaveBeenCalled(); + expect(mockRepo.updateAccountAuth).not.toHaveBeenCalled(); + }); + }); + describe('Zod schema validation', () => { it('healthResponseSchema should reject invalid status', () => { const invalid = { diff --git a/apps/server/trpc/routers/account.ts b/apps/server/trpc/routers/account.ts index 99c638d..b40a4db 100644 --- a/apps/server/trpc/routers/account.ts +++ b/apps/server/trpc/routers/account.ts @@ -72,5 +72,29 @@ export function accountProcedures(publicProcedure: ProcedureBuilder) { .query(() => { return listProviderNames(); }), + + addAccountByToken: publicProcedure + .input(z.object({ + email: z.string().min(1), + provider: z.string().default('claude'), + token: z.string().min(1), + })) + .mutation(async ({ ctx, input }) => { + const repo = requireAccountRepository(ctx); + const credentials = JSON.stringify({ claudeAiOauth: { accessToken: input.token } }); + const configJson = JSON.stringify({ hasCompletedOnboarding: true }); + const existing = await repo.findByEmail(input.email); + if (existing) { + const account = await repo.updateAccountAuth(existing.id, configJson, credentials); + return { upserted: true, account }; + } + const account = await repo.create({ + email: input.email, + provider: input.provider, + configJson, + credentials, + }); + return { upserted: false, account }; + }), }; }