test: add 48 unit tests across types, ui, and web packages

packages/types: 27 tests
- Zod schema validation for user, node, workspace, tag, link
- Default values, required fields, enum constraints, edge cases

packages/ui: 8 tests
- Button rendering, variants, sizes, asChild, ref forwarding, disabled
- Dialog trigger rendering

apps/web: 13 tests
- Notes store CRUD operations (add, update, remove, select, search)
- Tasks store operations (add, toggle complete, priority, remove, view switching, projects)

apps/api: 4 placeholders for DB-backed integration tests

Infrastructure:
- vitest configs for packages/types and packages/ui
- jsdom test environment for UI components
- localStorage mock for Zustand persist middleware in jsdom
- crypto.randomUUID polyfill for test environment
- Fixed create schemas to make non-essential fields optional
This commit is contained in:
YetAnotherSuite Dev 2026-07-20 22:41:13 +02:00
parent 93489d5c2b
commit fedda0894d
18 changed files with 562 additions and 17 deletions

View File

@ -0,0 +1,8 @@
import { describe, it } from 'vitest';
describe('auth routes', () => {
it.todo('registers a new user and returns token');
it.todo('health check returns ok');
it.todo('rejects login with invalid credentials');
it.todo('requires authentication for /me');
});

View File

@ -0,0 +1,7 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
},
});

View File

@ -0,0 +1,59 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { useNotesStore } from '../stores/notes';
function createNote(overrides: Record<string, unknown> = {}) {
return {
id: crypto.randomUUID(),
title: 'Test Note',
type: 'note' as const,
content: { type: 'doc', content: [] },
plainText: '',
status: 'active' as const,
workspaceId: 'ws-1',
tags: [],
folderId: null,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
...overrides,
};
}
describe('useNotesStore', () => {
beforeEach(() => {
useNotesStore.setState({ notes: [], folders: [], selectedNoteId: null, searchQuery: '', loading: false });
});
it('starts with empty notes', () => {
expect(useNotesStore.getState().notes).toHaveLength(0);
});
it('adds a note', () => {
const note = createNote();
useNotesStore.getState().addNote(note);
expect(useNotesStore.getState().notes).toHaveLength(1);
});
it('updates a note', () => {
const note = createNote();
useNotesStore.getState().addNote(note);
useNotesStore.getState().updateNote(note.id, { title: 'Updated' });
expect(useNotesStore.getState().notes[0]?.title).toBe('Updated');
});
it('removes a note', () => {
const note = createNote();
useNotesStore.getState().addNote(note);
useNotesStore.getState().removeNote(note.id);
expect(useNotesStore.getState().notes).toHaveLength(0);
});
it('sets selected note', () => {
useNotesStore.getState().setSelectedNoteId('note-1');
expect(useNotesStore.getState().selectedNoteId).toBe('note-1');
});
it('sets search query', () => {
useNotesStore.getState().setSearchQuery('hello');
expect(useNotesStore.getState().searchQuery).toBe('hello');
});
});

View File

