feat: add addAccountByToken tRPC mutation with upsert logic and tests

Adds a new mutation that accepts an email + raw OAuth token and upserts
the account — creating it if it doesn't exist, updating credentials if
it does. Covers all four scenarios with unit tests (new, existing,
empty-email, empty-token validation).
This commit is contained in:
Lukas May
2026-03-06 10:58:02 +01:00
parent f3042abe04
commit 9c4131c814
3 changed files with 94 additions and 0 deletions

View File

@@ -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<typeof createCaller>;
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 = {

View File

@@ -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 };
}),
};
}

View File

@@ -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 |