mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-20 22:01:45 +00:00
Merge remote-tracking branch 'origin/dev' into naver-list-import
This commit is contained in:
@@ -96,7 +96,7 @@ describe('Admin user management', () => {
|
||||
.get('/api/admin/users')
|
||||
.set('Cookie', authCookie(admin.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.users.length).toBeGreaterThanOrEqual(3);
|
||||
expect(res.body.users).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('ADMIN-002 — POST /admin/users creates a user', async () => {
|
||||
@@ -142,6 +142,10 @@ describe('Admin user management', () => {
|
||||
.set('Cookie', authCookie(admin.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
|
||||
// Verify the row is actually gone from the DB
|
||||
const deleted = testDb.prepare('SELECT id FROM users WHERE id = ?').get(user.id);
|
||||
expect(deleted).toBeUndefined();
|
||||
});
|
||||
|
||||
it('ADMIN-006 — admin cannot delete their own account', async () => {
|
||||
@@ -187,19 +191,25 @@ describe('Permissions management', () => {
|
||||
expect(Array.isArray(res.body.permissions)).toBe(true);
|
||||
});
|
||||
|
||||
it('ADMIN-008 — PUT /admin/permissions updates permissions', async () => {
|
||||
it('ADMIN-008 — PUT /admin/permissions updates permissions and change persists', async () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
|
||||
const getRes = await request(app)
|
||||
.get('/api/admin/permissions')
|
||||
.set('Cookie', authCookie(admin.id));
|
||||
const currentPerms = getRes.body;
|
||||
|
||||
// Change trip_create from its default ('everybody') to 'admin'
|
||||
const res = await request(app)
|
||||
.put('/api/admin/permissions')
|
||||
.set('Cookie', authCookie(admin.id))
|
||||
.send({ permissions: currentPerms });
|
||||
.send({ permissions: { trip_create: 'admin' } });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
|
||||
// Re-fetch and verify the change persisted
|
||||
const getRes = await request(app)
|
||||
.get('/api/admin/permissions')
|
||||
.set('Cookie', authCookie(admin.id));
|
||||
expect(getRes.status).toBe(200);
|
||||
const tripCreatePerm = getRes.body.permissions.find((p: any) => p.key === 'trip_create');
|
||||
expect(tripCreatePerm).toBeDefined();
|
||||
expect(tripCreatePerm.level).toBe('admin');
|
||||
});
|
||||
|
||||
it('ADMIN-008 — PUT /admin/permissions without object returns 400', async () => {
|
||||
@@ -351,3 +361,171 @@ describe('JWT rotation', () => {
|
||||
expect(res.body.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Packing template CRUD (full)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Packing template CRUD (full)', () => {
|
||||
async function makeTemplate(admin: any) {
|
||||
const res = await request(app)
|
||||
.post('/api/admin/packing-templates')
|
||||
.set('Cookie', authCookie(admin.id))
|
||||
.send({ name: 'Test Template' });
|
||||
return res.body.template;
|
||||
}
|
||||
|
||||
it('ADMIN-019 — GET /admin/packing-templates/:id returns template', async () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
const template = await makeTemplate(admin);
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/admin/packing-templates/${template.id}`)
|
||||
.set('Cookie', authCookie(admin.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.template.id).toBe(template.id);
|
||||
expect(res.body.template.name).toBe('Test Template');
|
||||
});
|
||||
|
||||
it('ADMIN-019b — GET /admin/packing-templates/:id returns 404 for missing', async () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/admin/packing-templates/99999')
|
||||
.set('Cookie', authCookie(admin.id));
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('ADMIN-020 — PUT /admin/packing-templates/:id updates name', async () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
const template = await makeTemplate(admin);
|
||||
|
||||
const res = await request(app)
|
||||
.put(`/api/admin/packing-templates/${template.id}`)
|
||||
.set('Cookie', authCookie(admin.id))
|
||||
.send({ name: 'Updated Name' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.template.name).toBe('Updated Name');
|
||||
});
|
||||
|
||||
it('ADMIN-021 — POST /admin/packing-templates/:id/categories adds a category', async () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
const template = await makeTemplate(admin);
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/admin/packing-templates/${template.id}/categories`)
|
||||
.set('Cookie', authCookie(admin.id))
|
||||
.send({ name: 'Clothing' });
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.category.name).toBe('Clothing');
|
||||
});
|
||||
|
||||
it('ADMIN-021b — PUT /admin/packing-templates/:templateId/categories/:catId updates category', async () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
const template = await makeTemplate(admin);
|
||||
const catRes = await request(app)
|
||||
.post(`/api/admin/packing-templates/${template.id}/categories`)
|
||||
.set('Cookie', authCookie(admin.id))
|
||||
.send({ name: 'Clothing' });
|
||||
const catId = catRes.body.category.id;
|
||||
|
||||
const res = await request(app)
|
||||
.put(`/api/admin/packing-templates/${template.id}/categories/${catId}`)
|
||||
.set('Cookie', authCookie(admin.id))
|
||||
.send({ name: 'Apparel' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.category.name).toBe('Apparel');
|
||||
});
|
||||
|
||||
it('ADMIN-021c — DELETE /admin/packing-templates/:templateId/categories/:catId removes category', async () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
const template = await makeTemplate(admin);
|
||||
const catRes = await request(app)
|
||||
.post(`/api/admin/packing-templates/${template.id}/categories`)
|
||||
.set('Cookie', authCookie(admin.id))
|
||||
.send({ name: 'Toiletries' });
|
||||
const catId = catRes.body.category.id;
|
||||
|
||||
const res = await request(app)
|
||||
.delete(`/api/admin/packing-templates/${template.id}/categories/${catId}`)
|
||||
.set('Cookie', authCookie(admin.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
});
|
||||
|
||||
it('ADMIN-021d — POST .../categories/:catId/items adds an item to category', async () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
const template = await makeTemplate(admin);
|
||||
const catRes = await request(app)
|
||||
.post(`/api/admin/packing-templates/${template.id}/categories`)
|
||||
.set('Cookie', authCookie(admin.id))
|
||||
.send({ name: 'Clothing' });
|
||||
const catId = catRes.body.category.id;
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/admin/packing-templates/${template.id}/categories/${catId}/items`)
|
||||
.set('Cookie', authCookie(admin.id))
|
||||
.send({ name: 'T-Shirt' });
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.item.name).toBe('T-Shirt');
|
||||
});
|
||||
|
||||
it('ADMIN-021e — PUT /admin/packing-templates/:templateId/items/:itemId updates item', async () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
const template = await makeTemplate(admin);
|
||||
const catRes = await request(app)
|
||||
.post(`/api/admin/packing-templates/${template.id}/categories`)
|
||||
.set('Cookie', authCookie(admin.id))
|
||||
.send({ name: 'Clothing' });
|
||||
const catId = catRes.body.category.id;
|
||||
const itemRes = await request(app)
|
||||
.post(`/api/admin/packing-templates/${template.id}/categories/${catId}/items`)
|
||||
.set('Cookie', authCookie(admin.id))
|
||||
.send({ name: 'T-Shirt' });
|
||||
const itemId = itemRes.body.item.id;
|
||||
|
||||
const res = await request(app)
|
||||
.put(`/api/admin/packing-templates/${template.id}/items/${itemId}`)
|
||||
.set('Cookie', authCookie(admin.id))
|
||||
.send({ name: 'Polo Shirt' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.item.name).toBe('Polo Shirt');
|
||||
});
|
||||
|
||||
it('ADMIN-021f — DELETE /admin/packing-templates/:templateId/items/:itemId removes item', async () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
const template = await makeTemplate(admin);
|
||||
const catRes = await request(app)
|
||||
.post(`/api/admin/packing-templates/${template.id}/categories`)
|
||||
.set('Cookie', authCookie(admin.id))
|
||||
.send({ name: 'Clothing' });
|
||||
const catId = catRes.body.category.id;
|
||||
const itemRes = await request(app)
|
||||
.post(`/api/admin/packing-templates/${template.id}/categories/${catId}/items`)
|
||||
.set('Cookie', authCookie(admin.id))
|
||||
.send({ name: 'T-Shirt' });
|
||||
const itemId = itemRes.body.item.id;
|
||||
|
||||
const res = await request(app)
|
||||
.delete(`/api/admin/packing-templates/${template.id}/items/${itemId}`)
|
||||
.set('Cookie', authCookie(admin.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// MCP token management
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('MCP token management', () => {
|
||||
it('ADMIN-023 — GET /admin/mcp-tokens returns list', async () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/admin/mcp-tokens')
|
||||
.set('Cookie', authCookie(admin.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(Array.isArray(res.body.tokens)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -41,7 +41,7 @@ import { createApp } from '../../src/app';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
import { createUser, createTrip, createDay, createPlace, addTripMember } from '../helpers/factories';
|
||||
import { createUser, createTrip, createDay, createPlace, addTripMember, createTag } from '../helpers/factories';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
|
||||
@@ -261,6 +261,12 @@ describe('Reorder assignments', () => {
|
||||
.send({ orderedIds: [a2.body.assignment.id, a1.body.assignment.id] });
|
||||
expect(reorder.status).toBe(200);
|
||||
expect(reorder.body.success).toBe(true);
|
||||
|
||||
const rows = testDb
|
||||
.prepare('SELECT id, order_index FROM day_assignments WHERE day_id = ? ORDER BY order_index')
|
||||
.all(day.id) as Array<{ id: number; order_index: number }>;
|
||||
expect(rows[0].id).toBe(a2.body.assignment.id);
|
||||
expect(rows[1].id).toBe(a1.body.assignment.id);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -321,6 +327,41 @@ describe('Assignment participants', () => {
|
||||
expect(getParticipants.body.participants).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('ASSIGN-010 — GET /assignments includes tags and participants when present', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { user: member } = createUser(testDb);
|
||||
const { trip, day, place } = setupAssignmentFixtures(user.id);
|
||||
addTripMember(testDb, trip.id, member.id);
|
||||
|
||||
// Attach a tag to the place
|
||||
const tag = createTag(testDb, user.id, { name: 'Must See' });
|
||||
testDb.prepare('INSERT INTO place_tags (place_id, tag_id) VALUES (?, ?)').run(place.id, tag.id);
|
||||
|
||||
// Create the assignment via API
|
||||
const create = await request(app)
|
||||
.post(`/api/trips/${trip.id}/days/${day.id}/assignments`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ place_id: place.id });
|
||||
expect(create.status).toBe(201);
|
||||
const assignmentId = create.body.assignment.id;
|
||||
|
||||
// Add participants to the assignment
|
||||
await request(app)
|
||||
.put(`/api/trips/${trip.id}/assignments/${assignmentId}/participants`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ user_ids: [user.id, member.id] });
|
||||
|
||||
// List assignments — should include tags (compact) and participants
|
||||
const res = await request(app)
|
||||
.get(`/api/trips/${trip.id}/days/${day.id}/assignments`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
const found = (res.body.assignments as any[]).find((a: any) => a.id === assignmentId);
|
||||
expect(found).toBeDefined();
|
||||
expect(found.place.tags).toHaveLength(1);
|
||||
expect(found.participants).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('ASSIGN-009 — PUT /time updates assignment time fields', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { trip, day, place } = setupAssignmentFixtures(user.id);
|
||||
|
||||
@@ -383,3 +383,27 @@ describe('Mark/unmark region', () => {
|
||||
expect(deRegions).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Regions geo', () => {
|
||||
it('ATLAS-012 — GET /regions/geo without countries param returns empty FeatureCollection', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/addons/atlas/regions/geo')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ type: 'FeatureCollection', features: [] });
|
||||
});
|
||||
|
||||
it('ATLAS-013 — GET /regions/geo?countries=DE,FR returns FeatureCollection', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/addons/atlas/regions/geo?countries=DE,FR')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toHaveProperty('type', 'FeatureCollection');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Authentication integration tests.
|
||||
* Covers AUTH-001 to AUTH-022, AUTH-028 to AUTH-030.
|
||||
* Covers AUTH-001 to AUTH-022, AUTH-028 to AUTH-033.
|
||||
* OIDC scenarios (AUTH-023 to AUTH-027) require a real IdP and are excluded.
|
||||
* Rate limiting scenarios (AUTH-004, AUTH-018) are at the end of this file.
|
||||
*/
|
||||
@@ -448,6 +448,67 @@ describe('Short-lived tokens', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Extended scenarios (AUTH-031 to AUTH-033)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Extended auth scenarios', () => {
|
||||
it('AUTH-031 — login succeeds with uppercased email (case-insensitive lookup)', async () => {
|
||||
const { user, password } = createUser(testDb, { email: 'alice@example.com' });
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/auth/login')
|
||||
.send({ email: 'ALICE@EXAMPLE.COM', password });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.user).toBeDefined();
|
||||
});
|
||||
|
||||
it('AUTH-032 — registration with duplicate username returns 409', async () => {
|
||||
createUser(testDb, { username: 'alice' });
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/auth/register')
|
||||
.send({ username: 'alice', email: 'alice2@example.com', password: 'Str0ng!Pass' });
|
||||
expect(res.status).toBe(409);
|
||||
});
|
||||
|
||||
it('AUTH-033 — MFA backup code login succeeds and invalidates the used code', async () => {
|
||||
const { hashBackupCode, generateBackupCodes } = await import('../../src/services/authService');
|
||||
const { user, password } = createUserWithMfa(testDb);
|
||||
|
||||
// Generate and store backup codes on the MFA-enabled user
|
||||
const backupCodes = generateBackupCodes();
|
||||
const backupHashes = backupCodes.map(hashBackupCode);
|
||||
testDb.prepare('UPDATE users SET mfa_backup_codes = ? WHERE id = ?')
|
||||
.run(JSON.stringify(backupHashes), user.id);
|
||||
|
||||
// Step 1: login to get mfa_token
|
||||
const loginRes = await request(app)
|
||||
.post('/api/auth/login')
|
||||
.send({ email: user.email, password });
|
||||
expect(loginRes.body.mfa_required).toBe(true);
|
||||
const { mfa_token } = loginRes.body;
|
||||
|
||||
// Step 2: verify with a backup code
|
||||
const res = await request(app)
|
||||
.post('/api/auth/mfa/verify-login')
|
||||
.send({ mfa_token, code: backupCodes[0] });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.user).toBeDefined();
|
||||
|
||||
// Step 3: same backup code is now consumed — second login attempt fails
|
||||
const loginRes2 = await request(app)
|
||||
.post('/api/auth/login')
|
||||
.send({ email: user.email, password });
|
||||
const { mfa_token: mfa_token2 } = loginRes2.body;
|
||||
|
||||
const res2 = await request(app)
|
||||
.post('/api/auth/mfa/verify-login')
|
||||
.send({ mfa_token: mfa_token2, code: backupCodes[0] });
|
||||
expect(res2.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Rate limiting (AUTH-004, AUTH-018) — placed last
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -478,3 +539,72 @@ describe('Rate limiting', () => {
|
||||
expect(lastStatus).toBe(429);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// MCP token management (AUTH-034 to AUTH-039)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('MCP token management', () => {
|
||||
it('AUTH-034 — GET /auth/mcp-tokens returns empty list initially', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const res = await request(app)
|
||||
.get('/api/auth/mcp-tokens')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.tokens).toEqual([]);
|
||||
});
|
||||
|
||||
it('AUTH-035 — POST /auth/mcp-tokens creates a token', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const res = await request(app)
|
||||
.post('/api/auth/mcp-tokens')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ name: 'my-token' });
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.token).toBeDefined();
|
||||
expect(typeof res.body.token.raw_token).toBe('string');
|
||||
});
|
||||
|
||||
it('AUTH-036 — POST /auth/mcp-tokens without name returns 400', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const res = await request(app)
|
||||
.post('/api/auth/mcp-tokens')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('AUTH-037 — DELETE /auth/mcp-tokens/:id deletes the token', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const createRes = await request(app)
|
||||
.post('/api/auth/mcp-tokens')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ name: 'to-delete' });
|
||||
expect(createRes.status).toBe(201);
|
||||
const tokenId = createRes.body.token.id;
|
||||
|
||||
const delRes = await request(app)
|
||||
.delete(`/api/auth/mcp-tokens/${tokenId}`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(delRes.status).toBe(200);
|
||||
expect(delRes.body.success).toBe(true);
|
||||
|
||||
const listRes = await request(app)
|
||||
.get('/api/auth/mcp-tokens')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(listRes.body.tokens).toEqual([]);
|
||||
});
|
||||
|
||||
it('AUTH-038 — DELETE /auth/mcp-tokens/:id returns 404 for non-existent', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const res = await request(app)
|
||||
.delete('/api/auth/mcp-tokens/99999')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('AUTH-039 — unauthenticated GET /auth/mcp-tokens returns 401', async () => {
|
||||
const res = await request(app).get('/api/auth/mcp-tokens');
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -60,6 +60,12 @@ vi.mock('../../src/services/backupService', async () => {
|
||||
day_of_week: 0,
|
||||
day_of_month: 1,
|
||||
}),
|
||||
restoreFromZip: vi.fn().mockResolvedValue({ success: true }),
|
||||
deleteBackup: vi.fn().mockReturnValue(undefined),
|
||||
backupFileExists: vi.fn().mockReturnValue(false),
|
||||
backupFilePath: vi.fn().mockReturnValue('/tmp/test-backup.zip'),
|
||||
// Keep checkRateLimit from actual so rate-limit tests work correctly
|
||||
checkRateLimit: vi.fn().mockReturnValue(true),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -70,6 +76,10 @@ import { resetTestDb } from '../helpers/test-db';
|
||||
import { createAdmin, createUser } from '../helpers/factories';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
import * as backupService from '../../src/services/backupService';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
|
||||
const app: Application = createApp();
|
||||
|
||||
@@ -173,3 +183,257 @@ describe('Backup security', () => {
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Download
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Backup download', () => {
|
||||
let tmpFile: string;
|
||||
|
||||
beforeEach(() => {
|
||||
// Create a real temporary file that Express can stream back
|
||||
tmpFile = path.join(os.tmpdir(), `test-backup-${Date.now()}.zip`);
|
||||
fs.writeFileSync(tmpFile, 'fake zip content');
|
||||
vi.mocked(backupService.backupFileExists).mockReturnValue(true);
|
||||
vi.mocked(backupService.backupFilePath).mockReturnValue(tmpFile);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
try { fs.unlinkSync(tmpFile); } catch {}
|
||||
});
|
||||
|
||||
it('BACKUP-INT-001 — GET /backup/download/:filename returns 200 with content-disposition', async () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
const filename = 'backup-2026-04-06T12-00-00.zip';
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/backup/download/${filename}`)
|
||||
.set('Cookie', authCookie(admin.id));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers['content-disposition']).toMatch(/attachment/i);
|
||||
expect(res.headers['content-disposition']).toContain(filename);
|
||||
});
|
||||
|
||||
it('BACKUP-INT-002 — GET /backup/download/:filename returns 400 for invalid filename', async () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
vi.mocked(backupService.backupFileExists).mockReturnValue(false);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/backup/download/not-a-valid-name.tar.gz')
|
||||
.set('Cookie', authCookie(admin.id));
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/invalid filename/i);
|
||||
});
|
||||
|
||||
it('BACKUP-INT-003 — GET /backup/download/:filename returns 404 when file not found', async () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
vi.mocked(backupService.backupFileExists).mockReturnValue(false);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/backup/download/backup-2026-04-06T12-00-00.zip')
|
||||
.set('Cookie', authCookie(admin.id));
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body.error).toMatch(/not found/i);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Restore from existing backup
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Backup restore', () => {
|
||||
it('BACKUP-INT-004 — POST /backup/restore/:filename returns 200 on success', async () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
const filename = 'backup-2026-04-06T12-00-00.zip';
|
||||
|
||||
vi.mocked(backupService.backupFileExists).mockReturnValue(true);
|
||||
vi.mocked(backupService.restoreFromZip).mockResolvedValue({ success: true });
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/backup/restore/${filename}`)
|
||||
.set('Cookie', authCookie(admin.id));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
});
|
||||
|
||||
it('BACKUP-INT-005 — POST /backup/restore/:filename returns 404 when backup not found', async () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
|
||||
vi.mocked(backupService.backupFileExists).mockReturnValue(false);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/backup/restore/backup-2026-04-06T12-00-00.zip')
|
||||
.set('Cookie', authCookie(admin.id));
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body.error).toMatch(/not found/i);
|
||||
});
|
||||
|
||||
it('BACKUP-INT-006 — POST /backup/restore/:filename returns 400 for invalid filename', async () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/backup/restore/../../evil.zip')
|
||||
.set('Cookie', authCookie(admin.id));
|
||||
|
||||
// Express resolves path traversal → no route or invalid filename check
|
||||
expect([400, 404]).toContain(res.status);
|
||||
});
|
||||
|
||||
it('BACKUP-INT-007 — POST /backup/restore/:filename returns 400 when restoreFromZip reports failure', async () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
const filename = 'backup-2026-04-06T12-00-00.zip';
|
||||
|
||||
vi.mocked(backupService.backupFileExists).mockReturnValue(true);
|
||||
vi.mocked(backupService.restoreFromZip).mockResolvedValue({
|
||||
success: false,
|
||||
error: 'Invalid backup: travel.db not found',
|
||||
status: 400,
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/backup/restore/${filename}`)
|
||||
.set('Cookie', authCookie(admin.id));
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/travel\.db not found/i);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Delete backup
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Backup delete', () => {
|
||||
it('BACKUP-INT-008 — DELETE /backup/:filename returns 200 on success', async () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
const filename = 'backup-2026-04-06T12-00-00.zip';
|
||||
|
||||
vi.mocked(backupService.backupFileExists).mockReturnValue(true);
|
||||
vi.mocked(backupService.deleteBackup).mockReturnValue(undefined);
|
||||
|
||||
const res = await request(app)
|
||||
.delete(`/api/backup/${filename}`)
|
||||
.set('Cookie', authCookie(admin.id));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(vi.mocked(backupService.deleteBackup)).toHaveBeenCalledWith(filename);
|
||||
});
|
||||
|
||||
it('BACKUP-INT-009 — DELETE /backup/:filename returns 404 when not found', async () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
|
||||
vi.mocked(backupService.backupFileExists).mockReturnValue(false);
|
||||
|
||||
const res = await request(app)
|
||||
.delete('/api/backup/backup-2026-04-06T12-00-00.zip')
|
||||
.set('Cookie', authCookie(admin.id));
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body.error).toMatch(/not found/i);
|
||||
});
|
||||
|
||||
it('BACKUP-INT-010 — DELETE /backup/:filename returns 400 for invalid filename', async () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.delete('/api/backup/not-a-backup.tar.gz')
|
||||
.set('Cookie', authCookie(admin.id));
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/invalid filename/i);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Rate limiter on POST /create
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Backup rate limiter', () => {
|
||||
it('BACKUP-INT-011 — POST /backup/create returns 429 after 3 requests', async () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
|
||||
// Allow first 3 calls, then block
|
||||
let callCount = 0;
|
||||
vi.mocked(backupService.checkRateLimit).mockImplementation(() => {
|
||||
callCount++;
|
||||
return callCount <= 3;
|
||||
});
|
||||
|
||||
// First 3 succeed
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const res = await request(app)
|
||||
.post('/api/backup/create')
|
||||
.set('Cookie', authCookie(admin.id));
|
||||
expect(res.status).toBe(200);
|
||||
}
|
||||
|
||||
// 4th is rate-limited
|
||||
const res = await request(app)
|
||||
.post('/api/backup/create')
|
||||
.set('Cookie', authCookie(admin.id));
|
||||
expect(res.status).toBe(429);
|
||||
expect(res.body.error).toMatch(/too many/i);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Upload-restore
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Backup upload-restore', () => {
|
||||
it('BACKUP-INT-012 — POST /backup/upload-restore with zip file returns 200', async () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
|
||||
vi.mocked(backupService.restoreFromZip).mockResolvedValue({ success: true });
|
||||
|
||||
// Create a minimal fake zip buffer (just needs to pass multer's file filter)
|
||||
const fakeZipBuffer = Buffer.from('PK\x03\x04'); // ZIP magic bytes
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/backup/upload-restore')
|
||||
.set('Cookie', authCookie(admin.id))
|
||||
.attach('backup', fakeZipBuffer, { filename: 'test-restore.zip', contentType: 'application/zip' });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(vi.mocked(backupService.restoreFromZip)).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('BACKUP-INT-013 — POST /backup/upload-restore with no file returns 400', async () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/backup/upload-restore')
|
||||
.set('Cookie', authCookie(admin.id));
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/no file/i);
|
||||
});
|
||||
|
||||
it('BACKUP-INT-014 — POST /backup/upload-restore returns 400 when restore fails', async () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
|
||||
vi.mocked(backupService.restoreFromZip).mockResolvedValue({
|
||||
success: false,
|
||||
error: 'Uploaded file is not a valid SQLite database',
|
||||
status: 400,
|
||||
});
|
||||
|
||||
const fakeZipBuffer = Buffer.from('PK\x03\x04');
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/backup/upload-restore')
|
||||
.set('Cookie', authCookie(admin.id))
|
||||
.attach('backup', fakeZipBuffer, { filename: 'bad-restore.zip', contentType: 'application/zip' });
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/not a valid SQLite/i);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -209,6 +209,35 @@ describe('Budget item members', () => {
|
||||
.send({ user_ids: [user.id, member.id] });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.members).toBeDefined();
|
||||
|
||||
// After assigning members, list items should include them (covers loadBudgetItems member loop)
|
||||
const listRes = await request(app)
|
||||
.get(`/api/trips/${trip.id}/budget`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(listRes.status).toBe(200);
|
||||
const foundItem = (listRes.body.items as any[]).find((i: any) => i.id === item.id);
|
||||
expect(foundItem).toBeDefined();
|
||||
expect(foundItem.members).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('BUDGET-005b — PUT /members with empty user_ids clears members', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const item = createBudgetItem(testDb, trip.id);
|
||||
|
||||
// First assign a member
|
||||
await request(app)
|
||||
.put(`/api/trips/${trip.id}/budget/${item.id}/members`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ user_ids: [user.id] });
|
||||
|
||||
// Then clear members with empty array
|
||||
const res = await request(app)
|
||||
.put(`/api/trips/${trip.id}/budget/${item.id}/members`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ user_ids: [] });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.members).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('BUDGET-005 — PUT /members with non-array user_ids returns 400', async () => {
|
||||
@@ -234,12 +263,22 @@ describe('Budget item members', () => {
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ user_ids: [user.id] });
|
||||
|
||||
// Toggle to paid=true
|
||||
const res = await request(app)
|
||||
.put(`/api/trips/${trip.id}/budget/${item.id}/members/${user.id}/paid`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ paid: true });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.member).toBeDefined();
|
||||
expect(res.body.member.paid).toBe(1); // SQLite stores as integer
|
||||
|
||||
// Toggle back to paid=false
|
||||
const res2 = await request(app)
|
||||
.put(`/api/trips/${trip.id}/budget/${item.id}/members/${user.id}/paid`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ paid: false });
|
||||
expect(res2.status).toBe(200);
|
||||
expect(res2.body.member.paid).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -251,36 +290,72 @@ describe('Budget summary and settlement', () => {
|
||||
it('BUDGET-007 — GET /summary/per-person returns per-person breakdown', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
createBudgetItem(testDb, trip.id, { name: 'Dinner', total_price: 60 });
|
||||
const item = createBudgetItem(testDb, trip.id, { name: 'Dinner', total_price: 60 });
|
||||
|
||||
await request(app)
|
||||
.put(`/api/trips/${trip.id}/budget/${item.id}/members`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ user_ids: [user.id] });
|
||||
await request(app)
|
||||
.put(`/api/trips/${trip.id}/budget/${item.id}/members/${user.id}/paid`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ paid: true });
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/trips/${trip.id}/budget/summary/per-person`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(Array.isArray(res.body.summary)).toBe(true);
|
||||
expect(res.body.summary).toHaveLength(1);
|
||||
const entry = res.body.summary[0];
|
||||
expect(entry.user_id).toBe(user.id);
|
||||
expect(typeof entry.total_paid).toBe('number');
|
||||
expect(entry.total_paid).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('BUDGET-008 — GET /settlement returns settlement transactions', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { user: user2 } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
addTripMember(testDb, trip.id, user2.id);
|
||||
const item = createBudgetItem(testDb, trip.id, { name: 'Dinner', total_price: 60 });
|
||||
|
||||
await request(app)
|
||||
.put(`/api/trips/${trip.id}/budget/${item.id}/members`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ user_ids: [user.id, user2.id] });
|
||||
await request(app)
|
||||
.put(`/api/trips/${trip.id}/budget/${item.id}/members/${user.id}/paid`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ paid: true });
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/trips/${trip.id}/budget/settlement`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toHaveProperty('balances');
|
||||
expect(res.body).toHaveProperty('flows');
|
||||
expect(Array.isArray(res.body.balances)).toBe(true);
|
||||
expect(Array.isArray(res.body.flows)).toBe(true);
|
||||
|
||||
const payerBalance = res.body.balances.find((b: any) => b.user_id === user.id);
|
||||
const nonPayerBalance = res.body.balances.find((b: any) => b.user_id === user2.id);
|
||||
expect(payerBalance.balance).toBeCloseTo(30);
|
||||
expect(nonPayerBalance.balance).toBeCloseTo(-30);
|
||||
|
||||
expect(res.body.flows).toHaveLength(1);
|
||||
expect(res.body.flows[0].from.user_id).toBe(user2.id);
|
||||
expect(res.body.flows[0].to.user_id).toBe(user.id);
|
||||
expect(res.body.flows[0].amount).toBeCloseTo(30);
|
||||
});
|
||||
|
||||
it('BUDGET-009 — settlement with no payers returns empty transactions', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
// Item with no members/payers assigned
|
||||
createBudgetItem(testDb, trip.id, { name: 'Train', total_price: 40 });
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/trips/${trip.id}/budget/settlement`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.balances).toEqual([]);
|
||||
expect(res.body.flows).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* Categories integration tests — CAT-001 through CAT-009.
|
||||
* Covers GET/POST/PUT/DELETE /api/categories.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import type { Application } from 'express';
|
||||
|
||||
const { testDb, dbMock } = vi.hoisted(() => {
|
||||
const Database = require('better-sqlite3');
|
||||
const db = new Database(':memory:');
|
||||
db.exec('PRAGMA journal_mode = WAL');
|
||||
db.exec('PRAGMA foreign_keys = ON');
|
||||
db.exec('PRAGMA busy_timeout = 5000');
|
||||
const mock = {
|
||||
db,
|
||||
closeDb: () => {},
|
||||
reinitialize: () => {},
|
||||
getPlaceWithTags: () => null,
|
||||
canAccessTrip: (tripId: any, userId: number) =>
|
||||
db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
|
||||
isOwner: (tripId: any, userId: number) =>
|
||||
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
|
||||
};
|
||||
return { testDb: db, dbMock: mock };
|
||||
});
|
||||
|
||||
vi.mock('../../src/db/database', () => dbMock);
|
||||
vi.mock('../../src/config', () => ({
|
||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||
updateJwtSecret: () => {},
|
||||
}));
|
||||
|
||||
import { createApp } from '../../src/app';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
import { createUser, createAdmin } from '../helpers/factories';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
|
||||
const app: Application = createApp();
|
||||
|
||||
beforeAll(() => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
loginAttempts.clear();
|
||||
mfaAttempts.clear();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
describe('Categories', () => {
|
||||
it('CAT-001: GET /api/categories returns seeded default categories', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const res = await request(app)
|
||||
.get('/api/categories')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(Array.isArray(res.body.categories)).toBe(true);
|
||||
// 10 default categories are seeded on reset
|
||||
expect(res.body.categories.length).toBeGreaterThanOrEqual(10);
|
||||
expect(res.body.categories[0]).toMatchObject({ name: expect.any(String), color: expect.any(String), icon: expect.any(String) });
|
||||
});
|
||||
|
||||
it('CAT-002: POST /api/categories - admin creates a new category', async () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
const res = await request(app)
|
||||
.post('/api/categories')
|
||||
.set('Cookie', authCookie(admin.id))
|
||||
.send({ name: 'Museum', color: '#7c3aed', icon: '🏛️' });
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.category).toMatchObject({ name: 'Museum', color: '#7c3aed', icon: '🏛️' });
|
||||
expect(res.body.category.id).toBeDefined();
|
||||
});
|
||||
|
||||
it('CAT-003: POST /api/categories - non-admin returns 403', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const res = await request(app)
|
||||
.post('/api/categories')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ name: 'Museum' });
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it('CAT-004: POST /api/categories - missing name returns 400', async () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
const res = await request(app)
|
||||
.post('/api/categories')
|
||||
.set('Cookie', authCookie(admin.id))
|
||||
.send({ color: '#7c3aed' });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toBeDefined();
|
||||
});
|
||||
|
||||
it('CAT-005: PUT /api/categories/:id - admin updates a category', async () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
// First create one
|
||||
const createRes = await request(app)
|
||||
.post('/api/categories')
|
||||
.set('Cookie', authCookie(admin.id))
|
||||
.send({ name: 'Old Name', color: '#aaaaaa', icon: '📌' });
|
||||
const catId = createRes.body.category.id;
|
||||
|
||||
const res = await request(app)
|
||||
.put(`/api/categories/${catId}`)
|
||||
.set('Cookie', authCookie(admin.id))
|
||||
.send({ name: 'New Name', color: '#bbbbbb' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.category.name).toBe('New Name');
|
||||
expect(res.body.category.color).toBe('#bbbbbb');
|
||||
// Icon unchanged
|
||||
expect(res.body.category.icon).toBe('📌');
|
||||
});
|
||||
|
||||
it('CAT-006: PUT /api/categories/:id - non-admin returns 403', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
// Get a seeded category id
|
||||
const cat = testDb.prepare('SELECT id FROM categories LIMIT 1').get() as { id: number };
|
||||
const res = await request(app)
|
||||
.put(`/api/categories/${cat.id}`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ name: 'Hacked' });
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it('CAT-007: PUT /api/categories/:id - non-existent category returns 404', async () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
const res = await request(app)
|
||||
.put('/api/categories/99999')
|
||||
.set('Cookie', authCookie(admin.id))
|
||||
.send({ name: 'Ghost' });
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('CAT-008: DELETE /api/categories/:id - admin deletes a category', async () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
const createRes = await request(app)
|
||||
.post('/api/categories')
|
||||
.set('Cookie', authCookie(admin.id))
|
||||
.send({ name: 'To Delete' });
|
||||
const catId = createRes.body.category.id;
|
||||
|
||||
const res = await request(app)
|
||||
.delete(`/api/categories/${catId}`)
|
||||
.set('Cookie', authCookie(admin.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
|
||||
// Verify it's gone
|
||||
const gone = testDb.prepare('SELECT id FROM categories WHERE id = ?').get(catId);
|
||||
expect(gone).toBeUndefined();
|
||||
});
|
||||
|
||||
it('CAT-009: DELETE /api/categories/:id - non-admin returns 403', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const cat = testDb.prepare('SELECT id FROM categories LIMIT 1').get() as { id: number };
|
||||
const res = await request(app)
|
||||
.delete(`/api/categories/${cat.id}`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it('CAT-010: GET /api/categories - unauthenticated returns 401', async () => {
|
||||
const res = await request(app).get('/api/categories');
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
@@ -42,6 +42,15 @@ vi.mock('../../src/config', () => ({
|
||||
updateJwtSecret: () => {},
|
||||
}));
|
||||
|
||||
// Partially mock collabService to make fetchLinkPreview controllable
|
||||
vi.mock('../../src/services/collabService', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../../src/services/collabService')>();
|
||||
return {
|
||||
...actual,
|
||||
fetchLinkPreview: vi.fn().mockResolvedValue({ title: null, description: null, image: null, url: '' }),
|
||||
};
|
||||
});
|
||||
|
||||
import { createApp } from '../../src/app';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
@@ -49,6 +58,7 @@ import { resetTestDb } from '../helpers/test-db';
|
||||
import { createUser, createTrip, addTripMember } from '../helpers/factories';
|
||||
import { authCookie, generateToken } from '../helpers/auth';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
import * as collabService from '../../src/services/collabService';
|
||||
|
||||
const app: Application = createApp();
|
||||
const FIXTURE_PDF = path.join(__dirname, '../fixtures/test.pdf');
|
||||
@@ -637,4 +647,140 @@ describe('Collab validation', () => {
|
||||
.send({ text: 'A'.repeat(5001) });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('COLLAB-008 — poll with fewer than 2 options returns 400', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/trips/${trip.id}/collab/polls`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ question: 'Only one option?', options: ['Option A'] });
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/2 options/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Link preview', () => {
|
||||
it('COLLAB-025 — GET /collab/link-preview without url returns 400', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/trips/${trip.id}/collab/link-preview`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/url/i);
|
||||
});
|
||||
|
||||
it('COLLAB-025 — GET /collab/link-preview returns preview for valid URL', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
vi.mocked(collabService.fetchLinkPreview).mockResolvedValueOnce({
|
||||
title: 'Example Domain',
|
||||
description: 'A test page',
|
||||
image: null,
|
||||
url: 'https://example.com',
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/trips/${trip.id}/collab/link-preview?url=https://example.com`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.title).toBe('Example Domain');
|
||||
});
|
||||
|
||||
it('COLLAB-026 — GET /collab/link-preview returns 400 when fetchLinkPreview returns error', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
vi.mocked(collabService.fetchLinkPreview).mockResolvedValueOnce({
|
||||
title: null,
|
||||
description: null,
|
||||
image: null,
|
||||
url: 'http://127.0.0.1',
|
||||
error: 'Requests to loopback and link-local addresses are not allowed',
|
||||
} as any);
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/trips/${trip.id}/collab/link-preview?url=http://127.0.0.1`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toBeDefined();
|
||||
});
|
||||
|
||||
it('COLLAB-027 — GET /collab/link-preview catches thrown errors and returns fallback', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
vi.mocked(collabService.fetchLinkPreview).mockRejectedValueOnce(new Error('Unexpected error'));
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/trips/${trip.id}/collab/link-preview?url=https://example.com`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.title).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Message reactions toggle', () => {
|
||||
it('COLLAB-028 — POST /collab/messages/:msgId/react adds a reaction', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
const msgRes = await request(app)
|
||||
.post(`/api/trips/${trip.id}/collab/messages`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ text: 'Hello!' });
|
||||
expect(msgRes.status).toBe(201);
|
||||
const messageId = msgRes.body.message.id;
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/trips/${trip.id}/collab/messages/${messageId}/react`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ emoji: '👍' });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.reactions).toBeDefined();
|
||||
const thumbsUp = res.body.reactions.find((r: any) => r.emoji === '👍');
|
||||
expect(thumbsUp).toBeDefined();
|
||||
expect(thumbsUp.users.some((u: any) => u.user_id === user.id || u === user.id)).toBe(true);
|
||||
});
|
||||
|
||||
it('COLLAB-029 — POST /collab/messages/:msgId/react on same emoji removes it (toggle)', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
const msgRes = await request(app)
|
||||
.post(`/api/trips/${trip.id}/collab/messages`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ text: 'Toggle me!' });
|
||||
expect(msgRes.status).toBe(201);
|
||||
const messageId = msgRes.body.message.id;
|
||||
|
||||
// First call — adds the reaction
|
||||
await request(app)
|
||||
.post(`/api/trips/${trip.id}/collab/messages/${messageId}/react`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ emoji: '👍' });
|
||||
|
||||
// Second call with same emoji — should toggle it off
|
||||
const res = await request(app)
|
||||
.post(`/api/trips/${trip.id}/collab/messages/${messageId}/react`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ emoji: '👍' });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.reactions).toBeDefined();
|
||||
const thumbsUp = res.body.reactions.find((r: any) => r.emoji === '👍');
|
||||
// After toggling off, either the entry is absent or the user is no longer in it
|
||||
const userStillReacted = thumbsUp && thumbsUp.users && thumbsUp.users.some((u: any) => u.user_id === user.id || u === user.id);
|
||||
expect(userStillReacted).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -434,6 +434,46 @@ describe('Accommodations', () => {
|
||||
expect(reservation.confirmation_number).toBe('CONF-XYZ');
|
||||
});
|
||||
|
||||
it('ACCOM-004 — PUT /api/trips/:tripId/accommodations/:id updates the accommodation', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, { title: 'Hotel Trip' });
|
||||
const day1 = createDay(testDb, trip.id, { date: '2026-10-20' });
|
||||
const day2 = createDay(testDb, trip.id, { date: '2026-10-22' });
|
||||
const day3 = createDay(testDb, trip.id, { date: '2026-10-25' });
|
||||
const place = createPlace(testDb, trip.id, { name: 'City Inn' });
|
||||
|
||||
const createRes = await request(app)
|
||||
.post(`/api/trips/${trip.id}/accommodations`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ place_id: place.id, start_day_id: day1.id, end_day_id: day2.id, notes: 'Original' });
|
||||
|
||||
expect(createRes.status).toBe(201);
|
||||
const accommodationId = createRes.body.accommodation.id;
|
||||
|
||||
const updateRes = await request(app)
|
||||
.put(`/api/trips/${trip.id}/accommodations/${accommodationId}`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ place_id: place.id, start_day_id: day1.id, end_day_id: day3.id, notes: 'Extended stay' });
|
||||
|
||||
expect(updateRes.status).toBe(200);
|
||||
expect(updateRes.body.accommodation).toBeDefined();
|
||||
expect(updateRes.body.accommodation.end_day_id).toBe(day3.id);
|
||||
expect(updateRes.body.accommodation.notes).toBe('Extended stay');
|
||||
});
|
||||
|
||||
it('ACCOM-004 — PUT non-existent accommodation returns 404', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, { title: 'Trip' });
|
||||
|
||||
const res = await request(app)
|
||||
.put(`/api/trips/${trip.id}/accommodations/999999`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ notes: 'Ghost update' });
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body.error).toMatch(/not found/i);
|
||||
});
|
||||
|
||||
it('ACCOM-003 — Deleting accommodation also removes the linked reservation', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, { title: 'Hotel Trip' });
|
||||
|
||||
@@ -77,6 +77,7 @@ beforeEach(() => {
|
||||
|
||||
afterAll(() => {
|
||||
testDb.close();
|
||||
fs.rmSync(uploadsDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// Helper to upload a file and return the file object
|
||||
|
||||
@@ -1,122 +0,0 @@
|
||||
/**
|
||||
* Basic smoke test to validate the integration test DB mock pattern.
|
||||
* Tests MISC-001 — Health check endpoint.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import type { Application } from 'express';
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Step 1: Create a bare in-memory DB instance via vi.hoisted() so it exists
|
||||
// before the mock factory below runs. Schema setup happens in beforeAll
|
||||
// (after mocks are registered, so config is mocked when migrations run).
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
const { testDb, dbMock } = vi.hoisted(() => {
|
||||
const Database = require('better-sqlite3');
|
||||
const db = new Database(':memory:');
|
||||
db.exec('PRAGMA journal_mode = WAL');
|
||||
db.exec('PRAGMA foreign_keys = ON');
|
||||
db.exec('PRAGMA busy_timeout = 5000');
|
||||
|
||||
const mock = {
|
||||
db,
|
||||
closeDb: () => {},
|
||||
reinitialize: () => {},
|
||||
getPlaceWithTags: (placeId: number) => {
|
||||
const place: any = db.prepare(`
|
||||
SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon
|
||||
FROM places p LEFT JOIN categories c ON p.category_id = c.id WHERE p.id = ?
|
||||
`).get(placeId);
|
||||
if (!place) return null;
|
||||
const tags = db.prepare(`SELECT t.* FROM tags t JOIN place_tags pt ON t.id = pt.tag_id WHERE pt.place_id = ?`).all(placeId);
|
||||
return { ...place, category: place.category_id ? { id: place.category_id, name: place.category_name, color: place.category_color, icon: place.category_icon } : null, tags };
|
||||
},
|
||||
canAccessTrip: (tripId: any, userId: number) =>
|
||||
db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
|
||||
isOwner: (tripId: any, userId: number) =>
|
||||
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
|
||||
};
|
||||
|
||||
return { testDb: db, dbMock: mock };
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Step 2: Register mocks BEFORE app is imported (these are hoisted by Vitest)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
vi.mock('../../src/db/database', () => dbMock);
|
||||
|
||||
vi.mock('../../src/config', () => ({
|
||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||
updateJwtSecret: () => {},
|
||||
}));
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Step 3: Import app AFTER mocks (Vitest hoisting ensures mocks are ready first)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
import { createApp } from '../../src/app';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
import { createUser } from '../helpers/factories';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
|
||||
const app: Application = createApp();
|
||||
|
||||
// Schema setup runs here — config is mocked so migrations work correctly
|
||||
beforeAll(() => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Tests
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Health check', () => {
|
||||
it('MISC-001 — GET /api/health returns 200 with status ok', async () => {
|
||||
const res = await request(app).get('/api/health');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.status).toBe('ok');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Basic auth', () => {
|
||||
it('AUTH-014 — GET /api/auth/me without session returns 401', async () => {
|
||||
const res = await request(app).get('/api/auth/me');
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.body.code).toBe('AUTH_REQUIRED');
|
||||
});
|
||||
|
||||
it('AUTH-001 — POST /api/auth/login with valid credentials returns 200 + cookie', async () => {
|
||||
const { user, password } = createUser(testDb);
|
||||
const res = await request(app)
|
||||
.post('/api/auth/login')
|
||||
.send({ email: user.email, password });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.user).toMatchObject({ id: user.id, email: user.email });
|
||||
expect(res.headers['set-cookie']).toBeDefined();
|
||||
const cookies: string[] = Array.isArray(res.headers['set-cookie'])
|
||||
? res.headers['set-cookie']
|
||||
: [res.headers['set-cookie']];
|
||||
expect(cookies.some((c: string) => c.includes('trek_session'))).toBe(true);
|
||||
});
|
||||
|
||||
it('AUTH-014 — authenticated GET /api/auth/me returns user object', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const res = await request(app)
|
||||
.get('/api/auth/me')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.user.id).toBe(user.id);
|
||||
expect(res.body.user.email).toBe(user.email);
|
||||
});
|
||||
});
|
||||
@@ -40,6 +40,18 @@ vi.mock('../../src/config', () => ({
|
||||
updateJwtSecret: () => {},
|
||||
}));
|
||||
|
||||
// Default mock: resolveGoogleMapsUrl rejects with 400 (SSRF-like behaviour for
|
||||
// URLs that look internal); individual tests override with mockResolvedValueOnce.
|
||||
vi.mock('../../src/services/mapsService', () => ({
|
||||
searchPlaces: vi.fn(),
|
||||
getPlaceDetails: vi.fn(),
|
||||
getPlacePhoto: vi.fn(),
|
||||
reverseGeocode: vi.fn(),
|
||||
resolveGoogleMapsUrl: vi.fn().mockRejectedValue(
|
||||
Object.assign(new Error('SSRF or invalid URL'), { status: 400 })
|
||||
),
|
||||
}));
|
||||
|
||||
import { createApp } from '../../src/app';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
@@ -47,6 +59,7 @@ import { resetTestDb } from '../helpers/test-db';
|
||||
import { createUser } from '../helpers/factories';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
import * as mapsService from '../../src/services/mapsService';
|
||||
|
||||
const app: Application = createApp();
|
||||
|
||||
@@ -133,3 +146,135 @@ describe('Maps SSRF protection', () => {
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Maps happy paths (mocked service)', () => {
|
||||
it('MAPS-002 — POST /maps/search returns results from service', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
vi.mocked(mapsService.searchPlaces).mockResolvedValueOnce({
|
||||
results: [{ address: 'Paris, France', source: 'nominatim' }],
|
||||
} as any);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/maps/search')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ query: 'Paris' });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.results).toHaveLength(1);
|
||||
expect(res.body.results[0].address).toBe('Paris, France');
|
||||
});
|
||||
|
||||
it('MAPS-003 — GET /maps/details/:placeId returns place details', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
vi.mocked(mapsService.getPlaceDetails).mockResolvedValueOnce({
|
||||
name: 'Eiffel Tower',
|
||||
address: 'Champ de Mars, Paris',
|
||||
} as any);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/maps/details/ChIJLU7jZClu5kcR4PcOOO6p3I0')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.name).toBe('Eiffel Tower');
|
||||
});
|
||||
|
||||
it('MAPS-004 — GET /maps/place-photo/:placeId returns photo url', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
vi.mocked(mapsService.getPlacePhoto).mockResolvedValueOnce({
|
||||
url: 'https://example.com/photo.jpg',
|
||||
source: 'wikimedia',
|
||||
} as any);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/maps/place-photo/ChIJLU7jZClu5kcR4PcOOO6p3I0?lat=48.8584&lng=2.2945')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.url).toBe('https://example.com/photo.jpg');
|
||||
});
|
||||
|
||||
it('MAPS-005 — GET /maps/reverse returns geocoded location', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
vi.mocked(mapsService.reverseGeocode).mockResolvedValueOnce({
|
||||
name: 'Eiffel Tower',
|
||||
address: 'Champ de Mars, Paris',
|
||||
} as any);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/maps/reverse?lat=48.8584&lng=2.2945')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.name).toBe('Eiffel Tower');
|
||||
});
|
||||
|
||||
it('MAPS-008 — POST /maps/resolve-url returns extracted coordinates', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
vi.mocked(mapsService.resolveGoogleMapsUrl).mockResolvedValueOnce({
|
||||
lat: 48.8584,
|
||||
lng: 2.2945,
|
||||
} as any);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/maps/resolve-url')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ url: 'https://maps.google.com/place/eiffel-tower' });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.lat).toBe(48.8584);
|
||||
expect(res.body.lng).toBe(2.2945);
|
||||
});
|
||||
|
||||
it('MAPS-002 — search service error propagates correct status', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const err = Object.assign(new Error('No API key'), { status: 503 });
|
||||
vi.mocked(mapsService.searchPlaces).mockRejectedValueOnce(err);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/maps/search')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ query: 'Anywhere' });
|
||||
|
||||
expect(res.status).toBe(503);
|
||||
});
|
||||
|
||||
it('MAPS-003 — getPlaceDetails error returns 500', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
vi.mocked(mapsService.getPlaceDetails).mockRejectedValueOnce(new Error('External API failure'));
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/maps/details/some-place-id')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(res.status).toBe(500);
|
||||
expect(res.body).toHaveProperty('error');
|
||||
});
|
||||
|
||||
it('MAPS-004 — getPlacePhoto error with status returns that status', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
vi.mocked(mapsService.getPlacePhoto).mockRejectedValueOnce(
|
||||
Object.assign(new Error('Photo not found'), { status: 404 })
|
||||
);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/maps/place-photo/some-place-id?lat=48.8&lng=2.3')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body).toHaveProperty('error');
|
||||
});
|
||||
|
||||
it('MAPS-005 — reverseGeocode error returns null values', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
vi.mocked(mapsService.reverseGeocode).mockRejectedValueOnce(new Error('Geocode failed'));
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/maps/reverse?lat=48.8584&lng=2.2945')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.name).toBeNull();
|
||||
expect(res.body.address).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -511,3 +511,214 @@ describe('Immich auth checks', () => {
|
||||
expect((await request(app).get(`${IMMICH}/assets/1/asset-x/1/original`)).status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Album sync ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Immich syncAlbumAssets', () => {
|
||||
it('IMMICH-080 — POST sync happy path: trip owner with album link saves photos to DB', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
setImmichCredentials(testDb, user.id, 'https://immich.example.com', 'test-api-key');
|
||||
const link = addAlbumLink(testDb, trip.id, user.id, 'immich', 'album-uuid-1', 'Vacation 2024');
|
||||
|
||||
const res = await request(app)
|
||||
.post(`${IMMICH}/trips/${trip.id}/album-links/${link.id}/sync`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(typeof res.body.total).toBe('number');
|
||||
expect(typeof res.body.added).toBe('number');
|
||||
|
||||
// Verify photos were inserted into the DB
|
||||
const photos = testDb.prepare('SELECT * FROM trip_photos WHERE trip_id = ? AND user_id = ?').all(trip.id, user.id) as any[];
|
||||
expect(photos.length).toBeGreaterThan(0);
|
||||
expect(photos[0].provider).toBe('immich');
|
||||
});
|
||||
|
||||
it('IMMICH-081 — POST sync when user is not a trip member returns 404', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: outsider } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
setImmichCredentials(testDb, owner.id, 'https://immich.example.com', 'test-api-key');
|
||||
const link = addAlbumLink(testDb, trip.id, owner.id, 'immich', 'album-uuid-1', 'Vacation 2024');
|
||||
|
||||
// outsider is not a trip member — getAlbumIdFromLink checks canAccessTrip
|
||||
const res = await request(app)
|
||||
.post(`${IMMICH}/trips/${trip.id}/album-links/${link.id}/sync`)
|
||||
.set('Cookie', authCookie(outsider.id));
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('IMMICH-082 — POST sync when Immich is not configured returns 400', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
// No Immich credentials set — but still need a valid album link owned by user
|
||||
const link = addAlbumLink(testDb, trip.id, user.id, 'immich', 'album-uuid-1', 'Vacation 2024');
|
||||
|
||||
const res = await request(app)
|
||||
.post(`${IMMICH}/trips/${trip.id}/album-links/${link.id}/sync`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toBeDefined();
|
||||
});
|
||||
|
||||
it('IMMICH-083 — POST sync when safeFetch throws returns 502', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
setImmichCredentials(testDb, user.id, 'https://immich.example.com', 'test-api-key');
|
||||
const link = addAlbumLink(testDb, trip.id, user.id, 'immich', 'album-uuid-1', 'Vacation 2024');
|
||||
|
||||
vi.mocked(safeFetch).mockRejectedValueOnce(new Error('network failure during sync'));
|
||||
|
||||
const res = await request(app)
|
||||
.post(`${IMMICH}/trips/${trip.id}/album-links/${link.id}/sync`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(res.status).toBe(502);
|
||||
expect(res.body.error).toBeDefined();
|
||||
});
|
||||
|
||||
it('IMMICH-084 — POST sync when album link does not belong to requesting user returns 404', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: member } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
addTripMember(testDb, trip.id, member.id);
|
||||
setImmichCredentials(testDb, member.id, 'https://immich.example.com', 'test-api-key');
|
||||
// Album link is owned by owner, not member
|
||||
const link = addAlbumLink(testDb, trip.id, owner.id, 'immich', 'album-uuid-1', 'Vacation 2024');
|
||||
|
||||
// member is a trip member but the album link belongs to owner — getAlbumIdFromLink checks user_id
|
||||
const res = await request(app)
|
||||
.post(`${IMMICH}/trips/${trip.id}/album-links/${link.id}/sync`)
|
||||
.set('Cookie', authCookie(member.id));
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('IMMICH-085 — POST sync without auth returns 401', async () => {
|
||||
expect((await request(app).post(`${IMMICH}/trips/1/album-links/1/sync`)).status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
// ── searchPhotos pagination safety ────────────────────────────────────────────
|
||||
|
||||
describe('Immich searchPhotos pagination safety', () => {
|
||||
it('IMMICH-090 — searchPhotos stops at page 20 when hasMore is always true', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
setImmichCredentials(testDb, user.id, 'https://immich.example.com', 'test-api-key');
|
||||
|
||||
// Return a full page of 1000 items on every call, so the loop would
|
||||
// run indefinitely without the page > 20 safety check.
|
||||
const fullPageResponse = {
|
||||
ok: true, status: 200,
|
||||
headers: { get: () => null },
|
||||
json: () => Promise.resolve({
|
||||
assets: {
|
||||
items: Array.from({ length: 1000 }, (_, i) => ({
|
||||
id: `asset-${i}`,
|
||||
fileCreatedAt: '2024-06-01T10:00:00.000Z',
|
||||
exifInfo: { city: 'Paris', country: 'France' },
|
||||
})),
|
||||
},
|
||||
}),
|
||||
body: null,
|
||||
} as any;
|
||||
|
||||
// Clear previous call history so the count only reflects this test
|
||||
vi.mocked(safeFetch).mockClear();
|
||||
vi.mocked(safeFetch).mockResolvedValue(fullPageResponse);
|
||||
|
||||
const res = await request(app)
|
||||
.post(`${IMMICH}/search`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(Array.isArray(res.body.assets)).toBe(true);
|
||||
// 20 pages × 1000 items = 20000 assets total (safety limit)
|
||||
expect(res.body.assets.length).toBe(20000);
|
||||
// safeFetch should have been called exactly 20 times (the safety limit)
|
||||
expect(vi.mocked(safeFetch)).toHaveBeenCalledTimes(20);
|
||||
});
|
||||
});
|
||||
|
||||
// ── saveImmichSettings clearing credentials ───────────────────────────────────
|
||||
|
||||
describe('Immich saveImmichSettings clearing URL', () => {
|
||||
it('IMMICH-095 — PUT /settings with no URL clears immich_url but preserves (updates) api key', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
setImmichCredentials(testDb, user.id, 'https://immich.example.com', 'old-key');
|
||||
|
||||
// Send without immich_url to trigger the else branch (clear URL path)
|
||||
const res = await request(app)
|
||||
.put(`${IMMICH}/settings`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ immich_api_key: 'new-key' });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
|
||||
const row = testDb.prepare('SELECT immich_url FROM users WHERE id = ?').get(user.id) as any;
|
||||
expect(row.immich_url).toBeNull();
|
||||
});
|
||||
|
||||
it('IMMICH-096 — PUT /settings with empty string URL clears immich_url', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
setImmichCredentials(testDb, user.id, 'https://immich.example.com', 'old-key');
|
||||
|
||||
const res = await request(app)
|
||||
.put(`${IMMICH}/settings`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ immich_url: '', immich_api_key: 'old-key' });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
|
||||
const row = testDb.prepare('SELECT immich_url FROM users WHERE id = ?').get(user.id) as any;
|
||||
expect(row.immich_url).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ── testConnection canonical URL detection ────────────────────────────────────
|
||||
|
||||
describe('Immich testConnection canonical URL detection', () => {
|
||||
it('IMMICH-100 — POST /test with http URL that gets upgraded to https returns canonicalUrl', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
// Mock safeFetch so the response.url reflects https upgrade
|
||||
vi.mocked(safeFetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
url: 'https://immich.example.com/api/users/me',
|
||||
headers: { get: (h: string) => h === 'content-type' ? 'application/json' : null } as any,
|
||||
json: async () => ({ name: 'Redirect User', email: 'redirect@immich.local' }),
|
||||
body: null,
|
||||
} as any);
|
||||
|
||||
const res = await request(app)
|
||||
.post(`${IMMICH}/test`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ immich_url: 'http://immich.example.com', immich_api_key: 'valid-key' });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.connected).toBe(true);
|
||||
expect(res.body.canonicalUrl).toBe('https://immich.example.com');
|
||||
});
|
||||
|
||||
it('IMMICH-101 — POST /test with https URL that stays https does not return canonicalUrl', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
// The default mock returns a response without .url property — no upgrade
|
||||
const res = await request(app)
|
||||
.post(`${IMMICH}/test`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ immich_url: 'https://immich.example.com', immich_api_key: 'valid-key' });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.connected).toBe(true);
|
||||
expect(res.body.canonicalUrl).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -543,3 +543,322 @@ describe('Synology auth checks', () => {
|
||||
expect((await request(app).get(`${SYNO}/assets/1/photo-x/1/thumbnail`)).status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Album sync ────────────────────────────────────────────────────────────────
|
||||
|
||||
import { addAlbumLink } from '../helpers/factories';
|
||||
|
||||
describe('Synology syncSynologyAlbumLink', () => {
|
||||
it('SYNO-050 — POST sync happy path: trip owner with album link saves photos to DB', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
setSynologyCredentials(testDb, user.id, 'https://synology.example.com', 'admin', 'pass');
|
||||
// The migration inserts synologyphotos with enabled=0; ensure it is enabled for this test.
|
||||
testDb.prepare("UPDATE photo_providers SET enabled = 1 WHERE id = 'synologyphotos'").run();
|
||||
// album_id must be a numeric string so getAlbumIdFromLink returns it and
|
||||
// syncSynologyAlbumLink passes Number(album_id) to the API.
|
||||
const link = addAlbumLink(testDb, trip.id, user.id, 'synologyphotos', '1', 'Summer Trip');
|
||||
|
||||
const res = await request(app)
|
||||
.post(`${SYNO}/trips/${trip.id}/album-links/${link.id}/sync`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(typeof res.body.added).toBe('number');
|
||||
expect(typeof res.body.total).toBe('number');
|
||||
|
||||
// Verify photos were inserted into the DB
|
||||
const photos = testDb.prepare('SELECT * FROM trip_photos WHERE trip_id = ? AND user_id = ?').all(trip.id, user.id) as any[];
|
||||
expect(photos.length).toBeGreaterThan(0);
|
||||
expect(photos[0].provider).toBe('synologyphotos');
|
||||
});
|
||||
|
||||
it('SYNO-051 — POST sync when user is not a trip member returns 404', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: outsider } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
setSynologyCredentials(testDb, owner.id, 'https://synology.example.com', 'admin', 'pass');
|
||||
const link = addAlbumLink(testDb, trip.id, owner.id, 'synologyphotos', '1', 'Summer Trip');
|
||||
|
||||
const res = await request(app)
|
||||
.post(`${SYNO}/trips/${trip.id}/album-links/${link.id}/sync`)
|
||||
.set('Cookie', authCookie(outsider.id));
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('SYNO-052 — POST sync when Synology is not configured returns 400', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
// No credentials — album link still exists for the user
|
||||
const link = addAlbumLink(testDb, trip.id, user.id, 'synologyphotos', '1', 'Summer Trip');
|
||||
|
||||
const res = await request(app)
|
||||
.post(`${SYNO}/trips/${trip.id}/album-links/${link.id}/sync`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toBeDefined();
|
||||
});
|
||||
|
||||
it('SYNO-053 — POST sync without auth returns 401', async () => {
|
||||
expect((await request(app).post(`${SYNO}/trips/1/album-links/1/sync`)).status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Session retry logic ───────────────────────────────────────────────────────
|
||||
|
||||
describe('Synology session retry on error codes 106/107/119', () => {
|
||||
it('SYNO-060 — request retries with fresh session when API returns error code 119', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
setSynologyCredentials(testDb, user.id, 'https://synology.example.com', 'admin', 'pass');
|
||||
|
||||
// Clear previous call history so the count only reflects this test's calls
|
||||
vi.mocked(safeFetch).mockClear();
|
||||
|
||||
// Call sequence:
|
||||
// 1. Auth login (fresh session — no cached SID) → success with sid
|
||||
// 2. SYNO.Foto.Browse.Album call → returns { success: false, error: { code: 119 } }
|
||||
// 3. Auth login again (retry session after clearing SID) → success with new sid
|
||||
// 4. SYNO.Foto.Browse.Album retry call → success
|
||||
vi.mocked(safeFetch)
|
||||
.mockResolvedValueOnce({
|
||||
// call 1: initial login
|
||||
ok: true, status: 200,
|
||||
headers: { get: () => 'application/json' },
|
||||
json: async () => ({ success: true, data: { sid: 'first-sid' } }),
|
||||
body: null,
|
||||
} as any)
|
||||
.mockResolvedValueOnce({
|
||||
// call 2: album list → session expired (119)
|
||||
ok: true, status: 200,
|
||||
headers: { get: () => 'application/json' },
|
||||
json: async () => ({ success: false, error: { code: 119 } }),
|
||||
body: null,
|
||||
} as any)
|
||||
.mockResolvedValueOnce({
|
||||
// call 3: retry login after clearing SID
|
||||
ok: true, status: 200,
|
||||
headers: { get: () => 'application/json' },
|
||||
json: async () => ({ success: true, data: { sid: 'second-sid' } }),
|
||||
body: null,
|
||||
} as any)
|
||||
.mockResolvedValueOnce({
|
||||
// call 4: retry album list → success
|
||||
ok: true, status: 200,
|
||||
headers: { get: () => 'application/json' },
|
||||
json: async () => ({
|
||||
success: true,
|
||||
data: {
|
||||
list: [{ id: 99, name: 'Retry Album', item_count: 5 }],
|
||||
},
|
||||
}),
|
||||
body: null,
|
||||
} as any);
|
||||
|
||||
const res = await request(app)
|
||||
.get(`${SYNO}/albums`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(Array.isArray(res.body.albums)).toBe(true);
|
||||
expect(res.body.albums[0]).toMatchObject({ albumName: 'Retry Album' });
|
||||
// Four safeFetch calls: login, failed album list, re-login, successful album list
|
||||
expect(vi.mocked(safeFetch)).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
|
||||
it('SYNO-061 — request retries with fresh session when API returns error code 106', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
setSynologyCredentials(testDb, user.id, 'https://synology.example.com', 'admin', 'pass');
|
||||
|
||||
vi.mocked(safeFetch).mockClear();
|
||||
vi.mocked(safeFetch)
|
||||
.mockResolvedValueOnce({
|
||||
ok: true, status: 200,
|
||||
headers: { get: () => 'application/json' },
|
||||
json: async () => ({ success: true, data: { sid: 'sid-one' } }),
|
||||
body: null,
|
||||
} as any)
|
||||
.mockResolvedValueOnce({
|
||||
ok: true, status: 200,
|
||||
headers: { get: () => 'application/json' },
|
||||
json: async () => ({ success: false, error: { code: 106 } }),
|
||||
body: null,
|
||||
} as any)
|
||||
.mockResolvedValueOnce({
|
||||
ok: true, status: 200,
|
||||
headers: { get: () => 'application/json' },
|
||||
json: async () => ({ success: true, data: { sid: 'sid-two' } }),
|
||||
body: null,
|
||||
} as any)
|
||||
.mockResolvedValueOnce({
|
||||
ok: true, status: 200,
|
||||
headers: { get: () => 'application/json' },
|
||||
json: async () => ({
|
||||
success: true,
|
||||
data: { list: [{ id: 3, name: 'Timeout Album', item_count: 2 }] },
|
||||
}),
|
||||
body: null,
|
||||
} as any);
|
||||
|
||||
const res = await request(app)
|
||||
.get(`${SYNO}/albums`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.albums[0]).toMatchObject({ albumName: 'Timeout Album' });
|
||||
expect(vi.mocked(safeFetch)).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Date range search ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('Synology searchSynologyPhotos date range', () => {
|
||||
it('SYNO-070 — POST /search with from/to passes start_time and end_time to Synology API', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
setSynologyCredentials(testDb, user.id, 'https://synology.example.com', 'admin', 'pass');
|
||||
|
||||
// Capture the body sent on the search call (second safeFetch call after auth)
|
||||
let capturedBody: URLSearchParams | null = null;
|
||||
vi.mocked(safeFetch)
|
||||
.mockResolvedValueOnce({
|
||||
// login
|
||||
ok: true, status: 200,
|
||||
headers: { get: () => 'application/json' },
|
||||
json: async () => ({ success: true, data: { sid: 'fake-sid' } }),
|
||||
body: null,
|
||||
} as any)
|
||||
.mockImplementationOnce((_url: string, init?: any) => {
|
||||
capturedBody = init?.body instanceof URLSearchParams
|
||||
? init.body
|
||||
: new URLSearchParams(String(init?.body ?? ''));
|
||||
return Promise.resolve({
|
||||
ok: true, status: 200,
|
||||
headers: { get: () => 'application/json' },
|
||||
json: async () => ({
|
||||
success: true,
|
||||
data: {
|
||||
list: [
|
||||
{
|
||||
id: 201,
|
||||
filename: 'dated.jpg',
|
||||
filesize: 512000,
|
||||
time: 1717228800,
|
||||
additional: {
|
||||
thumbnail: { cache_key: '201_abc' },
|
||||
address: { city: 'Kyoto', country: 'Japan', state: 'Kyoto' },
|
||||
exif: {},
|
||||
gps: {},
|
||||
resolution: { width: 4000, height: 3000 },
|
||||
orientation: 1,
|
||||
description: null,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
body: null,
|
||||
} as any);
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.post(`${SYNO}/search`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ from: '2024-06-01', to: '2024-06-30' });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(Array.isArray(res.body.assets)).toBe(true);
|
||||
|
||||
// Verify date parameters were forwarded in the Synology API request body
|
||||
expect(capturedBody).not.toBeNull();
|
||||
const startTime = capturedBody!.get('start_time');
|
||||
const endTime = capturedBody!.get('end_time');
|
||||
expect(startTime).toBeDefined();
|
||||
expect(Number(startTime)).toBeGreaterThan(0);
|
||||
expect(endTime).toBeDefined();
|
||||
expect(Number(endTime)).toBeGreaterThan(Number(startTime));
|
||||
});
|
||||
|
||||
it('SYNO-071 — POST /search without date range omits start_time and end_time', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
setSynologyCredentials(testDb, user.id, 'https://synology.example.com', 'admin', 'pass');
|
||||
|
||||
let capturedBody: URLSearchParams | null = null;
|
||||
vi.mocked(safeFetch)
|
||||
.mockResolvedValueOnce({
|
||||
ok: true, status: 200,
|
||||
headers: { get: () => 'application/json' },
|
||||
json: async () => ({ success: true, data: { sid: 'fake-sid' } }),
|
||||
body: null,
|
||||
} as any)
|
||||
.mockImplementationOnce((_url: string, init?: any) => {
|
||||
capturedBody = init?.body instanceof URLSearchParams
|
||||
? init.body
|
||||
: new URLSearchParams(String(init?.body ?? ''));
|
||||
return Promise.resolve({
|
||||
ok: true, status: 200,
|
||||
headers: { get: () => 'application/json' },
|
||||
json: async () => ({ success: true, data: { list: [] } }),
|
||||
body: null,
|
||||
} as any);
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.post(`${SYNO}/search`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(capturedBody).not.toBeNull();
|
||||
expect(capturedBody!.get('start_time')).toBeNull();
|
||||
expect(capturedBody!.get('end_time')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ── SSRF catch branch in _fetchSynologyJson ────────────────────────────────────
|
||||
|
||||
describe('Synology SSRF blocked error handling', () => {
|
||||
it('SYNO-080 — safeFetch throwing SsrfBlockedError for private IP URL returns connected: false', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
setSynologyCredentials(testDb, user.id, 'http://192.168.1.200', 'admin', 'pass');
|
||||
|
||||
const { SsrfBlockedError: SsrfErr } = await import('../../src/utils/ssrfGuard');
|
||||
|
||||
// Make safeFetch throw SsrfBlockedError — simulating the SSRF guard blocking the private IP.
|
||||
// _fetchSynologyJson catches SsrfBlockedError and returns fail(message, 400).
|
||||
// getSynologyStatus receives the failure from _getSynologySession and returns { connected: false }.
|
||||
vi.mocked(safeFetch).mockRejectedValueOnce(new SsrfErr('Private IP not allowed'));
|
||||
|
||||
const res = await request(app)
|
||||
.get(`${SYNO}/status`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.connected).toBe(false);
|
||||
});
|
||||
|
||||
it('SYNO-081 — safeFetch throwing SsrfBlockedError during album list returns 400', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
setSynologyCredentials(testDb, user.id, 'https://synology.example.com', 'admin', 'pass');
|
||||
|
||||
const { SsrfBlockedError: SsrfErr } = await import('../../src/utils/ssrfGuard');
|
||||
|
||||
// Auth succeeds, but the album-list call throws SsrfBlockedError
|
||||
vi.mocked(safeFetch)
|
||||
.mockResolvedValueOnce({
|
||||
ok: true, status: 200,
|
||||
headers: { get: () => 'application/json' },
|
||||
json: async () => ({ success: true, data: { sid: 'sid-x' } }),
|
||||
body: null,
|
||||
} as any)
|
||||
.mockRejectedValueOnce(new SsrfErr('Private IP detected'));
|
||||
|
||||
const res = await request(app)
|
||||
.get(`${SYNO}/albums`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
|
||||
// _fetchSynologyJson catches SsrfBlockedError and returns fail(message, 400)
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -119,24 +119,3 @@ describe('Force HTTPS redirect', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Categories endpoint', () => {
|
||||
it('MISC-013/PLACE-015 — GET /api/categories returns seeded categories', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/categories')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(Array.isArray(res.body.categories)).toBe(true);
|
||||
expect(res.body.categories.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('App config', () => {
|
||||
it('MISC-015 — GET /api/auth/app-config returns configuration', async () => {
|
||||
const res = await request(app).get('/api/auth/app-config');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toHaveProperty('allow_registration');
|
||||
expect(res.body).toHaveProperty('oidc_configured');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -40,6 +40,14 @@ vi.mock('../../src/config', () => ({
|
||||
updateJwtSecret: () => {},
|
||||
}));
|
||||
vi.mock('../../src/websocket', () => ({ broadcastToUser: vi.fn() }));
|
||||
vi.mock('../../src/services/notifications', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../../src/services/notifications')>();
|
||||
return {
|
||||
...actual,
|
||||
testSmtp: vi.fn().mockResolvedValue({ success: true }),
|
||||
testWebhook: vi.fn().mockResolvedValue({ success: true }),
|
||||
};
|
||||
});
|
||||
|
||||
import { createApp } from '../../src/app';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
@@ -316,6 +324,30 @@ describe('Notification test endpoints', () => {
|
||||
.send({ url: 'not-a-url' });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('NOTIF-005b — admin can call test-smtp and gets a result', async () => {
|
||||
const { user } = createAdmin(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/notifications/test-smtp')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ email: 'test@example.com' });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toHaveProperty('success');
|
||||
});
|
||||
|
||||
it('NOTIF-006c — POST /api/notifications/test-webhook with valid URL calls testWebhook', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/notifications/test-webhook')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ url: 'https://webhook.site/test-endpoint' });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toHaveProperty('success');
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
/**
|
||||
* OIDC integration tests — OIDC-001 through OIDC-010.
|
||||
* Covers /api/auth/oidc/login, /callback, /exchange.
|
||||
* HTTP calls (discover, exchangeCodeForToken, getUserInfo) are mocked.
|
||||
* State management, auth codes, and findOrCreateUser run against the real test DB.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll, afterEach } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import type { Application } from 'express';
|
||||
|
||||
// ── DB mock (inline vi.hoisted pattern) ──────────────────────────────────────
|
||||
|
||||
const { testDb, dbMock } = vi.hoisted(() => {
|
||||
const Database = require('better-sqlite3');
|
||||
const db = new Database(':memory:');
|
||||
db.exec('PRAGMA journal_mode = WAL');
|
||||
db.exec('PRAGMA foreign_keys = ON');
|
||||
db.exec('PRAGMA busy_timeout = 5000');
|
||||
const mock = {
|
||||
db,
|
||||
closeDb: () => {},
|
||||
reinitialize: () => {},
|
||||
getPlaceWithTags: () => null,
|
||||
canAccessTrip: (tripId: any, userId: number) =>
|
||||
db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
|
||||
isOwner: (tripId: any, userId: number) =>
|
||||
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
|
||||
};
|
||||
return { testDb: db, dbMock: mock };
|
||||
});
|
||||
|
||||
vi.mock('../../src/db/database', () => dbMock);
|
||||
vi.mock('../../src/config', () => ({
|
||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||
updateJwtSecret: () => {},
|
||||
}));
|
||||
|
||||
// ── Mock only the HTTP-calling functions from oidcService ────────────────────
|
||||
vi.mock('../../src/services/oidcService', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../../src/services/oidcService')>();
|
||||
return {
|
||||
...actual,
|
||||
discover: vi.fn(),
|
||||
exchangeCodeForToken: vi.fn(),
|
||||
getUserInfo: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
import { createApp } from '../../src/app';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
import { createUser } from '../helpers/factories';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
import * as oidcService from '../../src/services/oidcService';
|
||||
|
||||
const mockDiscover = vi.mocked(oidcService.discover);
|
||||
const mockExchangeCode = vi.mocked(oidcService.exchangeCodeForToken);
|
||||
const mockGetUserInfo = vi.mocked(oidcService.getUserInfo);
|
||||
|
||||
const MOCK_DISCOVERY_DOC = {
|
||||
authorization_endpoint: 'https://oidc.example.com/auth',
|
||||
token_endpoint: 'https://oidc.example.com/token',
|
||||
userinfo_endpoint: 'https://oidc.example.com/userinfo',
|
||||
};
|
||||
|
||||
const app: Application = createApp();
|
||||
|
||||
beforeAll(() => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
loginAttempts.clear();
|
||||
mfaAttempts.clear();
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Set OIDC environment variables for each test
|
||||
process.env.OIDC_ISSUER = 'https://oidc.example.com';
|
||||
process.env.OIDC_CLIENT_ID = 'test-client-id';
|
||||
process.env.OIDC_CLIENT_SECRET = 'test-client-secret';
|
||||
process.env.APP_URL = 'http://localhost:3001';
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.OIDC_ISSUER;
|
||||
delete process.env.OIDC_CLIENT_ID;
|
||||
delete process.env.OIDC_CLIENT_SECRET;
|
||||
delete process.env.APP_URL;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
// ── /login ───────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('GET /api/auth/oidc/login', () => {
|
||||
it('OIDC-001: redirects to OIDC authorization endpoint (302)', async () => {
|
||||
mockDiscover.mockResolvedValueOnce(MOCK_DISCOVERY_DOC);
|
||||
|
||||
const res = await request(app).get('/api/auth/oidc/login');
|
||||
|
||||
expect(res.status).toBe(302);
|
||||
expect(res.headers.location).toContain('https://oidc.example.com/auth');
|
||||
expect(res.headers.location).toContain('client_id=test-client-id');
|
||||
expect(res.headers.location).toContain('response_type=code');
|
||||
expect(res.headers.location).toContain('redirect_uri=');
|
||||
expect(res.headers.location).toContain('state=');
|
||||
});
|
||||
|
||||
it('OIDC-002: returns 400 when OIDC is not configured', async () => {
|
||||
delete process.env.OIDC_ISSUER;
|
||||
delete process.env.OIDC_CLIENT_ID;
|
||||
delete process.env.OIDC_CLIENT_SECRET;
|
||||
|
||||
const res = await request(app).get('/api/auth/oidc/login');
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toBeDefined();
|
||||
});
|
||||
|
||||
it('OIDC-003: includes invite token in state when provided', async () => {
|
||||
mockDiscover.mockResolvedValueOnce(MOCK_DISCOVERY_DOC);
|
||||
|
||||
const res = await request(app).get('/api/auth/oidc/login?invite=abc123');
|
||||
expect(res.status).toBe(302);
|
||||
// State is a hex token; the invite is embedded in pendingStates (internal)
|
||||
// We just verify the redirect happened successfully
|
||||
expect(res.headers.location).toContain('state=');
|
||||
});
|
||||
});
|
||||
|
||||
// ── /callback ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('GET /api/auth/oidc/callback', () => {
|
||||
it('OIDC-004: valid code for existing user → redirects to frontend with oidc_code', async () => {
|
||||
const { user } = createUser(testDb, { email: 'alice@example.com' });
|
||||
|
||||
mockDiscover.mockResolvedValueOnce(MOCK_DISCOVERY_DOC);
|
||||
mockExchangeCode.mockResolvedValueOnce({
|
||||
access_token: 'test-access-token',
|
||||
_ok: true,
|
||||
_status: 200,
|
||||
});
|
||||
mockGetUserInfo.mockResolvedValueOnce({
|
||||
sub: 'sub-alice-123',
|
||||
email: 'alice@example.com',
|
||||
name: 'Alice',
|
||||
});
|
||||
|
||||
// Create a valid state token
|
||||
const state = oidcService.createState('http://localhost:3001/api/auth/oidc/callback');
|
||||
|
||||
const res = await request(app).get(`/api/auth/oidc/callback?code=authcode123&state=${state}`);
|
||||
|
||||
expect(res.status).toBe(302);
|
||||
expect(res.headers.location).toContain('/login?oidc_code=');
|
||||
});
|
||||
|
||||
it('OIDC-005: new user gets created when registration is open', async () => {
|
||||
mockDiscover.mockResolvedValueOnce(MOCK_DISCOVERY_DOC);
|
||||
mockExchangeCode.mockResolvedValueOnce({ access_token: 'new-token', _ok: true, _status: 200 });
|
||||
mockGetUserInfo.mockResolvedValueOnce({
|
||||
sub: 'sub-newuser-999',
|
||||
email: 'newuser@example.com',
|
||||
name: 'New User',
|
||||
});
|
||||
|
||||
const state = oidcService.createState('http://localhost:3001/api/auth/oidc/callback');
|
||||
|
||||
const res = await request(app).get(`/api/auth/oidc/callback?code=code999&state=${state}`);
|
||||
|
||||
expect(res.status).toBe(302);
|
||||
expect(res.headers.location).toContain('/login?oidc_code=');
|
||||
|
||||
// Verify user was created in DB
|
||||
const newUser = testDb.prepare("SELECT * FROM users WHERE email = 'newuser@example.com'").get();
|
||||
expect(newUser).toBeDefined();
|
||||
});
|
||||
|
||||
it('OIDC-006: invalid state → redirects with invalid_state error', async () => {
|
||||
const res = await request(app).get('/api/auth/oidc/callback?code=abc&state=invalid-state-xyz');
|
||||
|
||||
expect(res.status).toBe(302);
|
||||
expect(res.headers.location).toContain('oidc_error=invalid_state');
|
||||
});
|
||||
|
||||
it('OIDC-007: provider error param → redirects with error', async () => {
|
||||
const res = await request(app).get('/api/auth/oidc/callback?error=access_denied');
|
||||
|
||||
expect(res.status).toBe(302);
|
||||
expect(res.headers.location).toContain('oidc_error=access_denied');
|
||||
});
|
||||
|
||||
it('OIDC-008: missing code or state → redirects with missing_params error', async () => {
|
||||
const res = await request(app).get('/api/auth/oidc/callback');
|
||||
|
||||
expect(res.status).toBe(302);
|
||||
expect(res.headers.location).toContain('oidc_error=missing_params');
|
||||
});
|
||||
|
||||
it('OIDC-009: token exchange failure → redirects with token_failed error', async () => {
|
||||
mockDiscover.mockResolvedValueOnce(MOCK_DISCOVERY_DOC);
|
||||
mockExchangeCode.mockResolvedValueOnce({ _ok: false, _status: 400 });
|
||||
|
||||
const state = oidcService.createState('http://localhost:3001/api/auth/oidc/callback');
|
||||
|
||||
const res = await request(app).get(`/api/auth/oidc/callback?code=badcode&state=${state}`);
|
||||
|
||||
expect(res.status).toBe(302);
|
||||
expect(res.headers.location).toContain('oidc_error=token_failed');
|
||||
});
|
||||
|
||||
it('OIDC-010: registration disabled for new user → redirects with registration_disabled error', async () => {
|
||||
// Need at least one existing user so isFirstUser=false
|
||||
createUser(testDb, { email: 'existing@example.com' });
|
||||
// Disable registration
|
||||
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('allow_registration', 'false')").run();
|
||||
|
||||
mockDiscover.mockResolvedValueOnce(MOCK_DISCOVERY_DOC);
|
||||
mockExchangeCode.mockResolvedValueOnce({ access_token: 'tok', _ok: true, _status: 200 });
|
||||
mockGetUserInfo.mockResolvedValueOnce({
|
||||
sub: 'sub-blocked-user',
|
||||
email: 'blocked@example.com',
|
||||
name: 'Blocked',
|
||||
});
|
||||
|
||||
const state = oidcService.createState('http://localhost:3001/api/auth/oidc/callback');
|
||||
|
||||
const res = await request(app).get(`/api/auth/oidc/callback?code=anycode&state=${state}`);
|
||||
|
||||
expect(res.status).toBe(302);
|
||||
expect(res.headers.location).toContain('oidc_error=registration_disabled');
|
||||
});
|
||||
});
|
||||
|
||||
// ── /exchange ─────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('GET /api/auth/oidc/exchange', () => {
|
||||
it('OIDC-011: valid auth code returns JWT and sets cookie', async () => {
|
||||
const fakeToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.test.sig';
|
||||
const code = oidcService.createAuthCode(fakeToken);
|
||||
|
||||
const res = await request(app).get(`/api/auth/oidc/exchange?code=${code}`);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.token).toBe(fakeToken);
|
||||
expect(res.headers['set-cookie']).toBeDefined();
|
||||
const cookieHeader = Array.isArray(res.headers['set-cookie'])
|
||||
? res.headers['set-cookie'].join(';')
|
||||
: res.headers['set-cookie'];
|
||||
expect(cookieHeader).toContain('trek_session');
|
||||
});
|
||||
|
||||
it('OIDC-012: missing code returns 400', async () => {
|
||||
const res = await request(app).get('/api/auth/oidc/exchange');
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toBeDefined();
|
||||
});
|
||||
|
||||
it('OIDC-013: invalid/expired code returns 400', async () => {
|
||||
const res = await request(app).get('/api/auth/oidc/exchange?code=not-a-real-code');
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toBeDefined();
|
||||
});
|
||||
|
||||
it('OIDC-014: auth code is single-use (second use returns 400)', async () => {
|
||||
const fakeToken = 'test.token.here';
|
||||
const code = oidcService.createAuthCode(fakeToken);
|
||||
|
||||
// First use: success
|
||||
const res1 = await request(app).get(`/api/auth/oidc/exchange?code=${code}`);
|
||||
expect(res1.status).toBe(200);
|
||||
|
||||
// Second use: rejected
|
||||
const res2 = await request(app).get(`/api/auth/oidc/exchange?code=${code}`);
|
||||
expect(res2.status).toBe(400);
|
||||
});
|
||||
});
|
||||
@@ -244,6 +244,12 @@ describe('Reorder packing items', () => {
|
||||
.send({ orderedIds: [i2.id, i1.id] });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
|
||||
const rows = testDb
|
||||
.prepare('SELECT id, sort_order FROM packing_items WHERE trip_id = ? ORDER BY sort_order')
|
||||
.all(trip.id) as Array<{ id: number; sort_order: number }>;
|
||||
expect(rows[0].id).toBe(i2.id);
|
||||
expect(rows[1].id).toBe(i1.id);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -360,3 +366,120 @@ describe('Category assignees', () => {
|
||||
expect(res.body.assignees).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Packing — apply-template, bag members, save-as-template', () => {
|
||||
it('PACK-015 — POST /apply-template/:templateId applies template items to trip', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
const tpl = testDb.prepare("INSERT INTO packing_templates (name, created_by) VALUES ('Beach', ?)").run(user.id);
|
||||
const cat = testDb.prepare("INSERT INTO packing_template_categories (template_id, name, sort_order) VALUES (?, 'Essentials', 0)").run(tpl.lastInsertRowid);
|
||||
testDb.prepare("INSERT INTO packing_template_items (category_id, name, sort_order) VALUES (?, 'Sunscreen', 0)").run(cat.lastInsertRowid);
|
||||
const templateId = tpl.lastInsertRowid;
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/trips/${trip.id}/packing/apply-template/${templateId}`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(Array.isArray(res.body.items)).toBe(true);
|
||||
expect(res.body.items.length).toBeGreaterThan(0);
|
||||
expect(res.body.count).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('PACK-015b — POST /apply-template/:id for empty template returns 404', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
// Template with no items
|
||||
const tpl = testDb.prepare("INSERT INTO packing_templates (name, created_by) VALUES ('Empty', ?)").run(user.id);
|
||||
const emptyTemplateId = tpl.lastInsertRowid;
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/trips/${trip.id}/packing/apply-template/${emptyTemplateId}`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body.error).toBeDefined();
|
||||
});
|
||||
|
||||
it('PACK-016 — PUT /bags/:bagId/members sets bag members', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { user: member } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
addTripMember(testDb, trip.id, member.id);
|
||||
|
||||
// Create a bag first
|
||||
const bagRes = await request(app)
|
||||
.post(`/api/trips/${trip.id}/packing/bags`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ name: 'Carry-on' });
|
||||
expect(bagRes.status).toBe(201);
|
||||
const bagId = bagRes.body.bag.id;
|
||||
|
||||
const res = await request(app)
|
||||
.put(`/api/trips/${trip.id}/packing/bags/${bagId}/members`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ user_ids: [user.id, member.id] });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(Array.isArray(res.body.members)).toBe(true);
|
||||
expect(res.body.members.length).toBe(2);
|
||||
});
|
||||
|
||||
it('PACK-016b — PUT /bags/:bagId/members for non-existent bag returns 404', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
const res = await request(app)
|
||||
.put(`/api/trips/${trip.id}/packing/bags/999999/members`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ user_ids: [user.id] });
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body.error).toBeDefined();
|
||||
});
|
||||
|
||||
it('PACK-017 — POST /save-as-template saves packing list as a template', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
// Add an item so the trip has something to save
|
||||
createPackingItem(testDb, trip.id);
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/trips/${trip.id}/packing/save-as-template`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ name: 'My Summer Template' });
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.template).toBeDefined();
|
||||
expect(res.body.template.name).toBe('My Summer Template');
|
||||
});
|
||||
|
||||
it('PACK-017b — POST /save-as-template without name returns 400', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/trips/${trip.id}/packing/save-as-template`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({});
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toBeDefined();
|
||||
});
|
||||
|
||||
it('PACK-017c — POST /save-as-template when trip has no items returns 400', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/trips/${trip.id}/packing/save-as-template`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ name: 'Empty Trip Template' });
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -42,6 +42,14 @@ vi.mock('../../src/config', () => ({
|
||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||
updateJwtSecret: () => {},
|
||||
}));
|
||||
vi.mock('../../src/services/placeService', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../../src/services/placeService')>();
|
||||
return {
|
||||
...actual,
|
||||
importGoogleList: vi.fn(),
|
||||
searchPlaceImage: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
import { createApp } from '../../src/app';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
@@ -50,6 +58,8 @@ import { resetTestDb } from '../helpers/test-db';
|
||||
import { createUser, createAdmin, createTrip, createPlace, addTripMember } from '../helpers/factories';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
import * as placeService from '../../src/services/placeService';
|
||||
import { invalidatePermissionsCache } from '../../src/services/permissions';
|
||||
|
||||
const app: Application = createApp();
|
||||
const GPX_FIXTURE = path.join(__dirname, '../fixtures/test.gpx');
|
||||
@@ -63,6 +73,7 @@ beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
loginAttempts.clear();
|
||||
mfaAttempts.clear();
|
||||
invalidatePermissionsCache();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
@@ -603,3 +614,179 @@ describe('GPX Import', () => {
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// GPX import — no waypoints
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('GPX Import — edge cases', () => {
|
||||
it('PLACE-019c — GPX with no waypoints returns 400', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
// Minimal valid GPX with no waypoints, tracks, or routes
|
||||
const emptyGpx = Buffer.from(
|
||||
'<?xml version="1.0" encoding="UTF-8"?>' +
|
||||
'<gpx version="1.1" xmlns="http://www.topografix.com/GPX/1/1"></gpx>'
|
||||
);
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/trips/${trip.id}/places/import/gpx`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.attach('file', emptyGpx, { filename: 'empty.gpx', contentType: 'application/gpx+xml' });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/no waypoints/i);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Google Maps list import
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Google Maps list import', () => {
|
||||
it('PLACE-020 — POST /import/google-list without url returns 400', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/trips/${trip.id}/places/import/google-list`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('PLACE-020b — POST /import/google-list success path returns 201 with places', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
vi.mocked(placeService.importGoogleList).mockResolvedValueOnce({
|
||||
places: [{ id: 1, name: 'Mocked Place' } as any],
|
||||
listName: 'My List',
|
||||
} as any);
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/trips/${trip.id}/places/import/google-list`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ url: 'https://maps.google.com/maps/list/example' });
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.count).toBe(1);
|
||||
expect(res.body.listName).toBe('My List');
|
||||
});
|
||||
|
||||
it('PLACE-020c — POST /import/google-list returns service error status', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
vi.mocked(placeService.importGoogleList).mockResolvedValueOnce({
|
||||
error: 'Invalid list URL',
|
||||
status: 422,
|
||||
} as any);
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/trips/${trip.id}/places/import/google-list`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ url: 'https://maps.google.com/maps/list/bad' });
|
||||
expect(res.status).toBe(422);
|
||||
expect(res.body.error).toBe('Invalid list URL');
|
||||
});
|
||||
|
||||
it('PLACE-020d — POST /import/google-list thrown exception returns 400', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
vi.mocked(placeService.importGoogleList).mockRejectedValueOnce(new Error('Network failure'));
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/trips/${trip.id}/places/import/google-list`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ url: 'https://maps.google.com/maps/list/broken' });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Place image search
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Place image search', () => {
|
||||
it('PLACE-021 — GET /:id/image returns photos on success', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const place = createPlace(testDb, trip.id, { name: 'Louvre' });
|
||||
|
||||
vi.mocked(placeService.searchPlaceImage).mockResolvedValueOnce({
|
||||
photos: [{ url: 'https://example.com/photo.jpg' }],
|
||||
} as any);
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/trips/${trip.id}/places/${place.id}/image`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.photos).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('PLACE-021b — GET /:id/image returns service error status', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const place = createPlace(testDb, trip.id, { name: 'Tower' });
|
||||
|
||||
vi.mocked(placeService.searchPlaceImage).mockResolvedValueOnce({
|
||||
error: 'No images found',
|
||||
status: 404,
|
||||
} as any);
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/trips/${trip.id}/places/${place.id}/image`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body.error).toBe('No images found');
|
||||
});
|
||||
|
||||
it('PLACE-021c — GET /:id/image thrown exception returns 500', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const place = createPlace(testDb, trip.id, { name: 'Bridge' });
|
||||
|
||||
vi.mocked(placeService.searchPlaceImage).mockRejectedValueOnce(new Error('Unsplash down'));
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/trips/${trip.id}/places/${place.id}/image`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(500);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Delete place permission denied
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Delete place — permission edge cases', () => {
|
||||
it('PLACE-022 — DELETE place by non-owner member when place_edit is trip_owner returns 403', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: member } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
addTripMember(testDb, trip.id, member.id);
|
||||
const place = createPlace(testDb, trip.id, { name: 'Restricted Place' });
|
||||
|
||||
// Restrict place edits to trip owner only
|
||||
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('perm_place_edit', 'trip_owner')").run();
|
||||
invalidatePermissionsCache();
|
||||
|
||||
const res = await request(app)
|
||||
.delete(`/api/trips/${trip.id}/places/${place.id}`)
|
||||
.set('Cookie', authCookie(member.id));
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Delete place — not found', () => {
|
||||
it('PLACE-023 — DELETE non-existent place returns 404', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
const res = await request(app)
|
||||
.delete(`/api/trips/${trip.id}/places/99999`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -205,36 +205,6 @@ describe('Settings', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('API Keys', () => {
|
||||
it('PROFILE-011 — PUT /api/auth/me/api-keys saves keys encrypted at rest', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const res = await request(app)
|
||||
.put('/api/auth/me/api-keys')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ openweather_api_key: 'my-weather-key-123' });
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
// Key in DB should be encrypted (not plaintext)
|
||||
const row = testDb.prepare('SELECT openweather_api_key FROM users WHERE id = ?').get(user.id) as any;
|
||||
expect(row.openweather_api_key).toMatch(/^enc:v1:/);
|
||||
});
|
||||
|
||||
it('PROFILE-011 — GET /api/auth/me does not return plaintext API keys', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
await request(app)
|
||||
.put('/api/auth/me/api-keys')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ openweather_api_key: 'plaintext-key' });
|
||||
|
||||
const me = await request(app)
|
||||
.get('/api/auth/me')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
// The key should be masked or absent, never plaintext
|
||||
const body = me.body.user;
|
||||
expect(body.openweather_api_key).not.toBe('plaintext-key');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Account deletion', () => {
|
||||
it('PROFILE-013 — DELETE /api/auth/me removes account, subsequent login fails', async () => {
|
||||
const { user, password } = createUser(testDb);
|
||||
|
||||
@@ -41,7 +41,7 @@ import { createApp } from '../../src/app';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
import { createUser, createTrip, createDay, createReservation, addTripMember } from '../helpers/factories';
|
||||
import { createUser, createTrip, createDay, createPlace, createReservation, addTripMember } from '../helpers/factories';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
|
||||
@@ -187,6 +187,43 @@ describe('Update reservation', () => {
|
||||
.send({ title: 'Updated' });
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('RESV-010 — PUT syncs check-in/out times to linked accommodation', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const day1 = createDay(testDb, trip.id, { date: '2025-08-01' });
|
||||
const day2 = createDay(testDb, trip.id, { date: '2025-08-03' });
|
||||
const place = createPlace(testDb, trip.id, { name: 'Sync Hotel' });
|
||||
|
||||
// Create reservation with linked accommodation
|
||||
const createRes = await request(app)
|
||||
.post(`/api/trips/${trip.id}/reservations`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({
|
||||
title: 'Hotel Booking',
|
||||
type: 'hotel',
|
||||
day_id: day1.id,
|
||||
create_accommodation: { place_id: place.id, start_day_id: day1.id, end_day_id: day2.id },
|
||||
});
|
||||
expect(createRes.status).toBe(201);
|
||||
const resvId = createRes.body.reservation.id;
|
||||
|
||||
// Update with metadata containing check-in/out times and confirmation_number
|
||||
const updateRes = await request(app)
|
||||
.put(`/api/trips/${trip.id}/reservations/${resvId}`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({
|
||||
metadata: { check_in_time: '15:00', check_out_time: '11:00' },
|
||||
confirmation_number: 'HTL-XYZ-999',
|
||||
});
|
||||
expect(updateRes.status).toBe(200);
|
||||
|
||||
// Verify accommodation was updated with check-in/out
|
||||
const accom = testDb.prepare('SELECT * FROM day_accommodations WHERE trip_id = ?').get(trip.id) as any;
|
||||
expect(accom.check_in).toBe('15:00');
|
||||
expect(accom.check_out).toBe('11:00');
|
||||
expect(accom.confirmation).toBe('HTL-XYZ-999');
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -241,3 +278,178 @@ describe('Batch update positions', () => {
|
||||
expect(res.body.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Budget entry auto-create / auto-update
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Reservation budget entry integration', () => {
|
||||
it('RESV-011 — POST with create_budget_entry auto-creates a linked budget item', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/trips/${trip.id}/reservations`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({
|
||||
title: 'Flight to Paris',
|
||||
type: 'flight',
|
||||
create_budget_entry: { total_price: 250, category: 'Transport' },
|
||||
});
|
||||
expect(res.status).toBe(201);
|
||||
|
||||
const budgetItem = testDb
|
||||
.prepare('SELECT * FROM budget_items WHERE trip_id = ? AND reservation_id = ?')
|
||||
.get(trip.id, res.body.reservation.id) as any;
|
||||
expect(budgetItem).toBeDefined();
|
||||
expect(budgetItem.total_price).toBe(250);
|
||||
expect(budgetItem.name).toBe('Flight to Paris');
|
||||
});
|
||||
|
||||
it('RESV-011b — POST with create_budget_entry.total_price = 0 skips budget creation', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/trips/${trip.id}/reservations`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({
|
||||
title: 'Free Entry',
|
||||
type: 'activity',
|
||||
create_budget_entry: { total_price: 0 },
|
||||
});
|
||||
expect(res.status).toBe(201);
|
||||
|
||||
const budgetItems = testDb
|
||||
.prepare('SELECT * FROM budget_items WHERE trip_id = ?')
|
||||
.all(trip.id) as any[];
|
||||
expect(budgetItems).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('RESV-012 — PUT with create_budget_entry creates a new budget item when none exists', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const resv = createReservation(testDb, trip.id, { title: 'Hotel Stay', type: 'hotel' });
|
||||
|
||||
const res = await request(app)
|
||||
.put(`/api/trips/${trip.id}/reservations/${resv.id}`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ create_budget_entry: { total_price: 300, category: 'Accommodation' } });
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const budgetItem = testDb
|
||||
.prepare('SELECT * FROM budget_items WHERE trip_id = ? AND reservation_id = ?')
|
||||
.get(trip.id, resv.id) as any;
|
||||
expect(budgetItem).toBeDefined();
|
||||
expect(budgetItem.total_price).toBe(300);
|
||||
});
|
||||
|
||||
it('RESV-013 — PUT with create_budget_entry updates existing linked budget item', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
// Create reservation with budget entry via POST
|
||||
const createRes = await request(app)
|
||||
.post(`/api/trips/${trip.id}/reservations`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({
|
||||
title: 'Car Rental',
|
||||
type: 'transport',
|
||||
create_budget_entry: { total_price: 100, category: 'Transport' },
|
||||
});
|
||||
expect(createRes.status).toBe(201);
|
||||
const resvId = createRes.body.reservation.id;
|
||||
|
||||
// Update with a new price — should update the existing budget item
|
||||
const updateRes = await request(app)
|
||||
.put(`/api/trips/${trip.id}/reservations/${resvId}`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ create_budget_entry: { total_price: 150, category: 'Transport' } });
|
||||
expect(updateRes.status).toBe(200);
|
||||
|
||||
const items = testDb
|
||||
.prepare('SELECT * FROM budget_items WHERE trip_id = ? AND reservation_id = ?')
|
||||
.all(trip.id, resvId) as any[];
|
||||
expect(items).toHaveLength(1);
|
||||
expect(items[0].total_price).toBe(150);
|
||||
});
|
||||
|
||||
it('RESV-014 — PUT without create_budget_entry removes existing linked budget item', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
// Create with budget entry
|
||||
const createRes = await request(app)
|
||||
.post(`/api/trips/${trip.id}/reservations`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({
|
||||
title: 'Taxi',
|
||||
type: 'transport',
|
||||
create_budget_entry: { total_price: 50, category: 'Transport' },
|
||||
});
|
||||
expect(createRes.status).toBe(201);
|
||||
const resvId = createRes.body.reservation.id;
|
||||
|
||||
// Verify budget item exists
|
||||
const before = testDb
|
||||
.prepare('SELECT id FROM budget_items WHERE trip_id = ? AND reservation_id = ?')
|
||||
.get(trip.id, resvId);
|
||||
expect(before).toBeDefined();
|
||||
|
||||
// Update without create_budget_entry — should delete the linked budget item
|
||||
const updateRes = await request(app)
|
||||
.put(`/api/trips/${trip.id}/reservations/${resvId}`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ title: 'Taxi Updated' });
|
||||
expect(updateRes.status).toBe(200);
|
||||
|
||||
const after = testDb
|
||||
.prepare('SELECT id FROM budget_items WHERE trip_id = ? AND reservation_id = ?')
|
||||
.get(trip.id, resvId);
|
||||
expect(after).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Reservation accommodation delete', () => {
|
||||
it('RESV-009 — DELETE reservation linked to accommodation also removes the accommodation', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const day1 = createDay(testDb, trip.id, { date: '2025-07-01' });
|
||||
const day2 = createDay(testDb, trip.id, { date: '2025-07-03' });
|
||||
const place = createPlace(testDb, trip.id, { name: 'Hotel Belle' });
|
||||
|
||||
// Create a reservation via API with create_accommodation as an object
|
||||
const createRes = await request(app)
|
||||
.post(`/api/trips/${trip.id}/reservations`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({
|
||||
title: 'Hotel Belle Stay',
|
||||
type: 'hotel',
|
||||
day_id: day1.id,
|
||||
create_accommodation: {
|
||||
place_id: place.id,
|
||||
start_day_id: day1.id,
|
||||
end_day_id: day2.id,
|
||||
},
|
||||
});
|
||||
expect(createRes.status).toBe(201);
|
||||
const reservationId = createRes.body.reservation.id;
|
||||
|
||||
// Verify accommodation was created
|
||||
const accom = testDb.prepare(
|
||||
'SELECT id FROM day_accommodations WHERE trip_id = ?'
|
||||
).get(trip.id) as any;
|
||||
expect(accom).toBeDefined();
|
||||
|
||||
// Delete reservation — should also remove the accommodation
|
||||
const delRes = await request(app)
|
||||
.delete(`/api/trips/${trip.id}/reservations/${reservationId}`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(delRes.status).toBe(200);
|
||||
|
||||
const accomAfter = testDb.prepare(
|
||||
'SELECT id FROM day_accommodations WHERE id = ?'
|
||||
).get(accom.id);
|
||||
expect(accomAfter).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import type { Application } from 'express';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
|
||||
const { testDb, dbMock } = vi.hoisted(() => {
|
||||
const Database = require('better-sqlite3');
|
||||
@@ -46,29 +48,35 @@ import { createApp } from '../../src/app';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
import { createUser } from '../helpers/factories';
|
||||
import { authCookie, generateToken } from '../helpers/auth';
|
||||
import { createUser, createTrip } from '../helpers/factories';
|
||||
import { authCookie, authHeader, generateToken } from '../helpers/auth';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
|
||||
const app: Application = createApp();
|
||||
const FIXTURE_IMG = path.join(__dirname, '../fixtures/small-image.jpg');
|
||||
const uploadsDir = path.join(__dirname, '../../uploads/files');
|
||||
|
||||
beforeAll(() => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
if (!fs.existsSync(uploadsDir)) fs.mkdirSync(uploadsDir, { recursive: true });
|
||||
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('allowed_file_types', '*')").run();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
loginAttempts.clear();
|
||||
mfaAttempts.clear();
|
||||
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('allowed_file_types', '*')").run();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
fs.rmSync(uploadsDir, { recursive: true, force: true });
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
describe('Authentication security', () => {
|
||||
it('SEC-007 — JWT in Authorization Bearer header authenticates user', async () => {
|
||||
it('SEC-007 — invalid JWT in Authorization Bearer header is rejected', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const token = generateToken(user.id);
|
||||
|
||||
@@ -162,12 +170,21 @@ describe('Request body size limit', () => {
|
||||
describe('File download path traversal', () => {
|
||||
it('SEC-005 — path traversal in file download is blocked', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = { id: 1 };
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
const upload = await request(app)
|
||||
.post(`/api/trips/${trip.id}/files`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.attach('file', FIXTURE_IMG);
|
||||
expect(upload.status).toBe(201);
|
||||
const fileId = upload.body.file.id;
|
||||
|
||||
testDb.prepare('UPDATE trip_files SET filename = ? WHERE id = ?').run('../../etc/passwd', fileId);
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/trips/${trip.id}/files/1/download`)
|
||||
.set('Authorization', `Bearer ${generateToken(user.id)}`);
|
||||
// Trip 1 does not exist after resetTestDb → 404 before any file path is evaluated
|
||||
expect(res.status).toBe(404);
|
||||
.get(`/api/trips/${trip.id}/files/${fileId}/download`)
|
||||
.set(authHeader(user.id));
|
||||
// resolveFilePath strips traversal via path.basename; normalized file does not exist in uploads
|
||||
expect(res.status).not.toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* Settings integration tests — SET-001 through SET-008.
|
||||
* Covers GET /api/settings, PUT /api/settings, POST /api/settings/bulk.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import type { Application } from 'express';
|
||||
|
||||
const { testDb, dbMock } = vi.hoisted(() => {
|
||||
const Database = require('better-sqlite3');
|
||||
const db = new Database(':memory:');
|
||||
db.exec('PRAGMA journal_mode = WAL');
|
||||
db.exec('PRAGMA foreign_keys = ON');
|
||||
db.exec('PRAGMA busy_timeout = 5000');
|
||||
const mock = {
|
||||
db,
|
||||
closeDb: () => {},
|
||||
reinitialize: () => {},
|
||||
getPlaceWithTags: () => null,
|
||||
canAccessTrip: (tripId: any, userId: number) =>
|
||||
db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
|
||||
isOwner: (tripId: any, userId: number) =>
|
||||
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
|
||||
};
|
||||
return { testDb: db, dbMock: mock };
|
||||
});
|
||||
|
||||
vi.mock('../../src/db/database', () => dbMock);
|
||||
vi.mock('../../src/config', () => ({
|
||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||
updateJwtSecret: () => {},
|
||||
}));
|
||||
|
||||
import { createApp } from '../../src/app';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
import { createUser } from '../helpers/factories';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
|
||||
const app: Application = createApp();
|
||||
|
||||
beforeAll(() => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
loginAttempts.clear();
|
||||
mfaAttempts.clear();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
describe('Settings', () => {
|
||||
it('SET-001: GET /api/settings returns empty object for new user', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const res = await request(app)
|
||||
.get('/api/settings')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.settings).toBeDefined();
|
||||
expect(typeof res.body.settings).toBe('object');
|
||||
// New user has no custom settings
|
||||
expect(Object.keys(res.body.settings)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('SET-002: PUT /api/settings sets a key/value pair', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const res = await request(app)
|
||||
.put('/api/settings')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ key: 'theme', value: 'dark' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(res.body.key).toBe('theme');
|
||||
expect(res.body.value).toBe('dark');
|
||||
});
|
||||
|
||||
it('SET-003: PUT /api/settings updates an existing key', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
await request(app)
|
||||
.put('/api/settings')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ key: 'theme', value: 'dark' });
|
||||
|
||||
const res = await request(app)
|
||||
.put('/api/settings')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ key: 'theme', value: 'light' });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.value).toBe('light');
|
||||
|
||||
// Verify the GET reflects the updated value
|
||||
const getRes = await request(app)
|
||||
.get('/api/settings')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(getRes.body.settings.theme).toBe('light');
|
||||
});
|
||||
|
||||
it('SET-004: POST /api/settings/bulk upserts multiple settings', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const res = await request(app)
|
||||
.post('/api/settings/bulk')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ settings: { theme: 'dark', language: 'en', compact_mode: 'true' } });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(res.body.updated).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
|
||||
it('SET-005: GET /api/settings reflects previously upserted values', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
await request(app)
|
||||
.post('/api/settings/bulk')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ settings: { theme: 'dark', language: 'fr' } });
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/settings')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.settings.theme).toBe('dark');
|
||||
expect(res.body.settings.language).toBe('fr');
|
||||
});
|
||||
|
||||
it('SET-006: GET /api/settings without auth returns 401', async () => {
|
||||
const res = await request(app).get('/api/settings');
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('SET-007: PUT /api/settings without key returns 400', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const res = await request(app)
|
||||
.put('/api/settings')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ value: 'dark' });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toBeDefined();
|
||||
});
|
||||
|
||||
it('SET-008: PUT /api/settings with masked value is ignored (no-op)', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
// First set a real value
|
||||
await request(app)
|
||||
.put('/api/settings')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ key: 'webhook_url', value: 'https://example.com/hook' });
|
||||
|
||||
// Then try to "save" the masked placeholder
|
||||
const res = await request(app)
|
||||
.put('/api/settings')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ key: 'webhook_url', value: '••••••••' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.unchanged).toBe(true);
|
||||
});
|
||||
|
||||
it('SET-009: POST /api/settings/bulk without settings object returns 400', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const res = await request(app)
|
||||
.post('/api/settings/bulk')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ settings: null });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('SET-010: settings are user-scoped (user A cannot see user B settings)', async () => {
|
||||
const { user: userA } = createUser(testDb);
|
||||
const { user: userB } = createUser(testDb);
|
||||
|
||||
await request(app)
|
||||
.put('/api/settings')
|
||||
.set('Cookie', authCookie(userA.id))
|
||||
.send({ key: 'secret_setting', value: 'user_a_secret' });
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/settings')
|
||||
.set('Cookie', authCookie(userB.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.settings.secret_setting).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -41,7 +41,7 @@ import { createApp } from '../../src/app';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
import { createUser, createTrip, addTripMember } from '../helpers/factories';
|
||||
import { createUser, createTrip, addTripMember, createDay, createPlace, createDayAssignment, createDayNote } from '../helpers/factories';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
|
||||
@@ -205,3 +205,83 @@ describe('Shared trip access', () => {
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Shared trip — day assignments and notes', () => {
|
||||
it('SHARE-010 — shared trip with days and assignments includes place data in assignments', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, { title: 'Rome Trip' });
|
||||
const day = createDay(testDb, trip.id, { date: '2025-06-01' });
|
||||
const place = createPlace(testDb, trip.id, { name: 'Colosseum', lat: 41.89, lng: 12.49 });
|
||||
createDayAssignment(testDb, day.id, place.id, { notes: 'Amazing site' });
|
||||
|
||||
const create = await request(app)
|
||||
.post(`/api/trips/${trip.id}/share-link`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({});
|
||||
const token = create.body.token;
|
||||
|
||||
const res = await request(app).get(`/api/shared/${token}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.days).toHaveLength(1);
|
||||
const dayAssignments = res.body.assignments[day.id];
|
||||
expect(Array.isArray(dayAssignments)).toBe(true);
|
||||
expect(dayAssignments).toHaveLength(1);
|
||||
expect(dayAssignments[0].place.name).toBe('Colosseum');
|
||||
expect(dayAssignments[0].place.lat).toBe(41.89);
|
||||
});
|
||||
|
||||
it('SHARE-011 — shared trip with day notes includes notes in response', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, { title: 'Notes Trip' });
|
||||
const day = createDay(testDb, trip.id, { date: '2025-07-01' });
|
||||
createDayNote(testDb, day.id, trip.id, { text: 'Meet at the station' });
|
||||
|
||||
const create = await request(app)
|
||||
.post(`/api/trips/${trip.id}/share-link`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({});
|
||||
const token = create.body.token;
|
||||
|
||||
const res = await request(app).get(`/api/shared/${token}`);
|
||||
expect(res.status).toBe(200);
|
||||
const dayNotes = res.body.dayNotes[day.id];
|
||||
expect(Array.isArray(dayNotes)).toBe(true);
|
||||
expect(dayNotes).toHaveLength(1);
|
||||
expect(dayNotes[0].text).toBe('Meet at the station');
|
||||
});
|
||||
|
||||
it('SHARE-012 — share_collab=true includes collab messages in response', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
testDb.prepare('INSERT INTO collab_messages (trip_id, user_id, text, deleted) VALUES (?, ?, ?, 0)').run(trip.id, user.id, 'Hello team!');
|
||||
|
||||
const create = await request(app)
|
||||
.post(`/api/trips/${trip.id}/share-link`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ share_collab: true });
|
||||
const token = create.body.token;
|
||||
|
||||
const res = await request(app).get(`/api/shared/${token}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(Array.isArray(res.body.collab)).toBe(true);
|
||||
expect(res.body.collab).toHaveLength(1);
|
||||
expect(res.body.collab[0].text).toBe('Hello team!');
|
||||
});
|
||||
|
||||
it('SHARE-013 — assignments empty when days have no assignments', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
createDay(testDb, trip.id, { date: '2025-08-01' });
|
||||
|
||||
const create = await request(app)
|
||||
.post(`/api/trips/${trip.id}/share-link`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({});
|
||||
const token = create.body.token;
|
||||
|
||||
const res = await request(app).get(`/api/shared/${token}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.days).toHaveLength(1);
|
||||
expect(res.body.assignments).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* Tags integration tests — TAG-001 through TAG-010.
|
||||
* Covers GET/POST/PUT/DELETE /api/tags.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import type { Application } from 'express';
|
||||
|
||||
const { testDb, dbMock } = vi.hoisted(() => {
|
||||
const Database = require('better-sqlite3');
|
||||
const db = new Database(':memory:');
|
||||
db.exec('PRAGMA journal_mode = WAL');
|
||||
db.exec('PRAGMA foreign_keys = ON');
|
||||
db.exec('PRAGMA busy_timeout = 5000');
|
||||
const mock = {
|
||||
db,
|
||||
closeDb: () => {},
|
||||
reinitialize: () => {},
|
||||
getPlaceWithTags: () => null,
|
||||
canAccessTrip: (tripId: any, userId: number) =>
|
||||
db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
|
||||
isOwner: (tripId: any, userId: number) =>
|
||||
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
|
||||
};
|
||||
return { testDb: db, dbMock: mock };
|
||||
});
|
||||
|
||||
vi.mock('../../src/db/database', () => dbMock);
|
||||
vi.mock('../../src/config', () => ({
|
||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||
updateJwtSecret: () => {},
|
||||
}));
|
||||
|
||||
import { createApp } from '../../src/app';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
import { createUser } from '../helpers/factories';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
|
||||
const app: Application = createApp();
|
||||
|
||||
beforeAll(() => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
loginAttempts.clear();
|
||||
mfaAttempts.clear();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
describe('Tags', () => {
|
||||
it('TAG-001: GET /api/tags returns empty array for new user', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const res = await request(app)
|
||||
.get('/api/tags')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.tags).toEqual([]);
|
||||
});
|
||||
|
||||
it('TAG-002: POST /api/tags creates a tag with default color', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const res = await request(app)
|
||||
.post('/api/tags')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ name: 'Must See' });
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.tag).toMatchObject({ name: 'Must See', user_id: user.id });
|
||||
expect(res.body.tag.id).toBeDefined();
|
||||
expect(res.body.tag.color).toBe('#10b981'); // default color
|
||||
});
|
||||
|
||||
it('TAG-003: POST /api/tags creates a tag with a custom color', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const res = await request(app)
|
||||
.post('/api/tags')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ name: 'Foodie', color: '#f59e0b' });
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.tag.color).toBe('#f59e0b');
|
||||
});
|
||||
|
||||
it('TAG-004: POST /api/tags without name returns 400', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const res = await request(app)
|
||||
.post('/api/tags')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ color: '#ff0000' });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toBeDefined();
|
||||
});
|
||||
|
||||
it('TAG-005: PUT /api/tags/:id updates tag name and color', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const createRes = await request(app)
|
||||
.post('/api/tags')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ name: 'Old Name', color: '#aaaaaa' });
|
||||
const tagId = createRes.body.tag.id;
|
||||
|
||||
const res = await request(app)
|
||||
.put(`/api/tags/${tagId}`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ name: 'New Name', color: '#bbbbbb' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.tag.name).toBe('New Name');
|
||||
expect(res.body.tag.color).toBe('#bbbbbb');
|
||||
});
|
||||
|
||||
it('TAG-006: PUT /api/tags/:id - tag belonging to another user returns 404', async () => {
|
||||
const { user: userA } = createUser(testDb);
|
||||
const { user: userB } = createUser(testDb);
|
||||
const createRes = await request(app)
|
||||
.post('/api/tags')
|
||||
.set('Cookie', authCookie(userA.id))
|
||||
.send({ name: 'User A Tag' });
|
||||
const tagId = createRes.body.tag.id;
|
||||
|
||||
// User B tries to update User A's tag
|
||||
const res = await request(app)
|
||||
.put(`/api/tags/${tagId}`)
|
||||
.set('Cookie', authCookie(userB.id))
|
||||
.send({ name: 'Hijacked' });
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('TAG-007: DELETE /api/tags/:id removes the tag', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const createRes = await request(app)
|
||||
.post('/api/tags')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ name: 'To Delete' });
|
||||
const tagId = createRes.body.tag.id;
|
||||
|
||||
const res = await request(app)
|
||||
.delete(`/api/tags/${tagId}`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
|
||||
// Verify gone
|
||||
const listRes = await request(app)
|
||||
.get('/api/tags')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(listRes.body.tags).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('TAG-008: DELETE /api/tags/:id - tag belonging to another user returns 404', async () => {
|
||||
const { user: userA } = createUser(testDb);
|
||||
const { user: userB } = createUser(testDb);
|
||||
const createRes = await request(app)
|
||||
.post('/api/tags')
|
||||
.set('Cookie', authCookie(userA.id))
|
||||
.send({ name: 'User A Tag' });
|
||||
const tagId = createRes.body.tag.id;
|
||||
|
||||
const res = await request(app)
|
||||
.delete(`/api/tags/${tagId}`)
|
||||
.set('Cookie', authCookie(userB.id));
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('TAG-009: Tags are user-scoped — user A cannot see user B tags', async () => {
|
||||
const { user: userA } = createUser(testDb);
|
||||
const { user: userB } = createUser(testDb);
|
||||
await request(app)
|
||||
.post('/api/tags')
|
||||
.set('Cookie', authCookie(userA.id))
|
||||
.send({ name: 'User A Private Tag' });
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/tags')
|
||||
.set('Cookie', authCookie(userB.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.tags).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('TAG-010: Unauthenticated request returns 401', async () => {
|
||||
const res = await request(app).get('/api/tags');
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,321 @@
|
||||
/**
|
||||
* Todo integration tests — TODO-001 through TODO-012.
|
||||
* Covers all endpoints at /api/trips/:tripId/todo.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import type { Application } from 'express';
|
||||
|
||||
const { testDb, dbMock } = vi.hoisted(() => {
|
||||
const Database = require('better-sqlite3');
|
||||
const db = new Database(':memory:');
|
||||
db.exec('PRAGMA journal_mode = WAL');
|
||||
db.exec('PRAGMA foreign_keys = ON');
|
||||
db.exec('PRAGMA busy_timeout = 5000');
|
||||
const mock = {
|
||||
db,
|
||||
closeDb: () => {},
|
||||
reinitialize: () => {},
|
||||
getPlaceWithTags: () => null,
|
||||
canAccessTrip: (tripId: any, userId: number) =>
|
||||
db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
|
||||
isOwner: (tripId: any, userId: number) =>
|
||||
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
|
||||
};
|
||||
return { testDb: db, dbMock: mock };
|
||||
});
|
||||
|
||||
vi.mock('../../src/db/database', () => dbMock);
|
||||
vi.mock('../../src/config', () => ({
|
||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||
updateJwtSecret: () => {},
|
||||
}));
|
||||
|
||||
import { createApp } from '../../src/app';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
import { createUser, createTrip, addTripMember } from '../helpers/factories';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
import { invalidatePermissionsCache } from '../../src/services/permissions';
|
||||
|
||||
const app: Application = createApp();
|
||||
|
||||
beforeAll(() => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
loginAttempts.clear();
|
||||
mfaAttempts.clear();
|
||||
invalidatePermissionsCache();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
describe('Todo items', () => {
|
||||
it('TODO-001: GET /api/trips/:id/todo returns empty items for a new trip', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const res = await request(app)
|
||||
.get(`/api/trips/${trip.id}/todo`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.items).toEqual([]);
|
||||
});
|
||||
|
||||
it('TODO-002: POST /api/trips/:id/todo creates a todo with title only', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const res = await request(app)
|
||||
.post(`/api/trips/${trip.id}/todo`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ name: 'Book hotel' });
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.item).toMatchObject({ name: 'Book hotel', checked: 0, trip_id: trip.id });
|
||||
expect(res.body.item.id).toBeDefined();
|
||||
});
|
||||
|
||||
it('TODO-003: POST /api/trips/:id/todo creates a todo with all optional fields', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const res = await request(app)
|
||||
.post(`/api/trips/${trip.id}/todo`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({
|
||||
name: 'Pack suitcase',
|
||||
category: 'Preparation',
|
||||
description: 'Pack everything for the trip',
|
||||
priority: 2,
|
||||
});
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.item).toMatchObject({
|
||||
name: 'Pack suitcase',
|
||||
category: 'Preparation',
|
||||
description: 'Pack everything for the trip',
|
||||
priority: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it('TODO-004: POST /api/trips/:id/todo - missing name returns 400', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const res = await request(app)
|
||||
.post(`/api/trips/${trip.id}/todo`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ category: 'Test' });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toBeDefined();
|
||||
});
|
||||
|
||||
it('TODO-005: PUT /api/trips/:id/todo/:todoId toggles checked status', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const createRes = await request(app)
|
||||
.post(`/api/trips/${trip.id}/todo`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ name: 'Visit museum' });
|
||||
const itemId = createRes.body.item.id;
|
||||
|
||||
// Toggle to checked
|
||||
const res = await request(app)
|
||||
.put(`/api/trips/${trip.id}/todo/${itemId}`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ checked: 1 });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.item.checked).toBe(1);
|
||||
|
||||
// Toggle back to unchecked
|
||||
const res2 = await request(app)
|
||||
.put(`/api/trips/${trip.id}/todo/${itemId}`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ checked: 0 });
|
||||
expect(res2.status).toBe(200);
|
||||
expect(res2.body.item.checked).toBe(0);
|
||||
});
|
||||
|
||||
it('TODO-006: PUT /api/trips/:id/todo/:todoId updates category', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const createRes = await request(app)
|
||||
.post(`/api/trips/${trip.id}/todo`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ name: 'Buy souvenirs' });
|
||||
const itemId = createRes.body.item.id;
|
||||
|
||||
const res = await request(app)
|
||||
.put(`/api/trips/${trip.id}/todo/${itemId}`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ category: 'Shopping' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.item.category).toBe('Shopping');
|
||||
});
|
||||
|
||||
it('TODO-007: DELETE /api/trips/:id/todo/:todoId deletes a todo', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const createRes = await request(app)
|
||||
.post(`/api/trips/${trip.id}/todo`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ name: 'To Delete' });
|
||||
const itemId = createRes.body.item.id;
|
||||
|
||||
const res = await request(app)
|
||||
.delete(`/api/trips/${trip.id}/todo/${itemId}`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
|
||||
// Verify gone from list
|
||||
const listRes = await request(app)
|
||||
.get(`/api/trips/${trip.id}/todo`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(listRes.body.items).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('TODO-008: PUT /api/trips/:id/todo/reorder reorders items', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
// Create 3 items
|
||||
const r1 = await request(app)
|
||||
.post(`/api/trips/${trip.id}/todo`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ name: 'First' });
|
||||
const r2 = await request(app)
|
||||
.post(`/api/trips/${trip.id}/todo`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ name: 'Second' });
|
||||
const r3 = await request(app)
|
||||
.post(`/api/trips/${trip.id}/todo`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ name: 'Third' });
|
||||
|
||||
const id1 = r1.body.item.id;
|
||||
const id2 = r2.body.item.id;
|
||||
const id3 = r3.body.item.id;
|
||||
|
||||
// Reverse the order
|
||||
const res = await request(app)
|
||||
.put(`/api/trips/${trip.id}/todo/reorder`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ orderedIds: [id3, id2, id1] });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
|
||||
// Verify the new order in the DB
|
||||
const items = testDb.prepare('SELECT id, sort_order FROM todo_items WHERE trip_id = ? ORDER BY sort_order').all(trip.id) as any[];
|
||||
expect(items[0].id).toBe(id3);
|
||||
expect(items[1].id).toBe(id2);
|
||||
expect(items[2].id).toBe(id1);
|
||||
});
|
||||
|
||||
it('TODO-009: Non-member accessing trip returns 404', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: stranger } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/trips/${trip.id}/todo`)
|
||||
.set('Cookie', authCookie(stranger.id));
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('TODO-010: Trip member can read and create todos', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: member } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
addTripMember(testDb, trip.id, member.id);
|
||||
|
||||
// Member can read
|
||||
const getRes = await request(app)
|
||||
.get(`/api/trips/${trip.id}/todo`)
|
||||
.set('Cookie', authCookie(member.id));
|
||||
expect(getRes.status).toBe(200);
|
||||
|
||||
// Member can create
|
||||
const postRes = await request(app)
|
||||
.post(`/api/trips/${trip.id}/todo`)
|
||||
.set('Cookie', authCookie(member.id))
|
||||
.send({ name: 'Member task' });
|
||||
expect(postRes.status).toBe(201);
|
||||
});
|
||||
|
||||
it('TODO-011: PUT /api/trips/:id/todo/:todoId - non-existent item returns 404', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const res = await request(app)
|
||||
.put(`/api/trips/${trip.id}/todo/99999`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ name: 'Ghost' });
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('TODO-012: GET /api/trips/:id/todo - unauthenticated returns 401', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const res = await request(app).get(`/api/trips/${trip.id}/todo`);
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Todo category assignees', () => {
|
||||
it('TODO-013: GET /api/trips/:id/todo/category-assignees returns empty object for new trip', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const res = await request(app)
|
||||
.get(`/api/trips/${trip.id}/todo/category-assignees`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.assignees).toEqual({});
|
||||
});
|
||||
|
||||
it('TODO-014: PUT /api/trips/:id/todo/category-assignees/:name sets assignees', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: member } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
addTripMember(testDb, trip.id, member.id);
|
||||
|
||||
const res = await request(app)
|
||||
.put(`/api/trips/${trip.id}/todo/category-assignees/Shopping`)
|
||||
.set('Cookie', authCookie(owner.id))
|
||||
.send({ user_ids: [owner.id, member.id] });
|
||||
expect(res.status).toBe(200);
|
||||
expect(Array.isArray(res.body.assignees)).toBe(true);
|
||||
expect(res.body.assignees).toHaveLength(2);
|
||||
|
||||
// Verify via GET
|
||||
const getRes = await request(app)
|
||||
.get(`/api/trips/${trip.id}/todo/category-assignees`)
|
||||
.set('Cookie', authCookie(owner.id));
|
||||
expect(getRes.body.assignees.Shopping).toBeDefined();
|
||||
expect(getRes.body.assignees.Shopping).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('TODO-015: PUT category-assignees with empty array clears assignees', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: member } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
addTripMember(testDb, trip.id, member.id);
|
||||
|
||||
// Set assignees
|
||||
await request(app)
|
||||
.put(`/api/trips/${trip.id}/todo/category-assignees/Shopping`)
|
||||
.set('Cookie', authCookie(owner.id))
|
||||
.send({ user_ids: [owner.id] });
|
||||
|
||||
// Clear them
|
||||
const res = await request(app)
|
||||
.put(`/api/trips/${trip.id}/todo/category-assignees/Shopping`)
|
||||
.set('Cookie', authCookie(owner.id))
|
||||
.send({ user_ids: [] });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.assignees).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -49,7 +49,7 @@ import { createApp } from '../../src/app';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
import { createUser, createAdmin, createTrip, addTripMember, createPlace, createReservation } from '../helpers/factories';
|
||||
import { createUser, createAdmin, createTrip, addTripMember, createPlace, createReservation, createTag, createDayAccommodation, createBudgetItem, createPackingItem, createDayNote } from '../helpers/factories';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
import { invalidatePermissionsCache } from '../../src/services/permissions';
|
||||
@@ -291,17 +291,6 @@ describe('Get trip', () => {
|
||||
expect(res.body.error).toMatch(/not found/i);
|
||||
});
|
||||
|
||||
it('TRIP-016 — Non-member cannot access trip → 404', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: nonMember } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id, { title: 'Private Trip' });
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/trips/${trip.id}`)
|
||||
.set('Cookie', authCookie(nonMember.id));
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('TRIP-017 — Member can access trip → 200', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
@@ -694,3 +683,212 @@ describe('Trip members', () => {
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Copy trip (TRIP-023, TRIP-024)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Copy trip', () => {
|
||||
it('TRIP-023 — POST /api/trips/:id/copy creates a duplicate trip with 201', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, { title: 'Original Trip', description: 'Desc' });
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/trips/${trip.id}/copy`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({});
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.trip).toBeDefined();
|
||||
expect(res.body.trip.id).not.toBe(trip.id);
|
||||
expect(res.body.trip.title).toBe('Original Trip');
|
||||
});
|
||||
|
||||
it('TRIP-023 — copy accepts a custom title for the new trip', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, { title: 'Source' });
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/trips/${trip.id}/copy`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ title: 'Custom Copy' });
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.trip.title).toBe('Custom Copy');
|
||||
});
|
||||
|
||||
it('TRIP-023 — copied trip belongs to the requesting user', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: member } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id, { title: 'Shared Trip' });
|
||||
addTripMember(testDb, trip.id, member.id);
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/trips/${trip.id}/copy`)
|
||||
.set('Cookie', authCookie(member.id))
|
||||
.send({});
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
const newTrip = testDb.prepare('SELECT * FROM trips WHERE id = ?').get(res.body.trip.id) as any;
|
||||
expect(newTrip.user_id).toBe(member.id);
|
||||
});
|
||||
|
||||
it('TRIP-024 — non-member cannot copy a trip → 404', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: stranger } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id, { title: 'Private Trip' });
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/trips/${trip.id}/copy`)
|
||||
.set('Cookie', authCookie(stranger.id))
|
||||
.send({});
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('TRIP-024 — copy of non-existent trip returns 404', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/trips/999999/copy')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({});
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// ICS export (TRIP-025)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('ICS export', () => {
|
||||
it('TRIP-025 — GET /api/trips/:id/export.ics returns text/calendar content', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, { title: 'Calendar Trip' });
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/trips/${trip.id}/export.ics`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers['content-type']).toMatch(/text\/calendar/);
|
||||
expect(res.text).toContain('BEGIN:VCALENDAR');
|
||||
expect(res.text).toContain('END:VCALENDAR');
|
||||
});
|
||||
|
||||
it('TRIP-025 — non-member cannot export ICS → 404', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: stranger } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id, { title: 'Private Trip' });
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/trips/${trip.id}/export.ics`)
|
||||
.set('Cookie', authCookie(stranger.id));
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('TRIP-025 — unauthenticated export returns 401', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, { title: 'Trip' });
|
||||
|
||||
const res = await request(app).get(`/api/trips/${trip.id}/export.ics`);
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Copy trip with full data (covers loop bodies in the copy transaction)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Copy trip with data', () => {
|
||||
it('TRIP-026 — copy preserves days, places, tags, assignments, accommodations, reservations, budget, packing, notes', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, {
|
||||
title: 'Data-Rich Trip',
|
||||
start_date: '2025-09-01',
|
||||
end_date: '2025-09-03',
|
||||
});
|
||||
|
||||
const days = testDb.prepare('SELECT * FROM days WHERE trip_id = ? ORDER BY day_number').all(trip.id) as any[];
|
||||
expect(days.length).toBe(3);
|
||||
|
||||
// Place with a tag
|
||||
const place = createPlace(testDb, trip.id, { name: 'Tower Bridge' });
|
||||
const tag = createTag(testDb, user.id, { name: 'Landmark' });
|
||||
testDb.prepare('INSERT INTO place_tags (place_id, tag_id) VALUES (?, ?)').run(place.id, tag.id);
|
||||
|
||||
// Day assignment
|
||||
testDb.prepare(
|
||||
'INSERT INTO day_assignments (day_id, place_id, order_index, notes) VALUES (?, ?, 0, ?)'
|
||||
).run(days[0].id, place.id, 'Visit in morning');
|
||||
|
||||
// Accommodation spanning days 0→1
|
||||
createDayAccommodation(testDb, trip.id, place.id, days[0].id, days[1].id);
|
||||
|
||||
// Reservation on day 0
|
||||
createReservation(testDb, trip.id, { title: 'Flight Out', type: 'flight', day_id: days[0].id });
|
||||
|
||||
// Budget item
|
||||
createBudgetItem(testDb, trip.id, { name: 'Flights', total_price: 400 });
|
||||
|
||||
// Packing item
|
||||
createPackingItem(testDb, trip.id, { name: 'Toothbrush' });
|
||||
|
||||
// Day note
|
||||
createDayNote(testDb, days[0].id, trip.id, { text: 'Pack early!' });
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/trips/${trip.id}/copy`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ title: 'Data-Rich Trip (Copy)' });
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
const newId = res.body.trip.id;
|
||||
expect(newId).not.toBe(trip.id);
|
||||
|
||||
// Days copied
|
||||
const newDays = testDb.prepare('SELECT * FROM days WHERE trip_id = ? ORDER BY day_number').all(newId) as any[];
|
||||
expect(newDays).toHaveLength(3);
|
||||
|
||||
// Place copied
|
||||
const newPlaces = testDb.prepare('SELECT * FROM places WHERE trip_id = ?').all(newId) as any[];
|
||||
expect(newPlaces).toHaveLength(1);
|
||||
expect(newPlaces[0].name).toBe('Tower Bridge');
|
||||
|
||||
// Place tag copied
|
||||
const newTags = testDb.prepare(
|
||||
'SELECT pt.* FROM place_tags pt JOIN places p ON p.id = pt.place_id WHERE p.trip_id = ?'
|
||||
).all(newId) as any[];
|
||||
expect(newTags).toHaveLength(1);
|
||||
|
||||
// Assignment copied
|
||||
const newAssignments = testDb.prepare(
|
||||
'SELECT da.* FROM day_assignments da JOIN days d ON d.id = da.day_id WHERE d.trip_id = ?'
|
||||
).all(newId) as any[];
|
||||
expect(newAssignments).toHaveLength(1);
|
||||
|
||||
// Accommodation copied
|
||||
const newAccom = testDb.prepare('SELECT * FROM day_accommodations WHERE trip_id = ?').all(newId) as any[];
|
||||
expect(newAccom).toHaveLength(1);
|
||||
|
||||
// Reservation copied
|
||||
const newResv = testDb.prepare('SELECT * FROM reservations WHERE trip_id = ?').all(newId) as any[];
|
||||
expect(newResv).toHaveLength(1);
|
||||
|
||||
// Budget copied
|
||||
const newBudget = testDb.prepare('SELECT * FROM budget_items WHERE trip_id = ?').all(newId) as any[];
|
||||
expect(newBudget).toHaveLength(1);
|
||||
|
||||
// Packing copied (checked reset to 0)
|
||||
const newPacking = testDb.prepare('SELECT * FROM packing_items WHERE trip_id = ?').all(newId) as any[];
|
||||
expect(newPacking).toHaveLength(1);
|
||||
expect(newPacking[0].checked).toBe(0);
|
||||
|
||||
// Day note copied
|
||||
const newNotes = testDb.prepare('SELECT * FROM day_notes WHERE trip_id = ?').all(newId) as any[];
|
||||
expect(newNotes).toHaveLength(1);
|
||||
expect(newNotes[0].text).toBe('Pack early!');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -303,3 +303,230 @@ describe('Vacay dissolve plan', () => {
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Vacay holiday calendar CRUD', () => {
|
||||
it('VACAY-026 — PUT /plan/holiday-calendars/:id updates an existing calendar', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
await request(app).get('/api/addons/vacay/plan').set('Cookie', authCookie(user.id));
|
||||
|
||||
// Create a calendar first
|
||||
const createRes = await request(app)
|
||||
.post('/api/addons/vacay/plan/holiday-calendars')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ region: 'US', label: 'US Holidays' });
|
||||
expect(createRes.status).toBe(200);
|
||||
const calId = createRes.body.plan?.holiday_calendars?.at(-1)?.id
|
||||
?? (testDb.prepare('SELECT id FROM vacay_holiday_calendars ORDER BY id DESC LIMIT 1').get() as any)?.id;
|
||||
|
||||
const res = await request(app)
|
||||
.put(`/api/addons/vacay/plan/holiday-calendars/${calId}`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ label: 'Updated Label', color: '#ff0000' });
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it('VACAY-027 — DELETE /plan/holiday-calendars/:id removes the calendar', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
await request(app).get('/api/addons/vacay/plan').set('Cookie', authCookie(user.id));
|
||||
|
||||
const createRes = await request(app)
|
||||
.post('/api/addons/vacay/plan/holiday-calendars')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ region: 'FR', label: 'French Holidays' });
|
||||
expect(createRes.status).toBe(200);
|
||||
const calId = (testDb.prepare('SELECT id FROM vacay_holiday_calendars ORDER BY id DESC LIMIT 1').get() as any)?.id;
|
||||
|
||||
const res = await request(app)
|
||||
.delete(`/api/addons/vacay/plan/holiday-calendars/${calId}`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
});
|
||||
|
||||
it('VACAY-027b — DELETE /plan/holiday-calendars/:id non-existent returns 404', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
await request(app).get('/api/addons/vacay/plan').set('Cookie', authCookie(user.id));
|
||||
|
||||
const res = await request(app)
|
||||
.delete('/api/addons/vacay/plan/holiday-calendars/99999')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Vacay invite full flow', () => {
|
||||
it('VACAY-028 — POST /invite/accept joins the invitee to the owner plan', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: invitee } = createUser(testDb);
|
||||
|
||||
// Owner creates plan
|
||||
const planRes = await request(app).get('/api/addons/vacay/plan').set('Cookie', authCookie(owner.id));
|
||||
const planId = planRes.body.plan.id;
|
||||
|
||||
// Owner invites invitee
|
||||
await request(app)
|
||||
.post('/api/addons/vacay/invite')
|
||||
.set('Cookie', authCookie(owner.id))
|
||||
.send({ user_id: invitee.id });
|
||||
|
||||
// Invitee accepts
|
||||
const res = await request(app)
|
||||
.post('/api/addons/vacay/invite/accept')
|
||||
.set('Cookie', authCookie(invitee.id))
|
||||
.send({ plan_id: planId });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
});
|
||||
|
||||
it('VACAY-029 — POST /invite/decline removes the pending invite', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: invitee } = createUser(testDb);
|
||||
|
||||
const planRes = await request(app).get('/api/addons/vacay/plan').set('Cookie', authCookie(owner.id));
|
||||
const planId = planRes.body.plan.id;
|
||||
|
||||
await request(app)
|
||||
.post('/api/addons/vacay/invite')
|
||||
.set('Cookie', authCookie(owner.id))
|
||||
.send({ user_id: invitee.id });
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/addons/vacay/invite/decline')
|
||||
.set('Cookie', authCookie(invitee.id))
|
||||
.send({ plan_id: planId });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
});
|
||||
|
||||
it('VACAY-030 — POST /invite/cancel removes the pending invite from owner side', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: invitee } = createUser(testDb);
|
||||
|
||||
await request(app).get('/api/addons/vacay/plan').set('Cookie', authCookie(owner.id));
|
||||
|
||||
await request(app)
|
||||
.post('/api/addons/vacay/invite')
|
||||
.set('Cookie', authCookie(owner.id))
|
||||
.send({ user_id: invitee.id });
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/addons/vacay/invite/cancel')
|
||||
.set('Cookie', authCookie(owner.id))
|
||||
.send({ user_id: invitee.id });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Vacay company holidays', () => {
|
||||
it('VACAY-032 — POST /entries/company-holiday toggles a company holiday', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
await request(app).get('/api/addons/vacay/plan').set('Cookie', authCookie(user.id));
|
||||
await request(app).post('/api/addons/vacay/years').set('Cookie', authCookie(user.id)).send({ year: 2025 });
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/addons/vacay/entries/company-holiday')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ date: '2025-12-25', note: 'Christmas' });
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it('VACAY-033 — POST /entries/toggle with target_user_id not in plan returns 403', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: outsider } = createUser(testDb);
|
||||
await request(app).get('/api/addons/vacay/plan').set('Cookie', authCookie(owner.id));
|
||||
await request(app).post('/api/addons/vacay/years').set('Cookie', authCookie(owner.id)).send({ year: 2025 });
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/addons/vacay/entries/toggle')
|
||||
.set('Cookie', authCookie(owner.id))
|
||||
.send({ date: '2025-07-14', target_user_id: outsider.id });
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Vacay stats restrictions', () => {
|
||||
it('VACAY-034 — PUT /stats/:year for user not in plan returns 403', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: outsider } = createUser(testDb);
|
||||
await request(app).get('/api/addons/vacay/plan').set('Cookie', authCookie(owner.id));
|
||||
await request(app).post('/api/addons/vacay/years').set('Cookie', authCookie(owner.id)).send({ year: 2025 });
|
||||
|
||||
const res = await request(app)
|
||||
.put('/api/addons/vacay/stats/2025')
|
||||
.set('Cookie', authCookie(owner.id))
|
||||
.send({ vacation_days: 25, target_user_id: outsider.id });
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Vacay holidays error path', () => {
|
||||
it('VACAY-035 — GET /holidays/:year/:country returns 502 when external API fetch fails', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
// Use an unusual country/year to avoid cache hits from other tests
|
||||
vi.mocked(global.fetch as any).mockRejectedValueOnce(new Error('Network error'));
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/addons/vacay/holidays/2099/ZZ')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(502);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Vacay color restriction', () => {
|
||||
it('VACAY-036 — PUT /color with target_user_id not in plan returns 403', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: outsider } = createUser(testDb);
|
||||
await request(app).get('/api/addons/vacay/plan').set('Cookie', authCookie(owner.id));
|
||||
|
||||
const res = await request(app)
|
||||
.put('/api/addons/vacay/color')
|
||||
.set('Cookie', authCookie(owner.id))
|
||||
.send({ color: '#ff0000', target_user_id: outsider.id });
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Vacay holidays success path', () => {
|
||||
it('VACAY-037 — GET /holidays/:year/:country returns data when fetch succeeds', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
// Use unique year/country to avoid cache from other tests
|
||||
vi.mocked(global.fetch as any).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve([{ date: '2025-05-01', name: 'Labour Day', countryCode: 'AT' }]),
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/addons/vacay/holidays/2025/AT')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Vacay toggle entry for plan member', () => {
|
||||
it('VACAY-038 — POST /entries/toggle with target_user_id in plan toggles their entry', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: invitee } = createUser(testDb);
|
||||
|
||||
const planRes = await request(app).get('/api/addons/vacay/plan').set('Cookie', authCookie(owner.id));
|
||||
const planId = planRes.body.plan.id;
|
||||
await request(app).post('/api/addons/vacay/years').set('Cookie', authCookie(owner.id)).send({ year: 2025 });
|
||||
|
||||
// Invite and accept so invitee is in the plan
|
||||
await request(app)
|
||||
.post('/api/addons/vacay/invite')
|
||||
.set('Cookie', authCookie(owner.id))
|
||||
.send({ user_id: invitee.id });
|
||||
await request(app)
|
||||
.post('/api/addons/vacay/invite/accept')
|
||||
.set('Cookie', authCookie(invitee.id))
|
||||
.send({ plan_id: planId });
|
||||
|
||||
// Owner toggles an entry for the invitee (who is now in the plan)
|
||||
const res = await request(app)
|
||||
.post('/api/addons/vacay/entries/toggle')
|
||||
.set('Cookie', authCookie(owner.id))
|
||||
.send({ date: '2025-06-10', target_user_id: invitee.id });
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -153,4 +153,110 @@ describe('Weather with mocked API', () => {
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toHaveProperty('temp');
|
||||
});
|
||||
|
||||
it('WEATHER-007 — GET /weather returns 500 on non-ok API response (ApiError path)', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
// Use unique coords to avoid cache from previous tests
|
||||
vi.mocked(global.fetch as any).mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 503,
|
||||
json: () => Promise.resolve({ error: true, reason: 'Service unavailable' }),
|
||||
});
|
||||
const futureDate = new Date();
|
||||
futureDate.setDate(futureDate.getDate() + 3);
|
||||
const dateStr = futureDate.toISOString().slice(0, 10);
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/weather?lat=55.0&lng=25.0&date=${dateStr}`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(503);
|
||||
expect(res.body).toHaveProperty('error');
|
||||
});
|
||||
|
||||
it('WEATHER-008 — GET /weather returns 500 on network error (generic error path)', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
vi.mocked(global.fetch as any).mockRejectedValueOnce(new Error('Network error'));
|
||||
const futureDate = new Date();
|
||||
futureDate.setDate(futureDate.getDate() + 4);
|
||||
const dateStr = futureDate.toISOString().slice(0, 10);
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/weather?lat=56.0&lng=26.0&date=${dateStr}`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(500);
|
||||
expect(res.body).toHaveProperty('error');
|
||||
});
|
||||
|
||||
it('WEATHER-009 — GET /weather/detailed returns detailed weather data', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const futureDate = new Date();
|
||||
futureDate.setDate(futureDate.getDate() + 2);
|
||||
const dateStr = futureDate.toISOString().slice(0, 10);
|
||||
|
||||
// Override mock with full detailed forecast response
|
||||
vi.mocked(global.fetch as any).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({
|
||||
daily: {
|
||||
time: [dateStr],
|
||||
temperature_2m_max: [24],
|
||||
temperature_2m_min: [16],
|
||||
weathercode: [1],
|
||||
precipitation_sum: [0],
|
||||
windspeed_10m_max: [12],
|
||||
sunrise: [`${dateStr}T06:00`],
|
||||
sunset: [`${dateStr}T21:00`],
|
||||
precipitation_probability_max: [10],
|
||||
},
|
||||
hourly: {
|
||||
time: [`${dateStr}T12:00`],
|
||||
temperature_2m: [20],
|
||||
precipitation_probability: [5],
|
||||
precipitation: [0],
|
||||
weathercode: [1],
|
||||
windspeed_10m: [10],
|
||||
relativehumidity_2m: [55],
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/weather/detailed?lat=50.0&lng=10.0&date=${dateStr}`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toHaveProperty('temp');
|
||||
expect(res.body.type).toBe('forecast');
|
||||
});
|
||||
|
||||
it('WEATHER-010 — GET /weather/detailed returns error status on ApiError', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
vi.mocked(global.fetch as any).mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 502,
|
||||
json: () => Promise.resolve({ error: true, reason: 'Bad Gateway' }),
|
||||
});
|
||||
const futureDate = new Date();
|
||||
futureDate.setDate(futureDate.getDate() + 6);
|
||||
const dateStr = futureDate.toISOString().slice(0, 10);
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/weather/detailed?lat=57.0&lng=27.0&date=${dateStr}`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(502);
|
||||
expect(res.body).toHaveProperty('error');
|
||||
});
|
||||
|
||||
it('WEATHER-011 — GET /weather/detailed returns 500 on network error', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
vi.mocked(global.fetch as any).mockRejectedValueOnce(new Error('Network error'));
|
||||
const futureDate = new Date();
|
||||
futureDate.setDate(futureDate.getDate() + 7);
|
||||
const dateStr = futureDate.toISOString().slice(0, 10);
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/weather/detailed?lat=58.0&lng=28.0&date=${dateStr}`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(500);
|
||||
expect(res.body).toHaveProperty('error');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user