@ -0,0 +1,76 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { useTasksStore, type Task } from '../stores/tasks';
function createTask(overrides: Partial<Task> = {}): Task {
return {
id: crypto.randomUUID(),
title: 'Test Task',
description: '',
priority: null,
status: 'active',
dueDate: null,
durationMinutes: null,
tags: [],
subtasks: [],
projectId: null,
parentId: null,
workspaceId: 'ws-1',
startTime: null,
endTime: null,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
completedAt: null,
...overrides,
};
}
describe('useTasksStore', () => {
beforeEach(() => {
useTasksStore.setState({ tasks: [], projects: [], view: 'inbox', selectedTaskId: null });
});
it('adds a task', () => {
useTasksStore.getState().addTask(createTask());
expect(useTasksStore.getState().tasks).toHaveLength(1);
});
it('toggles task completion', () => {
const task = createTask();
useTasksStore.getState().addTask(task);
useTasksStore.getState().toggleComplete(task.id);
expect(useTasksStore.getState().tasks[0]?.status).toBe('completed');
expect(useTasksStore.getState().tasks[0]?.completedAt).toBeTruthy();
});
it('un-completes a completed task', () => {
const task = createTask({ status: 'completed', completedAt: new Date().toISOString() });
useTasksStore.getState().addTask(task);
useTasksStore.getState().toggleComplete(task.id);
expect(useTasksStore.getState().tasks[0]?.status).toBe('active');
expect(useTasksStore.getState().tasks[0]?.completedAt).toBeNull();
});
it('updates task priority', () => {
const task = createTask();
useTasksStore.getState().addTask(task);
useTasksStore.getState().updateTask(task.id, { priority: 1 });
expect(useTasksStore.getState().tasks[0]?.priority).toBe(1);
});
it('removes a task', () => {
const task = createTask();
useTasksStore.getState().addTask(task);
useTasksStore.getState().removeTask(task.id);
expect(useTasksStore.getState().tasks).toHaveLength(0);
});
it('switches views', () => {
useTasksStore.getState().setView('today');
expect(useTasksStore.getState().view).toBe('today');
});
it('adds a project', () => {
useTasksStore.getState().addProject({ id: 'p1', name: 'Project 1', color: '#ff0000' });
expect(useTasksStore.getState().projects).toHaveLength(1);
});
});

View File

@ -1 +1,22 @@
import '@testing-library/jest-dom';
const localStorageMock = (() => {
let store: Record<string, string> = {};
return {
getItem: (key: string) => store[key] ?? null,
setItem: (key: string, value: string) => { store[key] = value; },
removeItem: (key: string) => { delete store[key]; },
clear: () => { store = {}; },
get length() { return Object.keys(store).length; },
key: (index: number) => Object.keys(store)[index] ?? null,
};
})();
Object.defineProperty(window, 'localStorage', { value: localStorageMock });
if (!globalThis.crypto?.randomUUID) {
Object.defineProperty(globalThis, 'crypto', {
value: { randomUUID: () => 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => { const r = Math.random() * 16 | 0; return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16); }) },
writable: true,
});
}

View File

@ -7,6 +7,8 @@
"scripts": {
"lint": "eslint src/",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest",
"clean": "rm -rf .turbo node_modules"
},
"dependencies": {
@ -16,6 +18,7 @@
"@yetanother/tsconfig": "workspace:*",
"@yetanother/eslint-config": "workspace:*",
"typescript": "^5.8.0",
"eslint": "^9.25.0"
"eslint": "^9.25.0",
"vitest": "^3.1.0"
}
}

View File

