File size: 18,043 Bytes
1f5ea39
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
/**
 * Trip Files integration tests.
 * Covers FILE-001 to FILE-021.
 *
 * Notes:
 * - Tests use fixture files from tests/fixtures/
 * - File uploads create real files in uploads/files/ β€” tests clean up after themselves where possible
 * - FILE-009 (ephemeral token download) is covered via the /api/auth/resource-token endpoint
 */
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
import request from 'supertest';
import type { Application } from 'express';
import type { INestApplication } from '@nestjs/common';
import path from 'path';
import fs from 'fs';

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

vi.mock('../../src/db/database', () => dbMock);
vi.mock('../../src/config', () => ({
  JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
  ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
  updateJwtSecret: () => {},
  SESSION_DURATION: '24h',
  SESSION_DURATION_MS: 86400000,
  SESSION_DURATION_SECONDS: 86400,
  DEFAULT_LANGUAGE: 'en',
}));
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));

import { buildApp } from '../../src/bootstrap';
import { createTables } from '../../src/db/schema';
import { runMigrations } from '../../src/db/migrations';
import { resetTestDb, resetRateLimits } from '../helpers/test-db';
import { createUser, createTrip, createReservation, addTripMember } from '../helpers/factories';
import { authCookie, generateToken } from '../helpers/auth';

let nestApp: INestApplication;
let app: Application;
const FIXTURE_PDF = path.join(__dirname, '../fixtures/test.pdf');
const FIXTURE_IMG = path.join(__dirname, '../fixtures/small-image.jpg');

// Ensure uploads/files dir exists
const uploadsDir = path.join(__dirname, '../../uploads/files');

beforeAll(async () => {
  createTables(testDb);
  runMigrations(testDb);
  nestApp = await buildApp();
  app = nestApp.getHttpAdapter().getInstance();
  if (!fs.existsSync(uploadsDir)) fs.mkdirSync(uploadsDir, { recursive: true });
  // Seed allowed_file_types to include common types (wildcard)
  testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('allowed_file_types', '*')").run();
});

beforeEach(() => {
  resetTestDb(testDb);
  resetRateLimits(nestApp);
  // Re-seed allowed_file_types after reset
  testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('allowed_file_types', '*')").run();
});

afterAll(async () => {
  await nestApp.close();
  testDb.close();
  fs.rmSync(uploadsDir, { recursive: true, force: true });
});

// Helper to upload a file and return the file object
async function uploadFile(tripId: number, userId: number, fixturePath = FIXTURE_PDF) {
  const res = await request(app)
    .post(`/api/trips/${tripId}/files`)
    .set('Cookie', authCookie(userId))
    .attach('file', fixturePath);
  return res;
}

// ─────────────────────────────────────────────────────────────────────────────
// Upload file
// ─────────────────────────────────────────────────────────────────────────────

describe('Upload file', () => {
  it('FILE-001 β€” POST uploads a file and returns file metadata', async () => {
    const { user } = createUser(testDb);
    const trip = createTrip(testDb, user.id);

    const res = await uploadFile(trip.id, user.id, FIXTURE_PDF);
    expect(res.status).toBe(201);
    expect(res.body.file).toBeDefined();
    expect(res.body.file.id).toBeDefined();
    expect(res.body.file.filename).toBeDefined();
  });

  it('FILE-002 β€” uploading a blocked extension (.svg) is rejected', async () => {
    const { user } = createUser(testDb);
    const trip = createTrip(testDb, user.id);

    // Create a temp .svg file
    const svgPath = path.join(uploadsDir, 'test_blocked.svg');
    fs.writeFileSync(svgPath, '<svg></svg>');
    try {
      const res = await request(app)
        .post(`/api/trips/${trip.id}/files`)
        .set('Cookie', authCookie(user.id))
        .attach('file', svgPath);
      expect(res.status).toBe(400);
    } finally {
      if (fs.existsSync(svgPath)) fs.unlinkSync(svgPath);
    }
  });

  it('FILE-021 β€” non-member cannot upload file', async () => {
    const { user: owner } = createUser(testDb);
    const { user: other } = createUser(testDb);
    const trip = createTrip(testDb, owner.id);

    const res = await request(app)
      .post(`/api/trips/${trip.id}/files`)
      .set('Cookie', authCookie(other.id))
      .attach('file', FIXTURE_PDF);
    expect(res.status).toBe(404);
  });
});

// ─────────────────────────────────────────────────────────────────────────────
// List files
// ─────────────────────────────────────────────────────────────────────────────

