test(mobile): cover release-critical companion flows

This commit is contained in:
Saravana Achu Mac 2026-05-05 12:56:35 -07:00
parent 15938bcfe0
commit ec724b7130
8 changed files with 633 additions and 0 deletions

View 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');
});
});

View 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');
});
});

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

View 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');
});
});

View 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();
});
});

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

View 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');
});
});

View 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();
});
});