test(mobile): cover release-critical companion flows
This commit is contained in:
parent
15938bcfe0
commit
ec724b7130
61
mobile/src/api/intake.test.ts
Normal file
61
mobile/src/api/intake.test.ts
Normal file
@ -0,0 +1,61 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const fetchMock = vi.fn();
|
||||
|
||||
vi.mock('./client', () => ({
|
||||
getApiClient: () => ({
|
||||
fetch: fetchMock,
|
||||
}),
|
||||
}));
|
||||
|
||||
import { getIntakeJob, listIntakeJobs, listIntakeRules, submitIntake } from './intake';
|
||||
|
||||
describe('mobile intake API client', () => {
|
||||
beforeEach(() => {
|
||||
fetchMock.mockReset();
|
||||
});
|
||||
|
||||
it('submits URL intake with optional workspace and template override', async () => {
|
||||
fetchMock.mockResolvedValueOnce({
|
||||
jobId: 'job-1',
|
||||
noteId: 'note-1',
|
||||
contentType: 'article',
|
||||
ruleMatched: null,
|
||||
templateSlug: 'summarize',
|
||||
status: 'queued',
|
||||
});
|
||||
|
||||
await submitIntake('https://example.com/post', 'ws-1', 'summarize');
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'/intake',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
url: 'https://example.com/post',
|
||||
workspaceId: 'ws-1',
|
||||
templateOverride: 'summarize',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('lists active intake jobs with query filters', async () => {
|
||||
fetchMock.mockResolvedValueOnce({ items: [{ id: 'job-1', status: 'processing' }], total: 1 });
|
||||
|
||||
const jobs = await listIntakeJobs({ status: 'queued,processing', since: '2026-05-05T00:00:00Z', limit: 10 });
|
||||
|
||||
expect(jobs).toEqual([{ id: 'job-1', status: 'processing' }]);
|
||||
expect(fetchMock).toHaveBeenCalledWith('/intake/jobs?status=queued%2Cprocessing&since=2026-05-05T00%3A00%3A00Z&limit=10');
|
||||
});
|
||||
|
||||
it('gets one intake job and lists rules', async () => {
|
||||
fetchMock.mockResolvedValueOnce({ id: 'job-1', status: 'complete' });
|
||||
await expect(getIntakeJob('job/1')).resolves.toEqual({ id: 'job-1', status: 'complete' });
|
||||
expect(fetchMock).toHaveBeenCalledWith('/intake/jobs/job%2F1');
|
||||
|
||||
fetchMock.mockResolvedValueOnce({ items: [{ id: 'rule-1', name: 'Articles' }], total: 1 });
|
||||
await expect(listIntakeRules()).resolves.toEqual([{ id: 'rule-1', name: 'Articles' }]);
|
||||
expect(fetchMock).toHaveBeenCalledWith('/intake-rules');
|
||||
});
|
||||
});
|
||||
94
mobile/src/api/note-agent-actions.test.ts
Normal file
94
mobile/src/api/note-agent-actions.test.ts
Normal file
@ -0,0 +1,94 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { fetchMock, listWorkspacesMock } = vi.hoisted(() => ({
|
||||
fetchMock: vi.fn(),
|
||||
listWorkspacesMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./client', () => ({
|
||||
getApiClient: () => ({
|
||||
fetch: fetchMock,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('./workspaces', () => ({
|
||||
listWorkspaces: listWorkspacesMock,
|
||||
}));
|
||||
|
||||
import { listActivityFeed, listApprovalQueue, updateApprovalState } from './note-agent-actions';
|
||||
|
||||
const proposedAction = {
|
||||
id: 'a1',
|
||||
workspaceId: 'ws-1',
|
||||
noteId: 'n1',
|
||||
actorId: 'agent',
|
||||
actionType: 'extract_tasks' as const,
|
||||
state: 'proposed' as const,
|
||||
reason: 'Extract tasks',
|
||||
updatedAt: '2026-05-05T12:00:00Z',
|
||||
};
|
||||
|
||||
const approvedAction = {
|
||||
...proposedAction,
|
||||
id: 'a2',
|
||||
actionType: 'attach_citation' as const,
|
||||
state: 'approved' as const,
|
||||
afterSummary: 'Citation attached',
|
||||
updatedAt: '2026-05-05T10:00:00Z',
|
||||
};
|
||||
|
||||
describe('mobile note agent actions API client', () => {
|
||||
beforeEach(() => {
|
||||
fetchMock.mockReset();
|
||||
listWorkspacesMock.mockReset();
|
||||
listWorkspacesMock.mockResolvedValue([{ id: 'ws-1', name: 'Research' }, { id: 'ws-2', name: 'Inbox' }]);
|
||||
});
|
||||
|
||||
it('builds the approval queue from draft and proposed agent actions across workspaces', async () => {
|
||||
fetchMock.mockResolvedValueOnce({ items: [approvedAction, proposedAction] });
|
||||
fetchMock.mockResolvedValueOnce({ items: [] });
|
||||
|
||||
const approvals = await listApprovalQueue();
|
||||
|
||||
expect(approvals).toEqual([
|
||||
{
|
||||
id: 'a1',
|
||||
workspaceId: 'ws-1',
|
||||
noteId: 'n1',
|
||||
title: 'Extract tasks',
|
||||
summary: 'Extract tasks',
|
||||
status: 'pending',
|
||||
},
|
||||
]);
|
||||
expect(fetchMock).toHaveBeenCalledWith('/note-agent-actions?workspaceId=ws-1');
|
||||
expect(fetchMock).toHaveBeenCalledWith('/note-agent-actions?workspaceId=ws-2');
|
||||
});
|
||||
|
||||
it('maps recent activity kinds for inbox activity feed', async () => {
|
||||
fetchMock.mockResolvedValueOnce({ items: [proposedAction, approvedAction] });
|
||||
fetchMock.mockResolvedValueOnce({ items: [] });
|
||||
|
||||
const activity = await listActivityFeed();
|
||||
|
||||
expect(activity.map((item) => ({ id: item.id, kind: item.kind }))).toEqual([
|
||||
{ id: 'a1', kind: 'task' },
|
||||
{ id: 'a2', kind: 'note' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('updates approval state with review note', async () => {
|
||||
fetchMock.mockResolvedValueOnce({ ...proposedAction, state: 'rejected' });
|
||||
|
||||
await updateApprovalState('a/1', 'ws 1', 'rejected', 'Not ready');
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'/note-agent-actions/a%2F1?workspaceId=ws%201',
|
||||
expect.objectContaining({
|
||||
method: 'PATCH',
|
||||
body: expect.stringContaining('"state":"rejected"'),
|
||||
}),
|
||||
);
|
||||
const body = JSON.parse(fetchMock.mock.calls[0][1].body as string) as { reviewNote: string };
|
||||
expect(body.reviewNote).toBe('Not ready');
|
||||
});
|
||||
});
|
||||
91
mobile/src/api/notes.test.ts
Normal file
91
mobile/src/api/notes.test.ts
Normal file
@ -0,0 +1,91 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { fetchMock, listWorkspacesMock } = vi.hoisted(() => ({
|
||||
fetchMock: vi.fn(),
|
||||
listWorkspacesMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./client', () => ({
|
||||
getApiClient: () => ({
|
||||
fetch: fetchMock,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('./workspaces', () => ({
|
||||
listWorkspaces: listWorkspacesMock,
|
||||
}));
|
||||
|
||||
import { createNote, getNote, listNotes, updateNote } from './notes';
|
||||
|
||||
const apiNote = {
|
||||
id: 'n1',
|
||||
workspaceId: 'ws 1',
|
||||
title: 'Release note',
|
||||
body: 'Ship it',
|
||||
status: 'active' as const,
|
||||
updatedAt: '2026-05-05T12:00:00.000Z',
|
||||
};
|
||||
|
||||
describe('mobile notes API client', () => {
|
||||
beforeEach(() => {
|
||||
fetchMock.mockReset();
|
||||
listWorkspacesMock.mockReset();
|
||||
listWorkspacesMock.mockResolvedValue([{ id: 'ws 1', name: 'Research' }]);
|
||||
});
|
||||
|
||||
it('lists notes with workspace names for note list cards', async () => {
|
||||
fetchMock.mockResolvedValueOnce({ items: [apiNote] });
|
||||
|
||||
await expect(listNotes()).resolves.toEqual([
|
||||
{
|
||||
id: 'n1',
|
||||
workspaceId: 'ws 1',
|
||||
title: 'Release note',
|
||||
body: 'Ship it',
|
||||
status: 'active',
|
||||
workspaceName: 'Research',
|
||||
updatedAt: '2026-05-05T12:00:00.000Z',
|
||||
},
|
||||
]);
|
||||
expect(fetchMock).toHaveBeenCalledWith('/notes');
|
||||
});
|
||||
|
||||
it('loads note detail with encoded workspace scope', async () => {
|
||||
fetchMock.mockResolvedValueOnce(apiNote);
|
||||
|
||||
await getNote('n1', 'ws 1');
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith('/notes/n1?workspaceId=ws%201');
|
||||
});
|
||||
|
||||
it('creates a draft note with trimmed title and workspace scope', async () => {
|
||||
fetchMock.mockResolvedValueOnce({ ...apiNote, title: 'New idea', status: 'draft' });
|
||||
|
||||
const created = await createNote('ws 1', ' New idea ', 'Body');
|
||||
|
||||
expect(created.title).toBe('New idea');
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'/notes',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
}),
|
||||
);
|
||||
const body = JSON.parse(fetchMock.mock.calls[0][1].body as string) as { workspaceId: string; title: string };
|
||||
expect(body.workspaceId).toBe('ws 1');
|
||||
expect(body.title).toBe('New idea');
|
||||
});
|
||||
|
||||
it('updates note detail with encoded workspace scope', async () => {
|
||||
fetchMock.mockResolvedValueOnce({ ...apiNote, title: 'Updated' });
|
||||
|
||||
await updateNote('n1', 'ws 1', 'Updated', 'Body');
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'/notes/n1?workspaceId=ws%201',
|
||||
expect.objectContaining({
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ title: 'Updated', body: 'Body' }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
32
mobile/src/api/workspaces.test.ts
Normal file
32
mobile/src/api/workspaces.test.ts
Normal file
@ -0,0 +1,32 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const fetchMock = vi.fn();
|
||||
|
||||
vi.mock('./client', () => ({
|
||||
getApiClient: () => ({
|
||||
fetch: fetchMock,
|
||||
}),
|
||||
}));
|
||||
|
||||
import { listWorkspaces } from './workspaces';
|
||||
|
||||
describe('mobile workspaces API client', () => {
|
||||
beforeEach(() => {
|
||||
fetchMock.mockReset();
|
||||
});
|
||||
|
||||
it('lists workspaces and preserves descriptions for selection UI', async () => {
|
||||
fetchMock.mockResolvedValueOnce({
|
||||
items: [
|
||||
{ id: 'ws-1', name: 'Research', description: 'AI notes' },
|
||||
{ id: 'ws-2', name: 'Inbox' },
|
||||
],
|
||||
});
|
||||
|
||||
await expect(listWorkspaces()).resolves.toEqual([
|
||||
{ id: 'ws-1', name: 'Research', description: 'AI notes' },
|
||||
{ id: 'ws-2', name: 'Inbox', description: undefined },
|
||||
]);
|
||||
expect(fetchMock).toHaveBeenCalledWith('/workspaces');
|
||||
});
|
||||
});
|
||||
85
mobile/src/app/prompt-result.test.tsx
Normal file
85
mobile/src/app/prompt-result.test.tsx
Normal file
@ -0,0 +1,85 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import React from 'react';
|
||||
import renderer, { act, type ReactTestRenderer } from 'react-test-renderer';
|
||||
|
||||
const backMock = vi.fn();
|
||||
const clearResultMock = vi.fn();
|
||||
const testGlobal = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean };
|
||||
testGlobal.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
let lastResult: {
|
||||
content: string;
|
||||
templateSlug: string;
|
||||
outputType: string;
|
||||
model?: string;
|
||||
usage?: { totalTokens: number };
|
||||
} | null = null;
|
||||
|
||||
vi.mock('expo-router', () => ({
|
||||
useRouter: () => ({ back: backMock }),
|
||||
useLocalSearchParams: () => ({ templateName: 'Summarize' }),
|
||||
}));
|
||||
|
||||
vi.mock('../store/prompt-store', () => ({
|
||||
usePromptStore: () => ({
|
||||
lastResult,
|
||||
clearResult: clearResultMock,
|
||||
}),
|
||||
}));
|
||||
|
||||
import PromptResultScreen from './prompt-result';
|
||||
|
||||
function renderWithAct(): ReactTestRenderer {
|
||||
let tree: ReactTestRenderer | null = null;
|
||||
act(() => {
|
||||
tree = renderer.create(<PromptResultScreen />);
|
||||
});
|
||||
if (!tree) {
|
||||
throw new Error('PromptResultScreen did not render');
|
||||
}
|
||||
return tree;
|
||||
}
|
||||
|
||||
function collectText(node: unknown): string {
|
||||
if (node === null || node === undefined) return '';
|
||||
if (typeof node === 'string') return node;
|
||||
if (Array.isArray(node)) return node.map(collectText).join('');
|
||||
if (typeof node === 'object' && 'children' in node) {
|
||||
const children = (node as { children?: unknown[] }).children ?? [];
|
||||
return children.map(collectText).join('');
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
describe('PromptResultScreen', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
lastResult = null;
|
||||
});
|
||||
|
||||
it('renders an empty state and back action without a result', () => {
|
||||
const tree = renderWithAct();
|
||||
|
||||
expect(collectText(tree.toJSON())).toContain('No prompt result available.');
|
||||
tree.root.findByProps({ accessibilityLabel: 'Go back' }).props.onPress();
|
||||
expect(backMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('renders prompt result metadata and clears result on dismiss', () => {
|
||||
lastResult = {
|
||||
content: 'Summarized note content',
|
||||
templateSlug: 'summarize',
|
||||
outputType: 'new_note',
|
||||
model: 'mock',
|
||||
usage: { totalTokens: 42 },
|
||||
};
|
||||
|
||||
const tree = renderWithAct();
|
||||
|
||||
const text = collectText(tree.toJSON());
|
||||
expect(text).toContain('Summarized note content');
|
||||
expect(text).toContain('mock · 42 tokens');
|
||||
tree.root.findByProps({ accessibilityLabel: 'Dismiss prompt result' }).props.onPress();
|
||||
expect(clearResultMock).toHaveBeenCalled();
|
||||
expect(backMock).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
105
mobile/src/lib/offline-queue.test.ts
Normal file
105
mobile/src/lib/offline-queue.test.ts
Normal file
@ -0,0 +1,105 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { PRODUCT_ID } from '../api/config';
|
||||
|
||||
const { createOfflineQueueMock, fetchMock, queue } = vi.hoisted(() => {
|
||||
const mockedQueue = {
|
||||
enqueue: vi.fn(),
|
||||
flush: vi.fn(),
|
||||
length: vi.fn(),
|
||||
};
|
||||
|
||||
return {
|
||||
queue: mockedQueue,
|
||||
createOfflineQueueMock: vi.fn(() => mockedQueue),
|
||||
fetchMock: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@bytelyst/offline-queue', () => ({
|
||||
createOfflineQueue: createOfflineQueueMock,
|
||||
}));
|
||||
|
||||
vi.mock('../api/client', () => ({
|
||||
getApiClient: () => ({
|
||||
fetch: fetchMock,
|
||||
}),
|
||||
}));
|
||||
|
||||
import {
|
||||
OFFLINE_QUEUE_MAX_RETRIES,
|
||||
OFFLINE_QUEUE_MAX_SIZE,
|
||||
enqueueNoteCreate,
|
||||
enqueueNoteUpdate,
|
||||
flushNoteQueue,
|
||||
getNoteQueueSize,
|
||||
} from './offline-queue';
|
||||
|
||||
describe('mobile offline note queue', () => {
|
||||
beforeEach(() => {
|
||||
queue.enqueue.mockReset();
|
||||
queue.flush.mockReset();
|
||||
queue.length.mockReset();
|
||||
fetchMock.mockReset();
|
||||
});
|
||||
|
||||
it('configures the common offline queue with NoteLett storage limits', async () => {
|
||||
expect(createOfflineQueueMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
storageKey: `${PRODUCT_ID}-offline-queue`,
|
||||
maxRetries: OFFLINE_QUEUE_MAX_RETRIES,
|
||||
maxQueueSize: OFFLINE_QUEUE_MAX_SIZE,
|
||||
}),
|
||||
);
|
||||
expect(OFFLINE_QUEUE_MAX_RETRIES).toBe(5);
|
||||
expect(OFFLINE_QUEUE_MAX_SIZE).toBe(50);
|
||||
});
|
||||
|
||||
it('enqueues note creates and updates with product backend paths', () => {
|
||||
enqueueNoteCreate({ id: 'n1', workspaceId: 'ws-1', title: 'Queued', body: 'Body' });
|
||||
enqueueNoteUpdate({ id: 'n1', workspaceId: 'ws 1', title: 'Updated', body: 'Body' });
|
||||
|
||||
expect(queue.enqueue).toHaveBeenNthCalledWith(1, {
|
||||
id: 'n1',
|
||||
action: 'create',
|
||||
path: '/notes',
|
||||
payload: {
|
||||
id: 'n1',
|
||||
workspaceId: 'ws-1',
|
||||
title: 'Queued',
|
||||
body: 'Body',
|
||||
tags: [],
|
||||
links: [],
|
||||
},
|
||||
});
|
||||
expect(queue.enqueue).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
action: 'update',
|
||||
path: '/notes/n1?workspaceId=ws%201',
|
||||
payload: { title: 'Updated', body: 'Body' },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('reports queue size and flushes queued actions through the shared API client', async () => {
|
||||
queue.length.mockReturnValueOnce(2);
|
||||
queue.flush.mockImplementationOnce(async (handler: (action: string, path: string, payload: unknown) => Promise<void>) => {
|
||||
await handler('create', '/notes', { id: 'n1' });
|
||||
await handler('update', '/notes/n1?workspaceId=ws-1', { title: 'Updated' });
|
||||
return { flushed: 2, failed: 0 };
|
||||
});
|
||||
|
||||
expect(getNoteQueueSize()).toBe(2);
|
||||
await expect(flushNoteQueue()).resolves.toEqual({ flushed: 2, failed: 0 });
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'/notes',
|
||||
expect.objectContaining({ method: 'POST', body: JSON.stringify({ id: 'n1' }) }),
|
||||
);
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'/notes/n1?workspaceId=ws-1',
|
||||
expect.objectContaining({ method: 'PATCH', body: JSON.stringify({ title: 'Updated' }) }),
|
||||
);
|
||||
});
|
||||
});
|
||||
88
mobile/src/lib/platform-clients.test.ts
Normal file
88
mobile/src/lib/platform-clients.test.ts
Normal file
@ -0,0 +1,88 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { API_CONFIG, PRODUCT_ID } from '../api/config';
|
||||
import { APP_PLATFORM, APP_VERSION, OS_VERSION } from './app-metadata';
|
||||
|
||||
const {
|
||||
createFeedbackClientMock,
|
||||
createBroadcastClientMock,
|
||||
createSurveyClientMock,
|
||||
getAccessTokenMock,
|
||||
} = vi.hoisted(() => ({
|
||||
createFeedbackClientMock: vi.fn(),
|
||||
createBroadcastClientMock: vi.fn(),
|
||||
createSurveyClientMock: vi.fn(),
|
||||
getAccessTokenMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@bytelyst/feedback-client', () => ({
|
||||
createFeedbackClient: createFeedbackClientMock,
|
||||
}));
|
||||
|
||||
vi.mock('@bytelyst/broadcast-client', () => ({
|
||||
createBroadcastClient: createBroadcastClientMock,
|
||||
}));
|
||||
|
||||
vi.mock('@bytelyst/survey-client', () => ({
|
||||
createSurveyClient: createSurveyClientMock,
|
||||
}));
|
||||
|
||||
vi.mock('../api/auth', () => ({
|
||||
getAuthClient: () => ({
|
||||
getAccessToken: getAccessTokenMock,
|
||||
}),
|
||||
}));
|
||||
|
||||
import { getBroadcastClient } from './broadcast-client';
|
||||
import { getFeedbackClient } from './feedback-client';
|
||||
import { getSurveyClient } from './survey-client';
|
||||
|
||||
describe('mobile shared platform clients', () => {
|
||||
beforeEach(() => {
|
||||
createFeedbackClientMock.mockReset();
|
||||
createBroadcastClientMock.mockReset();
|
||||
createSurveyClientMock.mockReset();
|
||||
getAccessTokenMock.mockReset();
|
||||
getAccessTokenMock.mockReturnValue('mobile-token');
|
||||
createFeedbackClientMock.mockReturnValue({ submitWithScreenshot: vi.fn() });
|
||||
createBroadcastClientMock.mockReturnValue({ listMessages: vi.fn() });
|
||||
createSurveyClientMock.mockReturnValue({ getActiveSurvey: vi.fn() });
|
||||
});
|
||||
|
||||
it('configures feedback with platform base URL and auth token access', () => {
|
||||
getFeedbackClient();
|
||||
|
||||
expect(createFeedbackClientMock).toHaveBeenCalledWith({
|
||||
baseUrl: API_CONFIG.platformBaseUrl,
|
||||
getAuthToken: expect.any(Function),
|
||||
});
|
||||
expect(createFeedbackClientMock.mock.calls[0][0].getAuthToken()).toBe('mobile-token');
|
||||
});
|
||||
|
||||
it('configures broadcast with product identity and app metadata', () => {
|
||||
getBroadcastClient();
|
||||
|
||||
expect(createBroadcastClientMock).toHaveBeenCalledWith({
|
||||
baseUrl: API_CONFIG.platformBaseUrl,
|
||||
productId: PRODUCT_ID,
|
||||
getAuthToken: expect.any(Function),
|
||||
platform: APP_PLATFORM,
|
||||
appVersion: APP_VERSION,
|
||||
osVersion: OS_VERSION,
|
||||
});
|
||||
expect(createBroadcastClientMock.mock.calls[0][0].getAuthToken()).toBe('mobile-token');
|
||||
});
|
||||
|
||||
it('configures surveys with product identity and app metadata', () => {
|
||||
getSurveyClient();
|
||||
|
||||
expect(createSurveyClientMock).toHaveBeenCalledWith({
|
||||
baseUrl: API_CONFIG.platformBaseUrl,
|
||||
productId: PRODUCT_ID,
|
||||
getAuthToken: expect.any(Function),
|
||||
platform: APP_PLATFORM,
|
||||
appVersion: APP_VERSION,
|
||||
osVersion: OS_VERSION,
|
||||
});
|
||||
expect(createSurveyClientMock.mock.calls[0][0].getAuthToken()).toBe('mobile-token');
|
||||
});
|
||||
});
|
||||
77
mobile/src/store/intake-store.test.ts
Normal file
77
mobile/src/store/intake-store.test.ts
Normal file
@ -0,0 +1,77 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { IntakeJob } from '../api/intake';
|
||||
|
||||
const { listIntakeJobsMock, getIntakeJobMock } = vi.hoisted(() => ({
|
||||
listIntakeJobsMock: vi.fn(),
|
||||
getIntakeJobMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../api/intake', () => ({
|
||||
listIntakeJobs: (...args: unknown[]) => listIntakeJobsMock(...args),
|
||||
getIntakeJob: (...args: unknown[]) => getIntakeJobMock(...args),
|
||||
}));
|
||||
|
||||
import { useIntakeStore } from './intake-store';
|
||||
|
||||
function resetStore() {
|
||||
useIntakeStore.setState({
|
||||
activeJobs: [],
|
||||
completedJobIds: [],
|
||||
isPolling: false,
|
||||
});
|
||||
}
|
||||
|
||||
function makeJob(overrides: Partial<IntakeJob> = {}): IntakeJob {
|
||||
return {
|
||||
id: 'job-1',
|
||||
productId: 'notelett',
|
||||
userId: 'user-1',
|
||||
workspaceId: 'ws-1',
|
||||
noteId: 'n1',
|
||||
ruleId: 'rule-1',
|
||||
url: 'https://example.com',
|
||||
contentType: 'article',
|
||||
templateSlug: 'summarize',
|
||||
status: 'processing',
|
||||
startedAt: '2026-05-05T12:00:00Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('useIntakeStore', () => {
|
||||
beforeEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.clearAllMocks();
|
||||
resetStore();
|
||||
});
|
||||
|
||||
it('pollActiveJobs loads active jobs and prevents duplicate concurrent polls', async () => {
|
||||
listIntakeJobsMock.mockResolvedValueOnce([makeJob({ status: 'queued' })]);
|
||||
|
||||
await useIntakeStore.getState().pollActiveJobs();
|
||||
|
||||
expect(listIntakeJobsMock).toHaveBeenCalledWith({ status: 'queued,extracting,processing', limit: 50 });
|
||||
expect(useIntakeStore.getState().activeJobs[0].status).toBe('queued');
|
||||
expect(useIntakeStore.getState().isPolling).toBe(false);
|
||||
|
||||
useIntakeStore.setState({ isPolling: true });
|
||||
await useIntakeStore.getState().pollActiveJobs();
|
||||
expect(listIntakeJobsMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('waitForJob calls completion handler and records completed job ids', async () => {
|
||||
vi.useFakeTimers();
|
||||
const onComplete = vi.fn();
|
||||
const completeJob = makeJob({ status: 'complete' });
|
||||
useIntakeStore.setState({ activeJobs: [makeJob()] });
|
||||
getIntakeJobMock.mockResolvedValueOnce(completeJob);
|
||||
|
||||
useIntakeStore.getState().waitForJob('job-1', onComplete);
|
||||
await vi.advanceTimersByTimeAsync(3000);
|
||||
|
||||
expect(onComplete).toHaveBeenCalledWith(completeJob);
|
||||
expect(useIntakeStore.getState().activeJobs).toEqual([]);
|
||||
expect(useIntakeStore.getState().completedJobIds).toEqual(['job-1']);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user