Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,10 @@ Auth flows:
- `POST /v1/connect/github/webhook`
- `DELETE /v1/connect/github`
- `POST /v1/connect/:vendor/register` (`vendor` in `openai | anthropic | gemini`)
- Returns `202 Accepted` for long-running registration work with `{ taskId, state, stage, pollAfterMs, heartbeatAt, updatedAt }`
- Clients should poll task status until `state` becomes `succeeded` or `failed`
- `GET /v1/tasks/:taskId`
- Returns current long-task status `{ taskId, state, stage, pollAfterMs, heartbeatAt, updatedAt, error? }`
- `GET /v1/connect/:vendor/token`
- `DELETE /v1/connect/:vendor`
- `GET /v1/connect/tokens`
Expand Down
104 changes: 104 additions & 0 deletions packages/happy-app/sources/sync/apiServices.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { connectService } from './apiServices';
import { AuthCredentials } from '@/auth/tokenStorage';

vi.mock('./serverConfig', () => ({
getServerUrl: () => 'https://api.test.com'
}));

vi.mock('@/utils/time', () => ({
backoff: vi.fn((fn) => fn())
}));

describe('apiServices', () => {
const mockCredentials: AuthCredentials = {
token: 'test-token',
secret: 'test-secret'
};

beforeEach(() => {
vi.clearAllMocks();
global.fetch = vi.fn();
});

afterEach(() => {
vi.restoreAllMocks();
});

it('supports the legacy immediate success response', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: vi.fn().mockResolvedValue({ success: true })
});

await expect(connectService(mockCredentials, 'openai', { oauth: true })).resolves.toBeUndefined();
expect(global.fetch).toHaveBeenCalledTimes(1);
});

it('waits for the long-task response to complete', async () => {
global.fetch = vi.fn()
.mockResolvedValueOnce({
ok: true,
status: 202,
json: vi.fn().mockResolvedValue({
taskId: 'task-1',
state: 'accepted',
stage: 'accepted',
pollAfterMs: 1,
heartbeatAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z'
})
})
.mockResolvedValueOnce({
ok: true,
status: 200,
json: vi.fn().mockResolvedValue({
taskId: 'task-1',
state: 'succeeded',
stage: 'succeeded',
pollAfterMs: 1,
heartbeatAt: '2026-01-01T00:00:00.100Z',
updatedAt: '2026-01-01T00:00:00.100Z'
})
});

await expect(connectService(mockCredentials, 'anthropic', { oauth: true })).resolves.toBeUndefined();
expect(global.fetch).toHaveBeenCalledTimes(2);
expect(global.fetch).toHaveBeenNthCalledWith(
2,
'https://api.test.com/v1/tasks/task-1',
{
method: 'GET',
headers: {
'Authorization': 'Bearer test-token',
'Content-Type': 'application/json'
}
}
);
});

it('surfaces a clearer error when the long-task record is lost', async () => {
global.fetch = vi.fn()
.mockResolvedValueOnce({
ok: true,
status: 202,
json: vi.fn().mockResolvedValue({
taskId: 'task-2',
state: 'accepted',
stage: 'accepted',
pollAfterMs: 1,
heartbeatAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z'
})
})
.mockResolvedValueOnce({
ok: false,
status: 404,
json: vi.fn()
});

await expect(connectService(mockCredentials, 'gemini', { oauth: true }))
.rejects.toThrow('Service connection task was lost before completion. Please retry.');
});
});
60 changes: 58 additions & 2 deletions packages/happy-app/sources/sync/apiServices.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,57 @@ import { AuthCredentials } from '@/auth/tokenStorage';
import { backoff } from '@/utils/time';
import { getServerUrl } from './serverConfig';

type RegisterSuccessResponse = { success: true };

type RegisterTaskResponse = {
taskId: string;
state: 'accepted' | 'running' | 'succeeded' | 'failed';
stage: string;
pollAfterMs: number;
heartbeatAt: string;
updatedAt: string;
error?: string;
};

function isRegisterTaskResponse(data: unknown): data is RegisterTaskResponse {
return Boolean(
data &&
typeof data === 'object' &&
'taskId' in data &&
typeof (data as RegisterTaskResponse).taskId === 'string'
);
}

async function waitForRegisterTask(credentials: AuthCredentials, initialTask: RegisterTaskResponse, apiEndpoint: string): Promise<void> {
while (true) {
const response = await fetch(`${apiEndpoint}/v1/tasks/${initialTask.taskId}`, {
method: 'GET',
headers: {
'Authorization': `Bearer ${credentials.token}`,
'Content-Type': 'application/json'
}
});

if (response.status === 404) {
throw new Error('Service connection task was lost before completion. Please retry.');
}

if (!response.ok) {
throw new Error(`Failed to poll service connection task: ${response.status}`);
}

const status = await response.json() as RegisterTaskResponse;
if (status.state === 'succeeded') {
return;
}
if (status.state === 'failed') {
throw new Error(status.error || 'Failed to connect service account');
}

await new Promise((resolve) => setTimeout(resolve, status.pollAfterMs || 500));
}
}

/**
* Connect a service to the user's account
*/
Expand All @@ -26,7 +77,12 @@ export async function connectService(
throw new Error(`Failed to connect ${service}: ${response.status}`);
}

const data = await response.json() as { success: true };
const data = await response.json() as RegisterSuccessResponse | RegisterTaskResponse;
if (isRegisterTaskResponse(data)) {
await waitForRegisterTask(credentials, data, API_ENDPOINT);
return;
}