@ -0,0 +1,128 @@
import { describe, it, expect } from 'vitest';
import { nodeSchema, createNodeSchema, nodeLinkSchema, tagSchema } from '../node';
const UUID = '550e8400-e29b-41d4-a716-446655440000';
const TS = '2024-01-01T00:00:00.000Z';
describe('nodeSchema', () => {
const validNode = {
id: UUID,
type: 'note' as const,
workspaceId: UUID,
ownerId: UUID,
parentId: null,
title: 'Test Note',
content: { type: 'doc' },
plainText: '',
status: 'active' as const,
priority: null,
startTime: null,
endTime: null,
durationMinutes: null,
recurrenceRule: null,
completionRate: null,
metadata: {},
version: 1,
isEncrypted: false,
createdAt: TS,
updatedAt: TS,
completedAt: null,
archivedAt: null,
};
it('validates a complete node', () => {
expect(nodeSchema.safeParse(validNode).success).toBe(true);
});
it('rejects invalid node type', () => {
expect(nodeSchema.safeParse({ ...validNode, type: 'invalid' }).success).toBe(false);
});
it('rejects empty title', () => {
expect(nodeSchema.safeParse({ ...validNode, title: '' }).success).toBe(false);
});
it('accepts all valid node types', () => {
for (const type of ['task', 'event', 'note', 'project', 'goal'] as const) {
expect(nodeSchema.safeParse({ ...validNode, type }).success).toBe(true);
}
});
it('rejects priority out of range', () => {
expect(nodeSchema.safeParse({ ...validNode, priority: 6 }).success).toBe(false);
});
});
describe('createNodeSchema', () => {
it('requires only type, workspaceId, and title', () => {
const result = createNodeSchema.safeParse({
type: 'task',
workspaceId: UUID,
title: 'My Task',
});
expect(result.success).toBe(true);
});
it('rejects missing type', () => {
expect(createNodeSchema.safeParse({ workspaceId: UUID, title: 'x' }).success).toBe(false);
});
it('accepts optional fields', () => {
const result = createNodeSchema.safeParse({
type: 'event',
workspaceId: UUID,
title: 'Meeting',
priority: 1,
startTime: TS,
durationMinutes: 60,
});
expect(result.success).toBe(true);
});
});
describe('nodeLinkSchema', () => {
const baseLink = {
id: UUID,
sourceId: UUID,
targetId: UUID,
createdAt: TS,
};
it('validates a node link', () => {
const result = nodeLinkSchema.safeParse({
...baseLink,
linkType: 'references',
strength: 0.8,
isAuto: false,
context: null,
});
expect(result.success).toBe(true);
});
it('accepts all link types', () => {
for (const linkType of ['references', 'blocks', 'relates_to', 'parent_of', 'child_of', 'scheduled_as', 'prepared_for'] as const) {
expect(nodeLinkSchema.safeParse({ ...baseLink, linkType, context: null }).success).toBe(true);
}
});
});
describe('tagSchema', () => {
it('validates a tag', () => {
const result = tagSchema.safeParse({
id: UUID,
name: 'urgent',
color: '#ff0000',
workspaceId: UUID,
isSystem: false,
createdAt: TS,
});
expect(result.success).toBe(true);
});
it('rejects invalid hex color', () => {
expect(tagSchema.safeParse({
id: UUID, name: 'urgent', color: 'red',
workspaceId: UUID, isSystem: false, createdAt: TS,
}).success).toBe(false);
});
});

View File

@ -0,0 +1,71 @@
import { describe, it, expect } from 'vitest';
import { userSchema, createUserSchema, updateUserSchema } from '../user';
const UUID = '550e8400-e29b-41d4-a716-446655440000';
const TS = '2024-01-01T00:00:00.000Z';
describe('userSchema', () => {
const validUser = {
id: UUID,
email: 'test@example.com',
displayName: 'Test User',
avatarUrl: null,
preferences: {
theme: 'system' as const,
timezone: 'UTC',
notificationSettings: { push: true, email: true, inApp: true },
defaultViews: { tasks: 'list' as const, calendar: 'week' as const, notes: 'list' as const },
},
createdAt: TS,
updatedAt: TS,
lastActiveAt: TS,
};
it('validates a complete user object', () => {
expect(userSchema.safeParse(validUser).success).toBe(true);
});
it('rejects invalid email', () => {
expect(userSchema.safeParse({ ...validUser, email: 'not-an-email' }).success).toBe(false);
});
it('rejects missing required fields', () => {
expect(userSchema.safeParse({}).success).toBe(false);
});
it('applies default preferences', () => {
const result = userSchema.safeParse({
id: UUID,
email: 'test@example.com',
displayName: 'Test',
avatarUrl: null,
createdAt: TS,
updatedAt: TS,
lastActiveAt: TS,
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.preferences.theme).toBe('system');
}
});
});
describe('createUserSchema', () => {
it('accepts email and displayName', () => {
expect(createUserSchema.safeParse({ email: 'a@b.com', displayName: 'Alice' }).success).toBe(true);
});
it('rejects missing displayName', () => {
expect(createUserSchema.safeParse({ email: 'a@b.com' }).success).toBe(false);
});
});
describe('updateUserSchema', () => {
it('accepts partial update', () => {
expect(updateUserSchema.safeParse({ displayName: 'New Name' }).success).toBe(true);
});
it('accepts empty object (no-op)', () => {
expect(updateUserSchema.safeParse({}).success).toBe(true);
});
});

View File

