const assert = require('assert'); const supertest = require('supertest'); let app, request, db; // Simple GPX with a track const TEST_GPX = ` Test Track 10 12 14 `; before(async () => { // Reset sequelize singleton for in-memory SQLite const dbModule = require('../src/database'); await dbModule.initDatabase(); app = require('../src/app').createApp(global.testConfig); request = supertest(app); }); // ─── Health ─────────────────────────────────────────────────────────────────── describe('GET /health', () => { it('returns ok', async () => { const res = await request.get('/health').expect(200); assert.strictEqual(res.body.ok, true); }); }); // ─── Auth ───────────────────────────────────────────────────────────────────── describe('Auth', () => { let adminToken, user2Token; describe('POST /api/auth/register', () => { it('first user becomes admin and is active', async () => { const res = await request.post('/api/auth/register') .send({ login: 'testadmin', password: 'admin123' }) .expect(201); assert.strictEqual(res.body.login, 'testadmin'); assert.strictEqual(res.body.isAdmin, true); assert.strictEqual(res.body.isActive, true); }); it('rejects duplicate login', async () => { await request.post('/api/auth/register') .send({ login: 'testadmin', password: 'pass123456' }) .expect(409); }); it('rejects invalid login format', async () => { await request.post('/api/auth/register') .send({ login: 'a b', password: 'pass123' }) .expect(400); }); it('rejects short password', async () => { await request.post('/api/auth/register') .send({ login: 'validname', password: 'x' }) .expect(400); }); it('second user is not active', async () => { const res = await request.post('/api/auth/register') .send({ login: 'testuser2', password: 'pass123' }) .expect(201); assert.strictEqual(res.body.isAdmin, false); assert.strictEqual(res.body.isActive, false); }); }); describe('POST /api/auth/login', () => { it('admin can login', async () => { const res = await request.post('/api/auth/login') .send({ login: 'testadmin', password: 'admin123' }) .expect(200); assert.ok(res.body.token); adminToken = res.body.token; }); it('inactive user cannot login', async () => { await request.post('/api/auth/login') .send({ login: 'testuser2', password: 'pass123' }) .expect(403); }); it('rejects wrong password', async () => { await request.post('/api/auth/login') .send({ login: 'testadmin', password: 'wrong' }) .expect(401); }); it('rejects unknown user', async () => { await request.post('/api/auth/login') .send({ login: 'nobody', password: 'pass' }) .expect(401); }); }); describe('GET /api/auth/me', () => { it('returns current user when authenticated', async () => { const res = await request.get('/api/auth/me') .set('Authorization', `Bearer ${adminToken}`) .expect(200); assert.strictEqual(res.body.login, 'testadmin'); }); it('returns 401 without token', async () => { await request.get('/api/auth/me').expect(401); }); }); // Store token for use in later tests after(() => { global.adminToken = adminToken; }); }); // ─── Admin ──────────────────────────────────────────────────────────────────── describe('Admin', () => { it('activates second user', async () => { // Find user2's id const users = await request.get('/api/admin/users') .set('Authorization', `Bearer ${global.adminToken}`) .expect(200); const user2 = users.body.find(u => u.login === 'testuser2'); assert.ok(user2); await request.put(`/api/admin/users/${user2.id}/activate`) .set('Authorization', `Bearer ${global.adminToken}`) .send({ isActive: true }) .expect(200); // user2 can now login const res = await request.post('/api/auth/login') .send({ login: 'testuser2', password: 'pass123' }) .expect(200); global.user2Token = res.body.token; }); it('lists all users', async () => { const res = await request.get('/api/admin/users') .set('Authorization', `Bearer ${global.adminToken}`) .expect(200); assert.ok(Array.isArray(res.body)); assert.ok(res.body.length >= 2); assert.ok(res.body.every(u => 'trackCount' in u)); }); it('rejects non-admin access', async () => { await request.get('/api/admin/users') .set('Authorization', `Bearer ${global.user2Token}`) .expect(403); }); }); // ─── Directories ────────────────────────────────────────────────────────────── describe('Directories', () => { let dirId; it('creates a root directory', async () => { const res = await request.post('/api/directories') .set('Authorization', `Bearer ${global.adminToken}`) .send({ name: 'My Rides' }) .expect(201); assert.strictEqual(res.body.name, 'My Rides'); assert.strictEqual(res.body.parentId, null); dirId = res.body.id; global.testDirId = dirId; }); it('creates a subdirectory', async () => { const res = await request.post('/api/directories') .set('Authorization', `Bearer ${global.adminToken}`) .send({ name: 'Summer 2024', parentId: dirId }) .expect(201); assert.strictEqual(res.body.parentId, dirId); global.subDirId = res.body.id; }); it('lists root directories', async () => { const res = await request.get('/api/directories') .set('Authorization', `Bearer ${global.adminToken}`) .expect(200); assert.ok(Array.isArray(res.body)); assert.ok(res.body.some(d => d.name === 'My Rides')); }); it('gets directory with children', async () => { const res = await request.get(`/api/directories/${dirId}`) .set('Authorization', `Bearer ${global.adminToken}`) .expect(200); assert.ok(Array.isArray(res.body.children)); assert.ok(res.body.children.some(c => c.name === 'Summer 2024')); }); it('renames directory', async () => { await request.put(`/api/directories/${dirId}`) .set('Authorization', `Bearer ${global.adminToken}`) .send({ name: 'All Rides' }) .expect(200); const res = await request.get(`/api/directories/${dirId}`) .set('Authorization', `Bearer ${global.adminToken}`); assert.strictEqual(res.body.name, 'All Rides'); }); it('prevents accessing other users directories', async () => { await request.get(`/api/directories/${dirId}`) .set('Authorization', `Bearer ${global.user2Token}`) .expect(404); }); }); // ─── Tracks ─────────────────────────────────────────────────────────────────── describe('Tracks', () => { let trackId; it('uploads a GPX file', async () => { const res = await request.post('/api/tracks/upload') .set('Authorization', `Bearer ${global.adminToken}`) .attach('file', Buffer.from(TEST_GPX), { filename: 'test.gpx', contentType: 'application/gpx+xml' }) .field('directoryId', global.testDirId) .expect(201); assert.strictEqual(res.body.name, 'Test Track'); assert.ok(res.body.pointCount > 0); assert.ok(res.body.totalDistance > 0); assert.ok(res.body.trackDate); trackId = res.body.id; global.testTrackId = trackId; }); it('rejects non-GPX upload', async () => { await request.post('/api/tracks/upload') .set('Authorization', `Bearer ${global.adminToken}`) .attach('file', Buffer.from('not gpx'), { filename: 'file.txt', contentType: 'text/plain' }) .expect(400); }); it('lists tracks in directory', async () => { const res = await request.get(`/api/tracks?directoryId=${global.testDirId}`) .set('Authorization', `Bearer ${global.adminToken}`) .expect(200); assert.ok(Array.isArray(res.body)); assert.ok(res.body.some(t => t.id === trackId)); }); it('gets track points in compact format', async () => { const res = await request.get(`/api/tracks/${trackId}/points`) .set('Authorization', `Bearer ${global.adminToken}`) .expect(200); assert.ok(res.body.meta); assert.ok(Array.isArray(res.body.segments)); assert.ok(res.body.segments.length > 0); const pts = res.body.segments[0]; assert.ok(Array.isArray(pts)); assert.ok(pts.length > 0); // Each point is [lat, lon, ele, time] const pt = pts[0]; assert.ok(Array.isArray(pt) && pt.length === 4); assert.ok(typeof pt[0] === 'number'); // lat assert.ok(typeof pt[1] === 'number'); // lon }); it('updates track name and directory', async () => { await request.put(`/api/tracks/${trackId}`) .set('Authorization', `Bearer ${global.adminToken}`) .send({ name: 'Renamed Track', directoryId: global.subDirId }) .expect(200); const res = await request.get(`/api/tracks/${trackId}`) .set('Authorization', `Bearer ${global.adminToken}`); assert.strictEqual(res.body.name, 'Renamed Track'); assert.strictEqual(res.body.directoryId, global.subDirId); }); it('prevents accessing other users tracks', async () => { await request.get(`/api/tracks/${trackId}/points`) .set('Authorization', `Bearer ${global.user2Token}`) .expect(404); }); }); // ─── Share Links ────────────────────────────────────────────────────────────── describe('Share Links', () => { let shareCode; it('creates a share link', async () => { const res = await request.post(`/api/tracks/${global.testTrackId}/share`) .set('Authorization', `Bearer ${global.adminToken}`) .expect(200); assert.ok(res.body.code); assert.match(res.body.code, /^[bcdfghjklmnprstvwxyz][aeiou]{1}[bcdfghjklmnprstvwxyz][aeiou]{1}[bcdfghjklmnprstvwxyz][aeiou]{1}[bcdfghjklmnprstvwxyz][aeiou]{1}[bcdfghjklmnprstvwxyz][aeiou]{1}$/); shareCode = res.body.code; global.shareCode = shareCode; }); it('returns same code on second call (idempotent)', async () => { const res = await request.post(`/api/tracks/${global.testTrackId}/share`) .set('Authorization', `Bearer ${global.adminToken}`) .expect(200); assert.strictEqual(res.body.code, shareCode); }); it('public share endpoint returns track data without auth', async () => { const res = await request.get(`/api/share/${shareCode}`).expect(200); assert.ok(res.body.meta); assert.ok(Array.isArray(res.body.segments)); assert.strictEqual(res.body.meta.name, 'Renamed Track'); }); it('public share returns 404 for unknown code', async () => { await request.get('/api/share/unknownxxx').expect(404); }); it('revokes share link', async () => { await request.delete(`/api/tracks/${global.testTrackId}/share`) .set('Authorization', `Bearer ${global.adminToken}`) .expect(200); await request.get(`/api/share/${shareCode}`).expect(404); }); }); // ─── Stats ──────────────────────────────────────────────────────────────────── describe('Stats', () => { it('returns distance stats by year/month/week', async () => { const res = await request.get('/api/stats') .set('Authorization', `Bearer ${global.adminToken}`) .expect(200); assert.ok(Array.isArray(res.body.byYear)); assert.ok(Array.isArray(res.body.byMonth)); assert.ok(Array.isArray(res.body.byWeek)); // Should have stats for 2024 (track date) assert.ok(res.body.byYear.some(y => y.year === 2024)); }); it('returns directory stats', async () => { const res = await request.get(`/api/stats/directory/${global.subDirId}`) .set('Authorization', `Bearer ${global.adminToken}`) .expect(200); assert.ok(Array.isArray(res.body.tracks)); assert.ok(typeof res.body.totalDistance === 'number'); }); }); // ─── Track Deletion ─────────────────────────────────────────────────────────── describe('Track and Directory Deletion', () => { it('deletes a track', async () => { await request.delete(`/api/tracks/${global.testTrackId}`) .set('Authorization', `Bearer ${global.adminToken}`) .expect(200); await request.get(`/api/tracks/${global.testTrackId}`) .set('Authorization', `Bearer ${global.adminToken}`) .expect(404); }); it('deletes a directory recursively', async () => { // Upload a track to subdir first await request.post('/api/tracks/upload') .set('Authorization', `Bearer ${global.adminToken}`) .attach('file', Buffer.from(TEST_GPX), { filename: 'test2.gpx', contentType: 'application/gpx+xml' }) .field('directoryId', global.subDirId); // Delete parent (should cascade to subdir and its track) await request.delete(`/api/directories/${global.testDirId}`) .set('Authorization', `Bearer ${global.adminToken}`) .expect(200); await request.get(`/api/directories/${global.subDirId}`) .set('Authorization', `Bearer ${global.adminToken}`) .expect(404); }); });