From dd62d3bf5c53a693e21c4b027322c70c8c75d581 Mon Sep 17 00:00:00 2001 From: saravanakumardb1 Date: Thu, 19 Mar 2026 08:51:36 -0700 Subject: [PATCH] test(web+mobile): Playwright E2E scaffold + 23 mobile store tests [C4, B7] - web: playwright.config.ts + e2e/navigation.spec.ts (7 navigation tests, scaffolded) - web: exclude e2e/ from tsconfig (playwright not yet installed as dep) - mobile: notes-store.test.ts (7 tests: hydrate, openNote, saveDraft, updateNote) - mobile: workspace-store.test.ts (5 tests: hydrate, preserve/reset active, set/clear) - mobile: inbox-store.test.ts (5 tests: hydrate, approve, reject, unknown id guards) - mobile: auth-store.test.ts (6 tests: bootstrap, signIn, signOut, failure paths) - Total: 76 backend, 14 web, 23 mobile = 113 tests --- mobile/src/store/auth-store.test.ts | 80 +++++++++++++++++++++ mobile/src/store/inbox-store.test.ts | 81 +++++++++++++++++++++ mobile/src/store/notes-store.test.ts | 89 ++++++++++++++++++++++++ mobile/src/store/workspace-store.test.ts | 57 +++++++++++++++ web/e2e/navigation.spec.ts | 47 +++++++++++++ web/playwright.config.ts | 20 ++++++ web/tsconfig.json | 2 +- 7 files changed, 375 insertions(+), 1 deletion(-) create mode 100644 mobile/src/store/auth-store.test.ts create mode 100644 mobile/src/store/inbox-store.test.ts create mode 100644 mobile/src/store/notes-store.test.ts create mode 100644 mobile/src/store/workspace-store.test.ts create mode 100644 web/e2e/navigation.spec.ts create mode 100644 web/playwright.config.ts diff --git a/mobile/src/store/auth-store.test.ts b/mobile/src/store/auth-store.test.ts new file mode 100644 index 0000000..3df0fe5 --- /dev/null +++ b/mobile/src/store/auth-store.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; + +const isAuthenticatedMock = vi.fn(); +const getMeMock = vi.fn(); +const loginMock = vi.fn(); +const clearTokensMock = vi.fn(); + +vi.mock('../api/auth', () => ({ + getAuthClient: () => ({ + isAuthenticated: isAuthenticatedMock, + getMe: getMeMock, + login: loginMock, + clearTokens: clearTokensMock, + getAccessToken: vi.fn(() => null), + }), +})); + +import { useAuthStore } from './auth-store'; + +function resetStore() { + useAuthStore.setState({ + isAuthenticated: false, + isLoading: false, + email: null, + }); +} + +describe('useAuthStore', () => { + beforeEach(() => { + vi.clearAllMocks(); + resetStore(); + }); + + it('bootstrap sets authenticated when token exists', async () => { + isAuthenticatedMock.mockReturnValue(true); + getMeMock.mockResolvedValueOnce({ email: 'test@example.com' }); + await useAuthStore.getState().bootstrap(); + expect(useAuthStore.getState().isAuthenticated).toBe(true); + expect(useAuthStore.getState().email).toBe('test@example.com'); + }); + + it('bootstrap sets unauthenticated when no token', async () => { + isAuthenticatedMock.mockReturnValue(false); + await useAuthStore.getState().bootstrap(); + expect(useAuthStore.getState().isAuthenticated).toBe(false); + expect(useAuthStore.getState().email).toBeNull(); + }); + + it('bootstrap clears tokens on getMe failure', async () => { + isAuthenticatedMock.mockReturnValue(true); + getMeMock.mockRejectedValueOnce(new Error('expired')); + await useAuthStore.getState().bootstrap(); + expect(clearTokensMock).toHaveBeenCalled(); + expect(useAuthStore.getState().isAuthenticated).toBe(false); + }); + + it('signIn sets authenticated on success', async () => { + loginMock.mockResolvedValueOnce(undefined); + const ok = await useAuthStore.getState().signIn('test@example.com', 'pass'); + expect(ok).toBe(true); + expect(useAuthStore.getState().isAuthenticated).toBe(true); + expect(useAuthStore.getState().email).toBe('test@example.com'); + }); + + it('signIn returns false on failure', async () => { + loginMock.mockRejectedValueOnce(new Error('bad credentials')); + const ok = await useAuthStore.getState().signIn('bad@example.com', 'wrong'); + expect(ok).toBe(false); + expect(useAuthStore.getState().isAuthenticated).toBe(false); + expect(clearTokensMock).toHaveBeenCalled(); + }); + + it('signOut clears state', () => { + useAuthStore.setState({ isAuthenticated: true, email: 'test@example.com' }); + useAuthStore.getState().signOut(); + expect(useAuthStore.getState().isAuthenticated).toBe(false); + expect(useAuthStore.getState().email).toBeNull(); + expect(clearTokensMock).toHaveBeenCalled(); + }); +}); diff --git a/mobile/src/store/inbox-store.test.ts b/mobile/src/store/inbox-store.test.ts new file mode 100644 index 0000000..7df2741 --- /dev/null +++ b/mobile/src/store/inbox-store.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; + +const listApprovalQueueMock = vi.fn(); +const listActivityFeedMock = vi.fn(); +const updateApprovalStateMock = vi.fn(); + +vi.mock('../api/note-agent-actions', () => ({ + listApprovalQueue: (...args: unknown[]) => listApprovalQueueMock(...args), + listActivityFeed: (...args: unknown[]) => listActivityFeedMock(...args), + updateApprovalState: (...args: unknown[]) => updateApprovalStateMock(...args), +})); + +import { useInboxStore } from './inbox-store'; + +function resetStore() { + useInboxStore.setState({ + approvals: [], + activity: [], + isLoading: false, + }); +} + +const fakeApproval = { + id: 'a1', + workspaceId: 'ws-1', + noteId: 'n1', + title: 'Summary', + summary: 'Agent proposed a summarize change.', + status: 'pending' as const, +}; + +const fakeActivity = { + id: 'act1', + title: 'Update', + summary: 'State: approved', + kind: 'agent' as const, +}; + +describe('useInboxStore', () => { + beforeEach(() => { + vi.clearAllMocks(); + resetStore(); + }); + + it('hydrate loads approvals and activity', async () => { + listApprovalQueueMock.mockResolvedValueOnce([fakeApproval]); + listActivityFeedMock.mockResolvedValueOnce([fakeActivity]); + await useInboxStore.getState().hydrate(); + expect(useInboxStore.getState().approvals).toHaveLength(1); + expect(useInboxStore.getState().activity).toHaveLength(1); + expect(useInboxStore.getState().isLoading).toBe(false); + }); + + it('approve calls updateApprovalState and updates list', async () => { + const approved = { ...fakeApproval, status: 'approved' as const }; + useInboxStore.setState({ approvals: [fakeApproval] }); + updateApprovalStateMock.mockResolvedValueOnce(approved); + await useInboxStore.getState().approve('a1', 'LGTM'); + expect(updateApprovalStateMock).toHaveBeenCalledWith('a1', 'ws-1', 'approved', 'LGTM'); + expect(useInboxStore.getState().approvals[0].status).toBe('approved'); + }); + + it('reject calls updateApprovalState and updates list', async () => { + const rejected = { ...fakeApproval, status: 'rejected' as const }; + useInboxStore.setState({ approvals: [fakeApproval] }); + updateApprovalStateMock.mockResolvedValueOnce(rejected); + await useInboxStore.getState().reject('a1', 'Not needed'); + expect(updateApprovalStateMock).toHaveBeenCalledWith('a1', 'ws-1', 'rejected', 'Not needed'); + expect(useInboxStore.getState().approvals[0].status).toBe('rejected'); + }); + + it('approve does nothing for unknown id', async () => { + await useInboxStore.getState().approve('missing'); + expect(updateApprovalStateMock).not.toHaveBeenCalled(); + }); + + it('reject does nothing for unknown id', async () => { + await useInboxStore.getState().reject('missing'); + expect(updateApprovalStateMock).not.toHaveBeenCalled(); + }); +}); diff --git a/mobile/src/store/notes-store.test.ts b/mobile/src/store/notes-store.test.ts new file mode 100644 index 0000000..f78684f --- /dev/null +++ b/mobile/src/store/notes-store.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; + +const listNotesMock = vi.fn(); +const getNoteMock = vi.fn(); +const createNoteMock = vi.fn(); +const updateNoteMock = vi.fn(); + +vi.mock('../api/notes', () => ({ + listNotes: (...args: unknown[]) => listNotesMock(...args), + getNote: (...args: unknown[]) => getNoteMock(...args), + createNote: (...args: unknown[]) => createNoteMock(...args), + updateNote: (...args: unknown[]) => updateNoteMock(...args), +})); + +import { useNotesStore } from './notes-store'; + +function resetStore() { + useNotesStore.setState({ + notes: [], + selectedNote: null, + isLoading: false, + }); +} + +const fakeNote = { + id: 'n1', + workspaceId: 'ws-1', + title: 'Test', + body: 'Body', + workspaceName: 'Work', + status: 'draft' as const, + updatedAt: '2025-01-01T00:00:00Z', +}; + +describe('useNotesStore', () => { + beforeEach(() => { + vi.clearAllMocks(); + resetStore(); + }); + + it('hydrate loads notes', async () => { + listNotesMock.mockResolvedValueOnce([fakeNote]); + await useNotesStore.getState().hydrate(); + expect(useNotesStore.getState().notes).toHaveLength(1); + expect(useNotesStore.getState().notes[0].id).toBe('n1'); + expect(useNotesStore.getState().isLoading).toBe(false); + }); + + it('openNote sets selectedNote', async () => { + useNotesStore.setState({ notes: [fakeNote] }); + getNoteMock.mockResolvedValueOnce(fakeNote); + await useNotesStore.getState().openNote('n1'); + expect(useNotesStore.getState().selectedNote?.id).toBe('n1'); + }); + + it('openNote clears selectedNote for unknown id', async () => { + useNotesStore.setState({ notes: [fakeNote] }); + await useNotesStore.getState().openNote('missing'); + expect(useNotesStore.getState().selectedNote).toBeNull(); + }); + + it('saveDraft creates note and prepends to list', async () => { + const created = { ...fakeNote, id: 'n2', title: 'New' }; + createNoteMock.mockResolvedValueOnce(created); + const ok = await useNotesStore.getState().saveDraft('ws-1', 'New', 'Body'); + expect(ok).toBe(true); + expect(useNotesStore.getState().notes[0].id).toBe('n2'); + expect(useNotesStore.getState().selectedNote?.id).toBe('n2'); + }); + + it('saveDraft returns false without workspaceId', async () => { + const ok = await useNotesStore.getState().saveDraft(null, 'Title', 'Body'); + expect(ok).toBe(false); + expect(createNoteMock).not.toHaveBeenCalled(); + }); + + it('updateNote persists and updates state', async () => { + useNotesStore.setState({ notes: [fakeNote] }); + const updated = { ...fakeNote, title: 'Updated' }; + updateNoteMock.mockResolvedValueOnce(updated); + await useNotesStore.getState().updateNote('n1', 'Updated', 'Body'); + expect(useNotesStore.getState().notes[0].title).toBe('Updated'); + }); + + it('updateNote does nothing for unknown id', async () => { + await useNotesStore.getState().updateNote('missing', 'Title', 'Body'); + expect(updateNoteMock).not.toHaveBeenCalled(); + }); +}); diff --git a/mobile/src/store/workspace-store.test.ts b/mobile/src/store/workspace-store.test.ts new file mode 100644 index 0000000..8a689b1 --- /dev/null +++ b/mobile/src/store/workspace-store.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; + +const listWorkspacesMock = vi.fn(); + +vi.mock('../api/workspaces', () => ({ + listWorkspaces: (...args: unknown[]) => listWorkspacesMock(...args), +})); + +import { useWorkspaceStore } from './workspace-store'; + +function resetStore() { + useWorkspaceStore.setState({ + workspaces: [], + activeWorkspaceId: null, + }); +} + +const ws1 = { id: 'ws-1', name: 'Product' }; +const ws2 = { id: 'ws-2', name: 'Design' }; + +describe('useWorkspaceStore', () => { + beforeEach(() => { + vi.clearAllMocks(); + resetStore(); + }); + + it('hydrate loads workspaces', async () => { + listWorkspacesMock.mockResolvedValueOnce([ws1, ws2]); + await useWorkspaceStore.getState().hydrate(); + expect(useWorkspaceStore.getState().workspaces).toHaveLength(2); + }); + + it('hydrate preserves valid activeWorkspaceId', async () => { + useWorkspaceStore.setState({ activeWorkspaceId: 'ws-1' }); + listWorkspacesMock.mockResolvedValueOnce([ws1, ws2]); + await useWorkspaceStore.getState().hydrate(); + expect(useWorkspaceStore.getState().activeWorkspaceId).toBe('ws-1'); + }); + + it('hydrate resets activeWorkspaceId when workspace removed', async () => { + useWorkspaceStore.setState({ activeWorkspaceId: 'ws-gone' }); + listWorkspacesMock.mockResolvedValueOnce([ws1]); + await useWorkspaceStore.getState().hydrate(); + expect(useWorkspaceStore.getState().activeWorkspaceId).toBe('ws-1'); + }); + + it('setActiveWorkspace updates the id', () => { + useWorkspaceStore.getState().setActiveWorkspace('ws-2'); + expect(useWorkspaceStore.getState().activeWorkspaceId).toBe('ws-2'); + }); + + it('setActiveWorkspace can clear to null', () => { + useWorkspaceStore.setState({ activeWorkspaceId: 'ws-1' }); + useWorkspaceStore.getState().setActiveWorkspace(null); + expect(useWorkspaceStore.getState().activeWorkspaceId).toBeNull(); + }); +}); diff --git a/web/e2e/navigation.spec.ts b/web/e2e/navigation.spec.ts new file mode 100644 index 0000000..b99d4c7 --- /dev/null +++ b/web/e2e/navigation.spec.ts @@ -0,0 +1,47 @@ +import { test, expect } from "@playwright/test"; + +test.describe("Navigation", () => { + test("landing page redirects to dashboard", async ({ page }) => { + await page.goto("/"); + await expect(page).toHaveURL(/dashboard/); + }); + + test("dashboard renders summary cards", async ({ page }) => { + await page.goto("/dashboard"); + await expect(page.getByText("Dashboard")).toBeVisible(); + await expect(page.getByText("Active workspaces")).toBeVisible(); + await expect(page.getByText("Tracked notes")).toBeVisible(); + }); + + test("sidebar links navigate correctly", async ({ page }) => { + await page.goto("/dashboard"); + await page.getByRole("link", { name: "Workspaces" }).click(); + await expect(page).toHaveURL(/workspaces/); + await page.getByRole("link", { name: "Search" }).click(); + await expect(page).toHaveURL(/search/); + await page.getByRole("link", { name: "Reviews" }).click(); + await expect(page).toHaveURL(/reviews/); + await page.getByRole("link", { name: "Settings" }).click(); + await expect(page).toHaveURL(/settings/); + }); + + test("workspaces page renders", async ({ page }) => { + await page.goto("/workspaces"); + await expect(page.getByText("Workspaces")).toBeVisible(); + }); + + test("search page renders", async ({ page }) => { + await page.goto("/search"); + await expect(page.getByText("Search")).toBeVisible(); + }); + + test("reviews page renders", async ({ page }) => { + await page.goto("/reviews"); + await expect(page.getByText("Reviews")).toBeVisible(); + }); + + test("settings page renders", async ({ page }) => { + await page.goto("/settings"); + await expect(page.getByText("Settings")).toBeVisible(); + }); +}); diff --git a/web/playwright.config.ts b/web/playwright.config.ts new file mode 100644 index 0000000..572af87 --- /dev/null +++ b/web/playwright.config.ts @@ -0,0 +1,20 @@ +import { defineConfig } from "@playwright/test"; + +export default defineConfig({ + testDir: "./e2e", + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: "html", + use: { + baseURL: "http://localhost:3000", + trace: "on-first-retry", + }, + webServer: { + command: "npm run dev", + url: "http://localhost:3000", + reuseExistingServer: !process.env.CI, + timeout: 30_000, + }, +}); diff --git a/web/tsconfig.json b/web/tsconfig.json index 19c51c8..9b1b7c9 100644 --- a/web/tsconfig.json +++ b/web/tsconfig.json @@ -29,5 +29,5 @@ ".next/types/**/*.ts", ".next/dev/types/**/*.ts" ], - "exclude": ["node_modules"] + "exclude": ["node_modules", "e2e", "playwright.config.ts"] }