describe('List files', () => {
  it('FILE-006 β€” GET returns all non-trashed files', async () => {
    const { user } = createUser(testDb);
    const trip = createTrip(testDb, user.id);
    await uploadFile(trip.id, user.id, FIXTURE_PDF);
    await uploadFile(trip.id, user.id, FIXTURE_IMG);

    const res = await request(app)
      .get(`/api/trips/${trip.id}/files`)
      .set('Cookie', authCookie(user.id));
    expect(res.status).toBe(200);
    expect(res.body.files.length).toBeGreaterThanOrEqual(2);
  });

  it('FILE-007 β€” GET ?trash=true returns only trashed files', async () => {
    const { user } = createUser(testDb);
    const trip = createTrip(testDb, user.id);
    const upload = await uploadFile(trip.id, user.id, FIXTURE_PDF);
    const fileId = upload.body.file.id;

    // Soft-delete it
    await request(app)
      .delete(`/api/trips/${trip.id}/files/${fileId}`)
      .set('Cookie', authCookie(user.id));

    const trash = await request(app)
      .get(`/api/trips/${trip.id}/files?trash=true`)
      .set('Cookie', authCookie(user.id));
    expect(trash.status).toBe(200);
    const trashIds = (trash.body.files as any[]).map((f: any) => f.id);
    expect(trashIds).toContain(fileId);
  });
});

// ─────────────────────────────────────────────────────────────────────────────
// Star / unstar
// ─────────────────────────────────────────────────────────────────────────────

describe('Star/unstar file', () => {
  it('FILE-011 β€” PATCH /:id/star toggles starred status', async () => {
    const { user } = createUser(testDb);
    const trip = createTrip(testDb, user.id);
    const upload = await uploadFile(trip.id, user.id, FIXTURE_PDF);
    const fileId = upload.body.file.id;

    const res = await request(app)
      .patch(`/api/trips/${trip.id}/files/${fileId}/star`)
      .set('Cookie', authCookie(user.id));
    expect(res.status).toBe(200);
    expect(res.body.file.starred).toBe(1);

    // Toggle back
    const res2 = await request(app)
      .patch(`/api/trips/${trip.id}/files/${fileId}/star`)
      .set('Cookie', authCookie(user.id));
    expect(res2.body.file.starred).toBe(0);
  });
});

// ─────────────────────────────────────────────────────────────────────────────
// Soft delete / restore / permanent delete
// ─────────────────────────────────────────────────────────────────────────────

describe('Soft delete, restore, permanent delete', () => {
  it('FILE-012 β€” DELETE moves file to trash', async () => {
    const { user } = createUser(testDb);
    const trip = createTrip(testDb, user.id);
    const upload = await uploadFile(trip.id, user.id, FIXTURE_PDF);
    const fileId = upload.body.file.id;

    const del = await request(app)
      .delete(`/api/trips/${trip.id}/files/${fileId}`)
      .set('Cookie', authCookie(user.id));
    expect(del.status).toBe(200);
    expect(del.body.success).toBe(true);

    // Should not appear in normal list
    const list = await request(app)
      .get(`/api/trips/${trip.id}/files`)
      .set('Cookie', authCookie(user.id));
    const ids = (list.body.files as any[]).map((f: any) => f.id);
    expect(ids).not.toContain(fileId);
  });

  it('FILE-013 β€” POST /:id/restore restores from trash', async () => {
    const { user } = createUser(testDb);
    const trip = createTrip(testDb, user.id);
    const upload = await uploadFile(trip.id, user.id, FIXTURE_PDF);
    const fileId = upload.body.file.id;

    await request(app)
      .delete(`/api/trips/${trip.id}/files/${fileId}`)
      .set('Cookie', authCookie(user.id));

    const restore = await request(app)
      .post(`/api/trips/${trip.id}/files/${fileId}/restore`)
      .set('Cookie', authCookie(user.id));
    expect(restore.status).toBe(200);
    expect(restore.body.file.id).toBe(fileId);
  });

  it('FILE-014 β€” DELETE /:id/permanent permanently deletes from trash', async () => {
    const { user } = createUser(testDb);
    const trip = createTrip(testDb, user.id);
    const upload = await uploadFile(trip.id, user.id, FIXTURE_PDF);
    const fileId = upload.body.file.id;

    await request(app)
      .delete(`/api/trips/${trip.id}/files/${fileId}`)
      .set('Cookie', authCookie(user.id));

    const perm = await request(app)
      .delete(`/api/trips/${trip.id}/files/${fileId}/permanent`)
      .set('Cookie', authCookie(user.id));
    expect(perm.status).toBe(200);
    expect(perm.body.success).toBe(true);
  });

  it('FILE-015 β€” DELETE /:id/permanent on non-trashed file returns 404', async () => {
    const { user } = createUser(testDb);
    const trip = createTrip(testDb, user.id);
    const upload = await uploadFile(trip.id, user.id, FIXTURE_PDF);
    const fileId = upload.body.file.id;

    // Not trashed β€” should 404
    const res = await request(app)
      .delete(`/api/trips/${trip.id}/files/${fileId}/permanent`)
      .set('Cookie', authCookie(user.id));
    expect(res.status).toBe(404);
  });

  it('FILE-016 β€” DELETE /trash/empty empties all trash', async () => {
    const { user } = createUser(testDb);
    const trip = createTrip(testDb, user.id);
    const f1 = await uploadFile(trip.id, user.id, FIXTURE_PDF);
    const f2 = await uploadFile(trip.id, user.id, FIXTURE_IMG);

    await request(app).delete(`/api/trips/${trip.id}/files/${f1.body.file.id}`).set('Cookie', authCookie(user.id));
    await request(app).delete(`/api/trips/${trip.id}/files/${f2.body.file.id}`).set('Cookie', authCookie(user.id));

    const empty = await request(app)
      .delete(`/api/trips/${trip.id}/files/trash/empty`)
      .set('Cookie', authCookie(user.id));
    expect(empty.status).toBe(200);

    const trash = await request(app)
      .get(`/api/trips/${trip.id}/files?trash=true`)
      .set('Cookie', authCookie(user.id));
    expect(trash.body.files).toHaveLength(0);
  });
});