if (!data.success) {
throw new Error(`Failed to connect ${service} account`);
}
Expand Down Expand Up @@ -60,4 +116,4 @@ export async function disconnectService(credentials: AuthCredentials, service: s
throw new Error(`Failed to disconnect ${service} account`);
}
});
}
}
163 changes: 161 additions & 2 deletions packages/happy-cli/src/api/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,25 +4,47 @@ import axios from 'axios';
import { connectionState } from '@/utils/serverConnectionErrors';

// Use vi.hoisted to ensure mock functions are available when vi.mock factory runs
const { mockPost, mockIsAxiosError } = vi.hoisted(() => ({
const { mockPost, mockGet, mockIsAxiosError } = vi.hoisted(() => ({
mockPost: vi.fn(),
mockGet: vi.fn(),
mockIsAxiosError: vi.fn(() => true)
}));

vi.mock('axios', () => ({
default: {
post: mockPost,
get: mockGet,
isAxiosError: mockIsAxiosError
},
isAxiosError: mockIsAxiosError
}));

vi.mock('chalk', () => ({
default: new Proxy({}, {
get: () => (value: string) => value
})
}));

vi.mock('@/ui/logger', () => ({
logger: {
debug: vi.fn()
}
}));

vi.mock('./apiSession', () => ({
ApiSessionClient: vi.fn()
}));

vi.mock('./apiMachine', () => ({
ApiMachineClient: vi.fn()
}));

vi.mock('./pushNotifications', () => ({
PushNotificationClient: vi.fn(function PushNotificationClient() {
return {};
})
}));

// Mock encryption utilities
vi.mock('./encryption', () => ({
decodeBase64: vi.fn((data: string) => data),
Expand Down Expand Up @@ -310,4 +332,141 @@ describe('Api server error handling', () => {
consoleSpy.mockRestore();
});
});
});

describe('registerVendorToken', () => {
it('waits for long-task completion while progress continues', async () => {
mockPost.mockResolvedValue({
status: 202,
data: {
taskId: 'task-1',
state: 'accepted',
stage: 'accepted',
pollAfterMs: 1,
heartbeatAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z'
}
});
mockGet
.mockResolvedValueOnce({
data: {
taskId: 'task-1',
state: 'running',
stage: 'persisting',
pollAfterMs: 1,
heartbeatAt: '2026-01-01T00:00:00.100Z',
updatedAt: '2026-01-01T00:00:00.100Z'
}
})
.mockResolvedValueOnce({
data: {
taskId: 'task-1',
state: 'succeeded',
stage: 'succeeded',
pollAfterMs: 1,
heartbeatAt: '2026-01-01T00:00:00.200Z',
updatedAt: '2026-01-01T00:00:00.200Z'
}
});

const seenStages: string[] = [];
await expect(api.registerVendorToken('openai', { oauth: true }, {
pollIntervalMs: 1,
idleTimeoutMs: 50,
absoluteTimeoutMs: 500,
onProgress: (status) => seenStages.push(status.stage)
})).resolves.toBeUndefined();

expect(mockGet).toHaveBeenCalledTimes(2);
expect(seenStages).toEqual(['accepted', 'persisting', 'succeeded']);
});

it('fails when task stops making progress', async () => {
mockPost.mockResolvedValue({
status: 202,
data: {
taskId: 'task-2',
state: 'accepted',
stage: 'accepted',
pollAfterMs: 1,
heartbeatAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z'
}
});
mockGet.mockResolvedValue({
data: {
taskId: 'task-2',
state: 'running',
stage: 'persisting',
pollAfterMs: 1,
heartbeatAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z'
}
});

await expect(api.registerVendorToken('openai', { oauth: true }, {
pollIntervalMs: 1,
idleTimeoutMs: 10,
absoluteTimeoutMs: 100
})).rejects.toThrow('Failed to register vendor token: Vendor token registration stalled after 10ms without progress');
});

it('surfaces task failure errors', async () => {
mockPost.mockResolvedValue({
status: 202,
data: {
taskId: 'task-3',
state: 'accepted',
stage: 'accepted',
pollAfterMs: 1,
heartbeatAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z'
}
});
mockGet.mockResolvedValue({
data: {
taskId: 'task-3',
state: 'failed',
stage: 'failed',
pollAfterMs: 1,
heartbeatAt: '2026-01-01T00:00:00.100Z',
updatedAt: '2026-01-01T00:00:00.100Z',
error: 'Vendor token registration failed. Please retry.',
errorCode: 'CONNECT_REGISTER_FAILED'
}
});

await expect(api.registerVendorToken('openai', { oauth: true }, {
pollIntervalMs: 1,
idleTimeoutMs: 50,
absoluteTimeoutMs: 500
})).rejects.toThrow('Failed to register vendor token: Vendor token registration failed. Please retry.');
});

it('explains when the long-task record disappears mid-poll', async () => {
mockPost.mockResolvedValue({
status: 202,
data: {
taskId: 'task-4',
state: 'accepted',
stage: 'accepted',
pollAfterMs: 1,
heartbeatAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z'
}
});
mockGet.mockRejectedValue({
response: {
status: 404
}
});

await expect(api.registerVendorToken('openai', { oauth: true }, {
pollIntervalMs: 1,
idleTimeoutMs: 50,
absoluteTimeoutMs: 500
})).rejects.toThrow(
'Failed to register vendor token: Vendor token registration task was lost before completion (the server may have restarted or evicted the task). Please retry registration.'
);
});
});
});
Loading
Loading