api.test.js 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379
  1. const assert = require('assert');
  2. const supertest = require('supertest');
  3. let app, request, db;
  4. // Simple GPX with a track
  5. const TEST_GPX = `<?xml version="1.0"?>
  6. <gpx version="1.1">
  7. <trk>
  8. <name>Test Track</name>
  9. <trkseg>
  10. <trkpt lat="51.500" lon="-0.100"><ele>10</ele><time>2024-03-01T10:00:00Z</time></trkpt>
  11. <trkpt lat="51.510" lon="-0.100"><ele>12</ele><time>2024-03-01T10:01:00Z</time></trkpt>
  12. <trkpt lat="51.520" lon="-0.100"><ele>14</ele><time>2024-03-01T10:02:00Z</time></trkpt>
  13. </trkseg>
  14. </trk>
  15. </gpx>`;
  16. before(async () => {
  17. // Reset sequelize singleton for in-memory SQLite
  18. const dbModule = require('../src/database');
  19. await dbModule.initDatabase();
  20. app = require('../src/app').createApp(global.testConfig);
  21. request = supertest(app);
  22. });
  23. // ─── Health ───────────────────────────────────────────────────────────────────
  24. describe('GET /health', () => {
  25. it('returns ok', async () => {
  26. const res = await request.get('/health').expect(200);
  27. assert.strictEqual(res.body.ok, true);
  28. });
  29. });
  30. // ─── Auth ─────────────────────────────────────────────────────────────────────
  31. describe('Auth', () => {
  32. let adminToken, user2Token;
  33. describe('POST /api/auth/register', () => {
  34. it('first user becomes admin and is active', async () => {
  35. const res = await request.post('/api/auth/register')
  36. .send({ login: 'testadmin', password: 'admin123' })
  37. .expect(201);
  38. assert.strictEqual(res.body.login, 'testadmin');
  39. assert.strictEqual(res.body.isAdmin, true);
  40. assert.strictEqual(res.body.isActive, true);
  41. });
  42. it('rejects duplicate login', async () => {
  43. await request.post('/api/auth/register')
  44. .send({ login: 'testadmin', password: 'pass123456' })
  45. .expect(409);
  46. });
  47. it('rejects invalid login format', async () => {
  48. await request.post('/api/auth/register')
  49. .send({ login: 'a b', password: 'pass123' })
  50. .expect(400);
  51. });
  52. it('rejects short password', async () => {
  53. await request.post('/api/auth/register')
  54. .send({ login: 'validname', password: 'x' })
  55. .expect(400);
  56. });
  57. it('second user is not active', async () => {
  58. const res = await request.post('/api/auth/register')
  59. .send({ login: 'testuser2', password: 'pass123' })
  60. .expect(201);
  61. assert.strictEqual(res.body.isAdmin, false);
  62. assert.strictEqual(res.body.isActive, false);
  63. });
  64. });
  65. describe('POST /api/auth/login', () => {
  66. it('admin can login', async () => {
  67. const res = await request.post('/api/auth/login')
  68. .send({ login: 'testadmin', password: 'admin123' })
  69. .expect(200);
  70. assert.ok(res.body.token);
  71. adminToken = res.body.token;
  72. });
  73. it('inactive user cannot login', async () => {
  74. await request.post('/api/auth/login')
  75. .send({ login: 'testuser2', password: 'pass123' })
  76. .expect(403);
  77. });
  78. it('rejects wrong password', async () => {
  79. await request.post('/api/auth/login')
  80. .send({ login: 'testadmin', password: 'wrong' })
  81. .expect(401);
  82. });
  83. it('rejects unknown user', async () => {
  84. await request.post('/api/auth/login')
  85. .send({ login: 'nobody', password: 'pass' })
  86. .expect(401);
  87. });
  88. });
  89. describe('GET /api/auth/me', () => {
  90. it('returns current user when authenticated', async () => {
  91. const res = await request.get('/api/auth/me')
  92. .set('Authorization', `Bearer ${adminToken}`)
  93. .expect(200);
  94. assert.strictEqual(res.body.login, 'testadmin');
  95. });
  96. it('returns 401 without token', async () => {
  97. await request.get('/api/auth/me').expect(401);
  98. });
  99. });
  100. // Store token for use in later tests
  101. after(() => {
  102. global.adminToken = adminToken;
  103. });
  104. });
  105. // ─── Admin ────────────────────────────────────────────────────────────────────
  106. describe('Admin', () => {
  107. it('activates second user', async () => {
  108. // Find user2's id
  109. const users = await request.get('/api/admin/users')
  110. .set('Authorization', `Bearer ${global.adminToken}`)
  111. .expect(200);
  112. const user2 = users.body.find(u => u.login === 'testuser2');
  113. assert.ok(user2);
  114. await request.put(`/api/admin/users/${user2.id}/activate`)
  115. .set('Authorization', `Bearer ${global.adminToken}`)
  116. .send({ isActive: true })
  117. .expect(200);
  118. // user2 can now login
  119. const res = await request.post('/api/auth/login')
  120. .send({ login: 'testuser2', password: 'pass123' })
  121. .expect(200);
  122. global.user2Token = res.body.token;
  123. });
  124. it('lists all users', async () => {
  125. const res = await request.get('/api/admin/users')
  126. .set('Authorization', `Bearer ${global.adminToken}`)
  127. .expect(200);
  128. assert.ok(Array.isArray(res.body));
  129. assert.ok(res.body.length >= 2);
  130. assert.ok(res.body.every(u => 'trackCount' in u));
  131. });
  132. it('rejects non-admin access', async () => {
  133. await request.get('/api/admin/users')
  134. .set('Authorization', `Bearer ${global.user2Token}`)
  135. .expect(403);
  136. });
  137. });
  138. // ─── Directories ──────────────────────────────────────────────────────────────
  139. describe('Directories', () => {
  140. let dirId;
  141. it('creates a root directory', async () => {
  142. const res = await request.post('/api/directories')
  143. .set('Authorization', `Bearer ${global.adminToken}`)
  144. .send({ name: 'My Rides' })
  145. .expect(201);
  146. assert.strictEqual(res.body.name, 'My Rides');
  147. assert.strictEqual(res.body.parentId, null);
  148. dirId = res.body.id;
  149. global.testDirId = dirId;
  150. });
  151. it('creates a subdirectory', async () => {
  152. const res = await request.post('/api/directories')
  153. .set('Authorization', `Bearer ${global.adminToken}`)
  154. .send({ name: 'Summer 2024', parentId: dirId })
  155. .expect(201);
  156. assert.strictEqual(res.body.parentId, dirId);
  157. global.subDirId = res.body.id;
  158. });
  159. it('lists root directories', async () => {
  160. const res = await request.get('/api/directories')
  161. .set('Authorization', `Bearer ${global.adminToken}`)
  162. .expect(200);
  163. assert.ok(Array.isArray(res.body));
  164. assert.ok(res.body.some(d => d.name === 'My Rides'));
  165. });
  166. it('gets directory with children', async () => {
  167. const res = await request.get(`/api/directories/${dirId}`)
  168. .set('Authorization', `Bearer ${global.adminToken}`)
  169. .expect(200);
  170. assert.ok(Array.isArray(res.body.children));
  171. assert.ok(res.body.children.some(c => c.name === 'Summer 2024'));
  172. });
  173. it('renames directory', async () => {
  174. await request.put(`/api/directories/${dirId}`)
  175. .set('Authorization', `Bearer ${global.adminToken}`)
  176. .send({ name: 'All Rides' })
  177. .expect(200);
  178. const res = await request.get(`/api/directories/${dirId}`)
  179. .set('Authorization', `Bearer ${global.adminToken}`);
  180. assert.strictEqual(res.body.name, 'All Rides');
  181. });
  182. it('prevents accessing other users directories', async () => {
  183. await request.get(`/api/directories/${dirId}`)
  184. .set('Authorization', `Bearer ${global.user2Token}`)
  185. .expect(404);
  186. });
  187. });
  188. // ─── Tracks ───────────────────────────────────────────────────────────────────
  189. describe('Tracks', () => {
  190. let trackId;
  191. it('uploads a GPX file', async () => {
  192. const res = await request.post('/api/tracks/upload')
  193. .set('Authorization', `Bearer ${global.adminToken}`)
  194. .attach('file', Buffer.from(TEST_GPX), { filename: 'test.gpx', contentType: 'application/gpx+xml' })
  195. .field('directoryId', global.testDirId)
  196. .expect(201);
  197. assert.strictEqual(res.body.name, 'Test Track');
  198. assert.ok(res.body.pointCount > 0);
  199. assert.ok(res.body.totalDistance > 0);
  200. assert.ok(res.body.trackDate);
  201. trackId = res.body.id;
  202. global.testTrackId = trackId;
  203. });
  204. it('rejects non-GPX upload', async () => {
  205. await request.post('/api/tracks/upload')
  206. .set('Authorization', `Bearer ${global.adminToken}`)
  207. .attach('file', Buffer.from('not gpx'), { filename: 'file.txt', contentType: 'text/plain' })
  208. .expect(400);
  209. });
  210. it('lists tracks in directory', async () => {
  211. const res = await request.get(`/api/tracks?directoryId=${global.testDirId}`)
  212. .set('Authorization', `Bearer ${global.adminToken}`)
  213. .expect(200);
  214. assert.ok(Array.isArray(res.body));
  215. assert.ok(res.body.some(t => t.id === trackId));
  216. });
  217. it('gets track points in compact format', async () => {
  218. const res = await request.get(`/api/tracks/${trackId}/points`)
  219. .set('Authorization', `Bearer ${global.adminToken}`)
  220. .expect(200);
  221. assert.ok(res.body.meta);
  222. assert.ok(Array.isArray(res.body.segments));
  223. assert.ok(res.body.segments.length > 0);
  224. const pts = res.body.segments[0];
  225. assert.ok(Array.isArray(pts));
  226. assert.ok(pts.length > 0);
  227. // Each point is [lat, lon, ele, time]
  228. const pt = pts[0];
  229. assert.ok(Array.isArray(pt) && pt.length === 4);
  230. assert.ok(typeof pt[0] === 'number'); // lat
  231. assert.ok(typeof pt[1] === 'number'); // lon
  232. });
  233. it('updates track name and directory', async () => {
  234. await request.put(`/api/tracks/${trackId}`)
  235. .set('Authorization', `Bearer ${global.adminToken}`)
  236. .send({ name: 'Renamed Track', directoryId: global.subDirId })
  237. .expect(200);
  238. const res = await request.get(`/api/tracks/${trackId}`)
  239. .set('Authorization', `Bearer ${global.adminToken}`);
  240. assert.strictEqual(res.body.name, 'Renamed Track');
  241. assert.strictEqual(res.body.directoryId, global.subDirId);
  242. });
  243. it('prevents accessing other users tracks', async () => {
  244. await request.get(`/api/tracks/${trackId}/points`)
  245. .set('Authorization', `Bearer ${global.user2Token}`)
  246. .expect(404);
  247. });
  248. });
  249. // ─── Share Links ──────────────────────────────────────────────────────────────
  250. describe('Share Links', () => {
  251. let shareCode;
  252. it('creates a share link', async () => {
  253. const res = await request.post(`/api/tracks/${global.testTrackId}/share`)
  254. .set('Authorization', `Bearer ${global.adminToken}`)
  255. .expect(200);
  256. assert.ok(res.body.code);
  257. assert.match(res.body.code, /^[bcdfghjklmnprstvwxyz][aeiou]{1}[bcdfghjklmnprstvwxyz][aeiou]{1}[bcdfghjklmnprstvwxyz][aeiou]{1}[bcdfghjklmnprstvwxyz][aeiou]{1}[bcdfghjklmnprstvwxyz][aeiou]{1}$/);
  258. shareCode = res.body.code;
  259. global.shareCode = shareCode;
  260. });
  261. it('returns same code on second call (idempotent)', async () => {
  262. const res = await request.post(`/api/tracks/${global.testTrackId}/share`)
  263. .set('Authorization', `Bearer ${global.adminToken}`)
  264. .expect(200);
  265. assert.strictEqual(res.body.code, shareCode);
  266. });
  267. it('public share endpoint returns track data without auth', async () => {
  268. const res = await request.get(`/api/share/${shareCode}`).expect(200);
  269. assert.ok(res.body.meta);
  270. assert.ok(Array.isArray(res.body.segments));
  271. assert.strictEqual(res.body.meta.name, 'Renamed Track');
  272. });
  273. it('public share returns 404 for unknown code', async () => {
  274. await request.get('/api/share/unknownxxx').expect(404);
  275. });
  276. it('revokes share link', async () => {
  277. await request.delete(`/api/tracks/${global.testTrackId}/share`)
  278. .set('Authorization', `Bearer ${global.adminToken}`)
  279. .expect(200);
  280. await request.get(`/api/share/${shareCode}`).expect(404);
  281. });
  282. });
  283. // ─── Stats ────────────────────────────────────────────────────────────────────
  284. describe('Stats', () => {
  285. it('returns distance stats by year/month/week', async () => {
  286. const res = await request.get('/api/stats')
  287. .set('Authorization', `Bearer ${global.adminToken}`)
  288. .expect(200);
  289. assert.ok(Array.isArray(res.body.byYear));
  290. assert.ok(Array.isArray(res.body.byMonth));
  291. assert.ok(Array.isArray(res.body.byWeek));
  292. // Should have stats for 2024 (track date)
  293. assert.ok(res.body.byYear.some(y => y.year === 2024));
  294. });
  295. it('returns directory stats', async () => {
  296. const res = await request.get(`/api/stats/directory/${global.subDirId}`)
  297. .set('Authorization', `Bearer ${global.adminToken}`)
  298. .expect(200);
  299. assert.ok(Array.isArray(res.body.tracks));
  300. assert.ok(typeof res.body.totalDistance === 'number');
  301. });
  302. });
  303. // ─── Track Deletion ───────────────────────────────────────────────────────────
  304. describe('Track and Directory Deletion', () => {
  305. it('deletes a track', async () => {
  306. await request.delete(`/api/tracks/${global.testTrackId}`)
  307. .set('Authorization', `Bearer ${global.adminToken}`)
  308. .expect(200);
  309. await request.get(`/api/tracks/${global.testTrackId}`)
  310. .set('Authorization', `Bearer ${global.adminToken}`)
  311. .expect(404);
  312. });
  313. it('deletes a directory recursively', async () => {
  314. // Upload a track to subdir first
  315. await request.post('/api/tracks/upload')
  316. .set('Authorization', `Bearer ${global.adminToken}`)
  317. .attach('file', Buffer.from(TEST_GPX), { filename: 'test2.gpx', contentType: 'application/gpx+xml' })
  318. .field('directoryId', global.subDirId);
  319. // Delete parent (should cascade to subdir and its track)
  320. await request.delete(`/api/directories/${global.testDirId}`)
  321. .set('Authorization', `Bearer ${global.adminToken}`)
  322. .expect(200);
  323. await request.get(`/api/directories/${global.subDirId}`)
  324. .set('Authorization', `Bearer ${global.adminToken}`)
  325. .expect(404);
  326. });
  327. });