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
This commit is contained in:
saravanakumardb1 2026-03-19 08:51:36 -07:00
parent a71747e3fb
commit dd62d3bf5c
7 changed files with 375 additions and 1 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

20
web/playwright.config.ts Normal file
View File

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

View File

@ -29,5 +29,5 @@
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": ["node_modules"]
"exclude": ["node_modules", "e2e", "playwright.config.ts"]
}