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:
parent
a71747e3fb
commit
dd62d3bf5c
80
mobile/src/store/auth-store.test.ts
Normal file
80
mobile/src/store/auth-store.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
81
mobile/src/store/inbox-store.test.ts
Normal file
81
mobile/src/store/inbox-store.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
89
mobile/src/store/notes-store.test.ts
Normal file
89
mobile/src/store/notes-store.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
57
mobile/src/store/workspace-store.test.ts
Normal file
57
mobile/src/store/workspace-store.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
47
web/e2e/navigation.spec.ts
Normal file
47
web/e2e/navigation.spec.ts
Normal 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
20
web/playwright.config.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
@ -29,5 +29,5 @@
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts"
|
||||
],
|
||||
"exclude": ["node_modules"]
|
||||
"exclude": ["node_modules", "e2e", "playwright.config.ts"]
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user