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:
@@ -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 = {
|
||||
|
||||
@@ -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 };
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 |
|
||||
|
||||
Reference in New Issue
Block a user