|
@@ -0,0 +1,379 @@
|
|
|
|
|
+const assert = require('assert');
|
|
|
|
|
+const supertest = require('supertest');
|
|
|
|
|
+
|
|
|
|
|
+let app, request, db;
|
|
|
|
|
+
|
|
|
|
|
+// Simple GPX with a track
|
|
|
|
|
+const TEST_GPX = `<?xml version="1.0"?>
|
|
|
|
|
+<gpx version="1.1">
|
|
|
|
|
+ <trk>
|
|
|
|
|
+ <name>Test Track</name>
|
|
|
|
|
+ <trkseg>
|
|
|
|
|
+ <trkpt lat="51.500" lon="-0.100"><ele>10</ele><time>2024-03-01T10:00:00Z</time></trkpt>
|
|
|
|
|
+ <trkpt lat="51.510" lon="-0.100"><ele>12</ele><time>2024-03-01T10:01:00Z</time></trkpt>
|
|
|
|
|
+ <trkpt lat="51.520" lon="-0.100"><ele>14</ele><time>2024-03-01T10:02:00Z</time></trkpt>
|
|
|
|
|
+ </trkseg>
|
|
|
|
|
+ </trk>
|
|
|
|
|
+</gpx>`;
|
|
|
|
|
+
|
|
|
|
|
+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);
|
|
|
|
|
+ });
|
|
|
|
|
+});
|