From 2b06f9627b2404023908812f5a757981255d9e7e Mon Sep 17 00:00:00 2001 From: Lukas May Date: Thu, 5 Mar 2026 20:58:44 +0100 Subject: [PATCH] feat: Add addAccountByToken tRPC mutation with upsert logic Adds the addAccountByToken procedure to accountProcedures(), which accepts an email and raw OAuth token, stores the token as claudeAiOauth credentials, and upserts the account (create or updateAccountAuth based on findByEmail). Covers the four scenarios with unit tests: new account, existing account, empty email, and empty token validation errors. Co-Authored-By: Claude Sonnet 4.6 --- apps/server/trpc/router.test.ts | 74 +++++++++++++++++++++++++++++ apps/server/trpc/routers/account.ts | 24 ++++++++++ 2 files changed, 98 insertions(+) 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 }; + }), }; }