diff --git a/apps/server/trpc/router.test.ts b/apps/server/trpc/router.test.ts index c585ce5..eb64e50 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,74 @@ describe('tRPC Router', () => { }); }); + describe('addAccountByToken procedure', () => { + let mockRepo: AccountRepository; + let caller: ReturnType; + + beforeEach(() => { + vi.resetAllMocks(); + mockRepo = { + findByEmail: vi.fn(), + updateAccountAuth: vi.fn(), + create: vi.fn(), + } as unknown as AccountRepository; + const ctx = createTestContext({ accountRepository: mockRepo }); + caller = createCaller(ctx); + }); + + it('creates a new account and returns { upserted: false, account }', async () => { + const stubAccount = { id: 'acc-1', email: 'new@example.com', provider: 'claude' }; + vi.mocked(mockRepo.findByEmail).mockResolvedValue(null); + vi.mocked(mockRepo.create).mockResolvedValue(stubAccount as any); + + const result = await caller.addAccountByToken({ email: 'new@example.com', token: 'tok-abc' }); + + expect(result).toEqual({ upserted: false, account: stubAccount }); + expect(mockRepo.create).toHaveBeenCalledWith({ + email: 'new@example.com', + provider: 'claude', + configJson: '{"hasCompletedOnboarding":true}', + credentials: '{"claudeAiOauth":{"accessToken":"tok-abc"}}', + }); + expect(mockRepo.updateAccountAuth).not.toHaveBeenCalled(); + }); + + it('updates an existing account and returns { upserted: true, account }', async () => { + const existingAccount = { id: 'acc-existing', email: 'existing@example.com', provider: 'claude' }; + const updatedAccount = { ...existingAccount, configJson: '{"hasCompletedOnboarding":true}' }; + vi.mocked(mockRepo.findByEmail).mockResolvedValue(existingAccount as any); + vi.mocked(mockRepo.updateAccountAuth).mockResolvedValue(updatedAccount as any); + + const result = await caller.addAccountByToken({ email: 'existing@example.com', token: 'tok-xyz' }); + + expect(result).toEqual({ upserted: true, account: updatedAccount }); + expect(mockRepo.updateAccountAuth).toHaveBeenCalledWith( + 'acc-existing', + '{"hasCompletedOnboarding":true}', + '{"claudeAiOauth":{"accessToken":"tok-xyz"}}', + ); + expect(mockRepo.create).not.toHaveBeenCalled(); + }); + + it('throws BAD_REQUEST when email is empty', async () => { + await expect( + caller.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 () => { + await expect( + caller.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 }; + }), }; } diff --git a/docs/server-api.md b/docs/server-api.md index 2b69321..0f7341d 100644 --- a/docs/server-api.md +++ b/docs/server-api.md @@ -190,6 +190,7 @@ Each procedure uses `require*Repository(ctx)` helpers that throw `TRPCError(INTE | updateAccountAuth | mutation | Update credentials | | markAccountExhausted | mutation | Set exhaustion timer | | listProviderNames | query | Available provider names | +| addAccountByToken | mutation | Upsert account from OAuth token; returns `{ upserted, account }` | ### Proposals | Procedure | Type | Description |