test: add Vitest setup with 115 tests (backend + frontend)
This commit is contained in:
@@ -0,0 +1,269 @@
|
||||
/**
|
||||
* Auth API Tests – Register / Login / Logout / Me / Password Change
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { SqliteAdapter } from '../src/database/SqliteAdapter.js';
|
||||
import { createServer } from '../src/server.js';
|
||||
|
||||
describe('Auth API', () => {
|
||||
let app: FastifyInstance;
|
||||
let db: SqliteAdapter;
|
||||
let authToken: string | null = null;
|
||||
|
||||
beforeAll(async () => {
|
||||
db = new SqliteAdapter(':memory:');
|
||||
await db.init();
|
||||
app = await createServer({ db, port: 0 });
|
||||
await app.ready();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
db.close();
|
||||
});
|
||||
|
||||
// ─── Register ────────────────────────────────────────
|
||||
|
||||
describe('POST /api/auth/register', () => {
|
||||
it('should register a new user with 201', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/auth/register',
|
||||
payload: {
|
||||
email: 'testuser@example.com',
|
||||
password: 'TestPass123!',
|
||||
name: 'Test User',
|
||||
role: 'planer',
|
||||
},
|
||||
});
|
||||
expect(response.statusCode).toBe(201);
|
||||
const body = JSON.parse(response.body);
|
||||
expect(body.user).toBeDefined();
|
||||
expect(body.user.email).toBe('testuser@example.com');
|
||||
expect(body.user.name).toBe('Test User');
|
||||
expect(body.user.password_hash).toBeUndefined();
|
||||
expect(body.session).toBeDefined();
|
||||
expect(body.session.token).toBeDefined();
|
||||
authToken = body.session.token;
|
||||
});
|
||||
|
||||
it('should reject duplicate registration with 409', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/auth/register',
|
||||
payload: {
|
||||
email: 'testuser@example.com',
|
||||
password: 'TestPass123!',
|
||||
name: 'Test User 2',
|
||||
},
|
||||
});
|
||||
expect(response.statusCode).toBe(409);
|
||||
const body = JSON.parse(response.body);
|
||||
expect(body.error).toContain('already registered');
|
||||
});
|
||||
|
||||
it('should reject missing fields with 400', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/auth/register',
|
||||
payload: { email: 'incomplete@example.com' },
|
||||
});
|
||||
expect(response.statusCode).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Login ──────────────────────────────────────────
|
||||
|
||||
describe('POST /api/auth/login', () => {
|
||||
it('should login with correct credentials', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/auth/login',
|
||||
payload: {
|
||||
email: 'testuser@example.com',
|
||||
password: 'TestPass123!',
|
||||
},
|
||||
});
|
||||
expect(response.statusCode).toBe(200);
|
||||
const body = JSON.parse(response.body);
|
||||
expect(body.user).toBeDefined();
|
||||
expect(body.session.token).toBeDefined();
|
||||
authToken = body.session.token;
|
||||
});
|
||||
|
||||
it('should reject wrong password with 401', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/auth/login',
|
||||
payload: {
|
||||
email: 'testuser@example.com',
|
||||
password: 'WrongPassword!',
|
||||
},
|
||||
});
|
||||
expect(response.statusCode).toBe(401);
|
||||
});
|
||||
|
||||
it('should reject non-existent email with 401', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/auth/login',
|
||||
payload: {
|
||||
email: 'nobody@example.com',
|
||||
password: 'SomePassword!',
|
||||
},
|
||||
});
|
||||
expect(response.statusCode).toBe(401);
|
||||
});
|
||||
|
||||
it('should reject missing fields with 400', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/auth/login',
|
||||
payload: { email: 'testuser@example.com' },
|
||||
});
|
||||
expect(response.statusCode).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Me ─────────────────────────────────────────────
|
||||
|
||||
describe('GET /api/auth/me', () => {
|
||||
it('should return current user profile with valid token', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/auth/me',
|
||||
headers: { authorization: `Bearer ${authToken}` },
|
||||
});
|
||||
expect(response.statusCode).toBe(200);
|
||||
const body = JSON.parse(response.body);
|
||||
expect(body.email).toBe('testuser@example.com');
|
||||
expect(body.password_hash).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should reject without token with 401', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/auth/me',
|
||||
});
|
||||
expect(response.statusCode).toBe(401);
|
||||
});
|
||||
|
||||
it('should reject invalid token with 401', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/auth/me',
|
||||
headers: { authorization: 'Bearer invalid-token-xxx' },
|
||||
});
|
||||
expect(response.statusCode).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Password Change ───────────────────────────────
|
||||
|
||||
describe('PATCH /api/auth/password', () => {
|
||||
it('should change password with correct old password', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'PATCH',
|
||||
url: '/api/auth/password',
|
||||
headers: { authorization: `Bearer ${authToken}` },
|
||||
payload: {
|
||||
oldPassword: 'TestPass123!',
|
||||
newPassword: 'NewPassword456!',
|
||||
},
|
||||
});
|
||||
expect(response.statusCode).toBe(204);
|
||||
});
|
||||
|
||||
it('should allow login with new password', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/auth/login',
|
||||
payload: {
|
||||
email: 'testuser@example.com',
|
||||
password: 'NewPassword456!',
|
||||
},
|
||||
});
|
||||
expect(response.statusCode).toBe(200);
|
||||
const body = JSON.parse(response.body);
|
||||
expect(body.session.token).toBeDefined();
|
||||
authToken = body.session.token;
|
||||
});
|
||||
|
||||
it('should reject old password after change', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/auth/login',
|
||||
payload: {
|
||||
email: 'testuser@example.com',
|
||||
password: 'TestPass123!',
|
||||
},
|
||||
});
|
||||
expect(response.statusCode).toBe(401);
|
||||
});
|
||||
|
||||
it('should reject password change with wrong old password', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'PATCH',
|
||||
url: '/api/auth/password',
|
||||
headers: { authorization: `Bearer ${authToken}` },
|
||||
payload: {
|
||||
oldPassword: 'WrongOldPassword!',
|
||||
newPassword: 'AnotherNew789!',
|
||||
},
|
||||
});
|
||||
expect(response.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it('should reject password change without auth', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'PATCH',
|
||||
url: '/api/auth/password',
|
||||
payload: {
|
||||
oldPassword: 'NewPassword456!',
|
||||
newPassword: 'AnotherNew789!',
|
||||
},
|
||||
});
|
||||
expect(response.statusCode).toBe(401);
|
||||
});
|
||||
|
||||
it('should reject missing password fields', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'PATCH',
|
||||
url: '/api/auth/password',
|
||||
headers: { authorization: `Bearer ${authToken}` },
|
||||
payload: { oldPassword: 'NewPassword456!' },
|
||||
});
|
||||
expect(response.statusCode).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Logout ─────────────────────────────────────────
|
||||
|
||||
describe('POST /api/auth/logout', () => {
|
||||
it('should logout and invalidate token', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/auth/logout',
|
||||
headers: { authorization: `Bearer ${authToken}` },
|
||||
});
|
||||
expect(response.statusCode).toBe(204);
|
||||
|
||||
// Token should now be invalid
|
||||
const meResponse = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/auth/me',
|
||||
headers: { authorization: `Bearer ${authToken}` },
|
||||
});
|
||||
expect(meResponse.statusCode).toBe(401);
|
||||
});
|
||||
|
||||
it('should return 204 even without token', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/auth/logout',
|
||||
});
|
||||
expect(response.statusCode).toBe(204);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Health Endpoint Tests
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { SqliteAdapter } from '../src/database/SqliteAdapter.js';
|
||||
import { createServer } from '../src/server.js';
|
||||
|
||||
describe('Health Endpoint', () => {
|
||||
let app: FastifyInstance;
|
||||
let db: SqliteAdapter;
|
||||
|
||||
beforeAll(async () => {
|
||||
db = new SqliteAdapter(':memory:');
|
||||
await db.init();
|
||||
app = await createServer({ db, port: 0 });
|
||||
await app.ready();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
db.close();
|
||||
});
|
||||
|
||||
it('GET /api/health should return ok status', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/health',
|
||||
});
|
||||
expect(response.statusCode).toBe(200);
|
||||
const body = JSON.parse(response.body);
|
||||
expect(body.status).toBe('ok');
|
||||
expect(body.timestamp).toBeDefined();
|
||||
});
|
||||
|
||||
it('GET /api/health should return valid ISO timestamp', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/health',
|
||||
});
|
||||
const body = JSON.parse(response.body);
|
||||
const date = new Date(body.timestamp);
|
||||
expect(date.toString()).not.toBe('Invalid Date');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* Projects API Tests – CRUD (List / Get / Create / Update / Delete)
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { SqliteAdapter } from '../src/database/SqliteAdapter.js';
|
||||
import { createServer } from '../src/server.js';
|
||||
|
||||
describe('Projects API', () => {
|
||||
let app: FastifyInstance;
|
||||
let db: SqliteAdapter;
|
||||
let createdProjectId: string | null = null;
|
||||
|
||||
beforeAll(async () => {
|
||||
db = new SqliteAdapter(':memory:');
|
||||
await db.init();
|
||||
app = await createServer({ db, port: 0 });
|
||||
await app.ready();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
db.close();
|
||||
});
|
||||
|
||||
// ─── Create ─────────────────────────────────────────
|
||||
|
||||
describe('POST /api/projects', () => {
|
||||
it('should create a project with 201', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/projects',
|
||||
payload: {
|
||||
name: 'Test Project',
|
||||
description: 'A test project',
|
||||
},
|
||||
});
|
||||
expect(response.statusCode).toBe(201);
|
||||
const body = JSON.parse(response.body);
|
||||
expect(body.id).toBeDefined();
|
||||
expect(body.name).toBe('Test Project');
|
||||
expect(body.description).toBe('A test project');
|
||||
createdProjectId = body.id;
|
||||
});
|
||||
|
||||
it('should reject project without name with 400', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/projects',
|
||||
payload: { description: 'No name' },
|
||||
});
|
||||
expect(response.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it('should create project with default values', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/projects',
|
||||
payload: { name: 'Minimal Project' },
|
||||
});
|
||||
expect(response.statusCode).toBe(201);
|
||||
const body = JSON.parse(response.body);
|
||||
expect(body.name).toBe('Minimal Project');
|
||||
expect(body.description).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── List ───────────────────────────────────────────
|
||||
|
||||
describe('GET /api/projects', () => {
|
||||
it('should list all projects', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/projects',
|
||||
});
|
||||
expect(response.statusCode).toBe(200);
|
||||
const body = JSON.parse(response.body);
|
||||
expect(Array.isArray(body)).toBe(true);
|
||||
expect(body.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Get ────────────────────────────────────────────
|
||||
|
||||
describe('GET /api/projects/:id', () => {
|
||||
it('should get a single project', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/projects/${createdProjectId}`,
|
||||
});
|
||||
expect(response.statusCode).toBe(200);
|
||||
const body = JSON.parse(response.body);
|
||||
expect(body.id).toBe(createdProjectId);
|
||||
expect(body.name).toBe('Test Project');
|
||||
});
|
||||
|
||||
it('should return 404 for non-existent project', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/projects/non-existent-id',
|
||||
});
|
||||
expect(response.statusCode).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Update ─────────────────────────────────────────
|
||||
|
||||
describe('PATCH /api/projects/:id', () => {
|
||||
it('should update project name', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'PATCH',
|
||||
url: `/api/projects/${createdProjectId}`,
|
||||
payload: { name: 'Updated Project Name' },
|
||||
});
|
||||
expect(response.statusCode).toBe(200);
|
||||
const body = JSON.parse(response.body);
|
||||
expect(body.name).toBe('Updated Project Name');
|
||||
expect(body.id).toBe(createdProjectId);
|
||||
});
|
||||
|
||||
it('should update project description', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'PATCH',
|
||||
url: `/api/projects/${createdProjectId}`,
|
||||
payload: { description: 'Updated description' },
|
||||
});
|
||||
expect(response.statusCode).toBe(200);
|
||||
const body = JSON.parse(response.body);
|
||||
expect(body.description).toBe('Updated description');
|
||||
});
|
||||
|
||||
it('should return 404 for non-existent project update', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'PATCH',
|
||||
url: '/api/projects/non-existent-id',
|
||||
payload: { name: 'New Name' },
|
||||
});
|
||||
expect(response.statusCode).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Delete ─────────────────────────────────────────
|
||||
|
||||
describe('DELETE /api/projects/:id', () => {
|
||||
it('should delete a project', async () => {
|
||||
// First create a project to delete
|
||||
const createRes = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/projects',
|
||||
payload: { name: 'To Be Deleted' },
|
||||
});
|
||||
const projectId = JSON.parse(createRes.body).id;
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'DELETE',
|
||||
url: `/api/projects/${projectId}`,
|
||||
});
|
||||
expect(response.statusCode).toBe(204);
|
||||
|
||||
// Verify it's gone
|
||||
const getRes = await app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/projects/${projectId}`,
|
||||
});
|
||||
expect(getRes.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it('should return 404 for non-existent project delete', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'DELETE',
|
||||
url: '/api/projects/non-existent-id',
|
||||
});
|
||||
expect(response.statusCode).toBe(404);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user