@ -0,0 +1,69 @@
import { describe, it, expect } from 'vitest';
import { workspaceSchema, createWorkspaceSchema, workspaceMemberSchema, workspaceRoleSchema } from '../workspace';
const UUID = '550e8400-e29b-41d4-a716-446655440000';
const TS = '2024-01-01T00:00:00.000Z';
describe('workspaceSchema', () => {
const valid = {
id: UUID,
name: 'Personal',
slug: 'personal',
description: null,
settings: {},
ownerId: UUID,
createdAt: TS,
updatedAt: TS,
};
it('validates a workspace', () => {
expect(workspaceSchema.safeParse(valid).success).toBe(true);
});
it('applies default settings', () => {
const result = workspaceSchema.safeParse(valid);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.settings.defaultView).toBe('list');
expect(result.data.settings.aiEnabled).toBe(true);
}
});
});
describe('workspaceRoleSchema', () => {
it('accepts all roles', () => {
for (const role of ['owner', 'admin', 'editor', 'viewer'] as const) {
expect(workspaceRoleSchema.safeParse(role).success).toBe(true);
}
});
it('rejects invalid role', () => {
expect(workspaceRoleSchema.safeParse('superadmin').success).toBe(false);
});
});
describe('workspaceMemberSchema', () => {
it('validates a member', () => {
const result = workspaceMemberSchema.safeParse({
id: UUID,
workspaceId: UUID,
userId: UUID,
role: 'editor',
permissions: {},
joinedAt: TS,
lastAccessedAt: TS,
});
expect(result.success).toBe(true);
});
});
describe('createWorkspaceSchema', () => {
it('requires name and slug', () => {
expect(createWorkspaceSchema.safeParse({ name: 'Work', slug: 'work' }).success).toBe(true);
expect(createWorkspaceSchema.safeParse({ name: 'Work' }).success).toBe(false);
});
it('accepts optional description', () => {
expect(createWorkspaceSchema.safeParse({ name: 'Work', slug: 'work', description: 'My workspace' }).success).toBe(true);
});
});

View File