// ─────────────────────────────────────────────────────────────────────────────
// Update file metadata
// ─────────────────────────────────────────────────────────────────────────────

describe('Update file metadata', () => {
  it('FILE-017 β€” PUT updates description', async () => {
    const { user } = createUser(testDb);
    const trip = createTrip(testDb, user.id);
    const upload = await uploadFile(trip.id, user.id, FIXTURE_PDF);
    const fileId = upload.body.file.id;

    const res = await request(app)
      .put(`/api/trips/${trip.id}/files/${fileId}`)
      .set('Cookie', authCookie(user.id))
      .send({ description: 'My important document' });
    expect(res.status).toBe(200);
    expect(res.body.file.description).toBe('My important document');
  });
});

// ─────────────────────────────────────────────────────────────────────────────
// File links
// ─────────────────────────────────────────────────────────────────────────────

describe('File links', () => {
  it('FILE-018/019/020 β€” link file to reservation, list links, unlink', async () => {
    const { user } = createUser(testDb);
    const trip = createTrip(testDb, user.id);
    const resv = createReservation(testDb, trip.id, { title: 'My Flight', type: 'flight' });
    const upload = await uploadFile(trip.id, user.id, FIXTURE_PDF);
    const fileId = upload.body.file.id;

    // Link (POST /:id/link)
    const link = await request(app)
      .post(`/api/trips/${trip.id}/files/${fileId}/link`)
      .set('Cookie', authCookie(user.id))
      .send({ reservation_id: resv.id });
    expect(link.status).toBe(200);
    expect(link.body.success).toBe(true);

    // List links (GET /:id/links)
    const links = await request(app)
      .get(`/api/trips/${trip.id}/files/${fileId}/links`)
      .set('Cookie', authCookie(user.id));
    expect(links.status).toBe(200);
    expect(links.body.links.some((l: any) => l.reservation_id === resv.id)).toBe(true);

    // Unlink (DELETE /:id/link/:linkId β€” use the link id from the list)
    const linkId = links.body.links.find((l: any) => l.reservation_id === resv.id)?.id;
    expect(linkId).toBeDefined();
    const unlink = await request(app)
      .delete(`/api/trips/${trip.id}/files/${fileId}/link/${linkId}`)
      .set('Cookie', authCookie(user.id));
    expect(unlink.status).toBe(200);
  });
});

// ─────────────────────────────────────────────────────────────────────────────
// Download
// ─────────────────────────────────────────────────────────────────────────────

describe('File download', () => {
  it('FILE-010 β€” GET /:id/download without auth returns 401', async () => {
    const { user } = createUser(testDb);
    const trip = createTrip(testDb, user.id);
    const upload = await uploadFile(trip.id, user.id, FIXTURE_PDF);
    const fileId = upload.body.file.id;

    const res = await request(app)
      .get(`/api/trips/${trip.id}/files/${fileId}/download`);
    expect(res.status).toBe(401);
  });

  it('FILE-008 β€” GET /:id/download with Bearer JWT downloads file', async () => {
    const { user } = createUser(testDb);
    const trip = createTrip(testDb, user.id);
    const upload = await uploadFile(trip.id, user.id, FIXTURE_PDF);
    const fileId = upload.body.file.id;

    const token = generateToken(user.id);

    const dl = await request(app)
      .get(`/api/trips/${trip.id}/files/${fileId}/download`)
      .set('Authorization', `Bearer ${token}`);
    // multer stores the file to disk during uploadFile β€” physical file exists
    expect(dl.status).toBe(200);
  });

  it('FILE-011 β€” GET /:id/download with trek_session cookie downloads file', async () => {
    const { user } = createUser(testDb);
    const trip = createTrip(testDb, user.id);
    const upload = await uploadFile(trip.id, user.id, FIXTURE_PDF);
    const fileId = upload.body.file.id;

    const token = generateToken(user.id);

    const dl = await request(app)
      .get(`/api/trips/${trip.id}/files/${fileId}/download`)
      .set('Cookie', `trek_session=${token}`);
    expect(dl.status).toBe(200);
  });
});