| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379 |
- 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);
- });
- });
|