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