From ec724b7130de847f4e8b2b32a85ce22f588b11d2 Mon Sep 17 00:00:00 2001 From: Saravana Achu Mac Date: Tue, 5 May 2026 12:56:35 -0700 Subject: [PATCH] test(mobile): cover release-critical companion flows --- mobile/src/api/intake.test.ts | 61 +++++++++++++ mobile/src/api/note-agent-actions.test.ts | 94 +++++++++++++++++++ mobile/src/api/notes.test.ts | 91 +++++++++++++++++++ mobile/src/api/workspaces.test.ts | 32 +++++++ mobile/src/app/prompt-result.test.tsx | 85 ++++++++++++++++++ mobile/src/lib/offline-queue.test.ts | 105 ++++++++++++++++++++++ mobile/src/lib/platform-clients.test.ts | 88 ++++++++++++++++++ mobile/src/store/intake-store.test.ts | 77 ++++++++++++++++ 8 files changed, 633 insertions(+) create mode 100644 mobile/src/api/intake.test.ts create mode 100644 mobile/src/api/note-agent-actions.test.ts create mode 100644 mobile/src/api/notes.test.ts create mode 100644 mobile/src/api/workspaces.test.ts create mode 100644 mobile/src/app/prompt-result.test.tsx create mode 100644 mobile/src/lib/offline-queue.test.ts create mode 100644 mobile/src/lib/platform-clients.test.ts create mode 100644 mobile/src/store/intake-store.test.ts diff --git a/mobile/src/api/intake.test.ts b/mobile/src/api/intake.test.ts new file mode 100644 index 0000000..eac02eb --- /dev/null +++ b/mobile/src/api/intake.test.ts @@ -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'); + }); +}); diff --git a/mobile/src/api/note-agent-actions.test.ts b/mobile/src/api/note-agent-actions.test.ts new file mode 100644 index 0000000..2c0a293 --- /dev/null +++ b/mobile/src/api/note-agent-actions.test.ts @@ -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'); + }); +}); diff --git a/mobile/src/api/notes.test.ts b/mobile/src/api/notes.test.ts new file mode 100644 index 0000000..f25745d --- /dev/null +++ b/mobile/src/api/notes.test.ts @@ -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' }), + }), + ); + }); +}); diff --git a/mobile/src/api/workspaces.test.ts b/mobile/src/api/workspaces.test.ts new file mode 100644 index 0000000..4268632 --- /dev/null +++ b/mobile/src/api/workspaces.test.ts @@ -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'); + }); +}); diff --git a/mobile/src/app/prompt-result.test.tsx b/mobile/src/app/prompt-result.test.tsx new file mode 100644 index 0000000..01cf045 --- /dev/null +++ b/mobile/src/app/prompt-result.test.tsx @@ -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(); + }); + 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(); + }); +}); diff --git a/mobile/src/lib/offline-queue.test.ts b/mobile/src/lib/offline-queue.test.ts new file mode 100644 index 0000000..e58779a --- /dev/null +++ b/mobile/src/lib/offline-queue.test.ts @@ -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) => { + 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' }) }), + ); + }); +}); diff --git a/mobile/src/lib/platform-clients.test.ts b/mobile/src/lib/platform-clients.test.ts new file mode 100644 index 0000000..1b827b1 --- /dev/null +++ b/mobile/src/lib/platform-clients.test.ts @@ -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'); + }); +}); diff --git a/mobile/src/store/intake-store.test.ts b/mobile/src/store/intake-store.test.ts new file mode 100644 index 0000000..d41d477 --- /dev/null +++ b/mobile/src/store/intake-store.test.ts @@ -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 { + 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(); + }); +});