@ -43,18 +43,18 @@ export const nodeSchema = z.object({
archivedAt: timestampSchema.nullable(),
});
export const createNodeSchema = nodeSchema.pick({
type: true,
workspaceId: true,
title: true,
content: true,
priority: true,
startTime: true,
endTime: true,
durationMinutes: true,
recurrenceRule: true,
parentId: true,
metadata: true,
export const createNodeSchema = z.object({
type: nodeTypeSchema,
workspaceId: uuidSchema,
title: z.string().min(1).max(500),
content: z.record(z.unknown()).optional(),
priority: z.number().int().min(1).max(5).optional(),
startTime: z.string().datetime().optional(),
endTime: z.string().datetime().optional(),
durationMinutes: z.number().int().min(0).optional(),
recurrenceRule: z.string().optional(),
parentId: uuidSchema.optional(),
metadata: z.record(z.unknown()).optional(),
});
export const updateNodeSchema = nodeSchema.partial().pick({
@ -81,7 +81,7 @@ export const nodeLinkSchema = z.object({
linkType: linkTypeSchema,
strength: z.number().min(0).max(1).default(0.5),
isAuto: z.boolean().default(false),
context: z.string().nullable(),
context: z.string().nullable().optional(),
createdAt: timestampSchema,
});

View File

@ -17,7 +17,11 @@ export const workspaceSchema = z.object({
updatedAt: timestampSchema,
});
export const createWorkspaceSchema = workspaceSchema.pick({ name: true, slug: true, description: true });
export const createWorkspaceSchema = z.object({
name: z.string().min(1).max(100),
slug: z.string().min(1).max(50),
description: z.string().max(500).optional(),
});
export const updateWorkspaceSchema = workspaceSchema.partial().pick({ name: true, description: true, settings: true });
export type Workspace = z.infer<typeof workspaceSchema>;

View File

@ -7,6 +7,8 @@
"scripts": {
"lint": "eslint src/",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest",
"clean": "rm -rf .turbo node_modules"
},
"dependencies": {
@ -31,6 +33,10 @@
"@types/react": "^19.1.0",
"typescript": "^5.8.0",
"eslint": "^9.25.0",
"tailwindcss": "^4.1.0"
"tailwindcss": "^4.1.0",
"vitest": "^3.1.0",
"@testing-library/react": "^16.3.0",
"@testing-library/jest-dom": "^6.6.0",
"jsdom": "^26.0.0"
}
}

View File

@ -0,0 +1,50 @@
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { Button } from '../button';
describe('Button', () => {
it('renders with text', () => {
render(<Button>Click me</Button>);
expect(screen.getByText('Click me')).toBeInTheDocument();
});
it('applies default variant classes', () => {
render(<Button>Default</Button>);
const button = screen.getByText('Default');
expect(button.className).toContain('bg-primary');
});
it('applies variant classes', () => {
render(<Button variant="destructive">Delete</Button>);
const button = screen.getByText('Delete');
expect(button.className).toContain('bg-destructive');
});
it('applies size classes', () => {
render(<Button size="lg">Large</Button>);
const button = screen.getByText('Large');
expect(button.className).toContain('h-11');
});
it('renders as child when asChild is true', () => {
render(
<Button asChild>
<a href="/test">Link</a>
</Button>,
);
const link = screen.getByText('Link');
expect(link.tagName).toBe('A');
});
it('forwards ref', () => {
const ref = { current: null };
render(<Button ref={ref}>Ref</Button>);
expect(ref.current).toBeInstanceOf(HTMLButtonElement);
});
it('can be disabled', () => {
render(<Button disabled>Disabled</Button>);
const button = screen.getByText('Disabled');
expect(button).toBeDisabled();
});
});

View File

@ -0,0 +1,19 @@
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { Dialog, DialogTrigger, DialogContent, DialogTitle, DialogHeader } from '../dialog';
describe('Dialog', () => {
it('renders trigger button', () => {
render(
<Dialog>
<DialogTrigger>Open</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Title</DialogTitle>
</DialogHeader>
</DialogContent>
</Dialog>,
);
expect(screen.getByText('Open')).toBeInTheDocument();
});
});

View File

@ -0,0 +1 @@
import '@testing-library/jest-dom';

View File

@ -0,0 +1,9 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'jsdom',
globals: true,
setupFiles: './src/__tests__/setup.ts',
},
});

View File

@ -380,6 +380,9 @@ importers:
typescript:
specifier: ^5.8.0
version: 5.9.3
vitest:
specifier: ^3.1.0
version: 3.2.7(@types/node@22.20.1)(jiti@2.7.0)(jsdom@26.1.0(supports-color@7.2.0))(lightningcss@1.32.0)(supports-color@7.2.0)(tsx@4.23.1)(yaml@2.9.0)
packages/ui:
dependencies:
@ -426,6 +429,12 @@ importers:
specifier: ^3.2.0
version: 3.6.0
devDependencies:
'@testing-library/jest-dom':
specifier: ^6.6.0
version: 6.9.1
'@testing-library/react':
specifier: ^16.3.0
version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@types/react':
specifier: ^19.1.0
version: 19.2.17
@ -438,12 +447,18 @@ importers:
eslint:
specifier: ^9.25.0
version: 9.39.5(jiti@2.7.0)(supports-color@7.2.0)
jsdom:
specifier: ^26.0.0
version: 26.1.0(supports-color@7.2.0)
tailwindcss:
specifier: ^4.1.0
version: 4.3.3
typescript:
specifier: ^5.8.0
version: 5.9.3
vitest:
specifier: ^3.1.0
version: 3.2.7(@types/node@22.20.1)(jiti@2.7.0)(jsdom@26.1.0(supports-color@7.2.0))(lightningcss@1.32.0)(supports-color@7.2.0)(tsx@4.23.1)(yaml@2.9.0)
packages/utils:
dependencies:

View File

@ -17,7 +17,6 @@
"dependsOn": ["^build"]
},
"test": {
"dependsOn": ["^build"],
"outputs": ["coverage/**"]
},
"clean": {