File size: 11,960 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
/**
 * Todo integration tests — TODO-001 through TODO-012.
 * Covers all endpoints at /api/trips/:tripId/todo.
 */
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';

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: () => null,
    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, addTripMember } from '../helpers/factories';
import { authCookie } from '../helpers/auth';
import { invalidatePermissionsCache } from '../../src/services/permissions';

let nestApp: INestApplication;
let app: Application;

beforeAll(async () => {
  createTables(testDb);
  runMigrations(testDb);
  nestApp = await buildApp();
  app = nestApp.getHttpAdapter().getInstance();
});

beforeEach(() => {
  resetTestDb(testDb);
  resetRateLimits(nestApp);
  invalidatePermissionsCache();
});

afterAll(async () => {
  await nestApp.close();
  testDb.close();
});

describe('Todo items', () => {
  it('TODO-001: GET /api/trips/:id/todo returns empty items for a new trip', async () => {
    const { user } = createUser(testDb);
    const trip = createTrip(testDb, user.id);
    const res = await request(app)
      .get(`/api/trips/${trip.id}/todo`)
      .set('Cookie', authCookie(user.id));
    expect(res.status).toBe(200);
    expect(res.body.items).toEqual([]);
  });

  it('TODO-002: POST /api/trips/:id/todo creates a todo with title only', async () => {
    const { user } = createUser(testDb);
    const trip = createTrip(testDb, user.id);
    const res = await request(app)
      .post(`/api/trips/${trip.id}/todo`)
      .set('Cookie', authCookie(user.id))
      .send({ name: 'Book hotel' });
    expect(res.status).toBe(201);
    expect(res.body.item).toMatchObject({ name: 'Book hotel', checked: 0, trip_id: trip.id });
    expect(res.body.item.id).toBeDefined();
  });

  it('TODO-003: POST /api/trips/:id/todo creates a todo with all optional fields', async () => {
    const { user } = createUser(testDb);
    const trip = createTrip(testDb, user.id);
    const res = await request(app)
      .post(`/api/trips/${trip.id}/todo`)
      .set('Cookie', authCookie(user.id))
      .send({
        name: 'Pack suitcase',
        category: 'Preparation',
        description: 'Pack everything for the trip',
        priority: 2,
      });
    expect(res.status).toBe(201);
    expect(res.body.item).toMatchObject({
      name: 'Pack suitcase',
      category: 'Preparation',
      description: 'Pack everything for the trip',
      priority: 2,
    });
  });

  it('TODO-004: POST /api/trips/:id/todo - missing name returns 400', async () => {
    const { user } = createUser(testDb);
    const trip = createTrip(testDb, user.id);
    const res = await request(app)
      .post(`/api/trips/${trip.id}/todo`)
      .set('Cookie', authCookie(user.id))
      .send({ category: 'Test' });
    expect(res.status).toBe(400);
    expect(res.body.error).toBeDefined();
  });

  it('TODO-005: PUT /api/trips/:id/todo/:todoId toggles checked status', async () => {
    const { user } = createUser(testDb);
    const trip = createTrip(testDb, user.id);
    const createRes = await request(app)
      .post(`/api/trips/${trip.id}/todo`)
      .set('Cookie', authCookie(user.id))
      .send({ name: 'Visit museum' });
    const itemId = createRes.body.item.id;

    // Toggle to checked
    const res = await request(app)
      .put(`/api/trips/${trip.id}/todo/${itemId}`)
      .set('Cookie', authCookie(user.id))
      .send({ checked: 1 });
    expect(res.status).toBe(200);
    expect(res.body.item.checked).toBe(1);

    // Toggle back to unchecked
    const res2 = await request(app)
      .put(`/api/trips/${trip.id}/todo/${itemId}`)
      .set('Cookie', authCookie(user.id))
      .send({ checked: 0 });
    expect(res2.status).toBe(200);
    expect(res2.body.item.checked).toBe(0);
  });

  it('TODO-006: PUT /api/trips/:id/todo/:todoId updates category', async () => {
    const { user } = createUser(testDb);
    const trip = createTrip(testDb, user.id);
    const createRes = await request(app)
      .post(`/api/trips/${trip.id}/todo`)
      .set('Cookie', authCookie(user.id))
      .send({ name: 'Buy souvenirs' });
    const itemId = createRes.body.item.id;

    const res = await request(app)
      .put(`/api/trips/${trip.id}/todo/${itemId}`)
      .set('Cookie', authCookie(user.id))
      .send({ category: 'Shopping' });
    expect(res.status).toBe(200);
    expect(res.body.item.category).toBe('Shopping');
  });

  it('TODO-007: DELETE /api/trips/:id/todo/:todoId deletes a todo', async () => {
    const { user } = createUser(testDb);
    const trip = createTrip(testDb, user.id);
    const createRes = await request(app)
      .post(`/api/trips/${trip.id}/todo`)
      .set('Cookie', authCookie(user.id))
      .send({ name: 'To Delete' });
    const itemId = createRes.body.item.id;

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

    // Verify gone from list
    const listRes = await request(app)
      .get(`/api/trips/${trip.id}/todo`)
      .set('Cookie', authCookie(user.id));
    expect(listRes.body.items).toHaveLength(0);
  });

  it('TODO-008: PUT /api/trips/:id/todo/reorder reorders items', async () => {
    const { user } = createUser(testDb);
    const trip = createTrip(testDb, user.id);

    // Create 3 items
    const r1 = await request(app)
      .post(`/api/trips/${trip.id}/todo`)
      .set('Cookie', authCookie(user.id))
      .send({ name: 'First' });
    const r2 = await request(app)
      .post(`/api/trips/${trip.id}/todo`)
      .set('Cookie', authCookie(user.id))
      .send({ name: 'Second' });
    const r3 = await request(app)
      .post(`/api/trips/${trip.id}/todo`)
      .set('Cookie', authCookie(user.id))
      .send({ name: 'Third' });

    const id1 = r1.body.item.id;
    const id2 = r2.body.item.id;
    const id3 = r3.body.item.id;

    // Reverse the order
    const res = await request(app)
      .put(`/api/trips/${trip.id}/todo/reorder`)
      .set('Cookie', authCookie(user.id))
      .send({ orderedIds: [id3, id2, id1] });
    expect(res.status).toBe(200);
    expect(res.body.success).toBe(true);

    // Verify the new order in the DB
    const items = testDb.prepare('SELECT id, sort_order FROM todo_items WHERE trip_id = ? ORDER BY sort_order').all(trip.id) as any[];
    expect(items[0].id).toBe(id3);
    expect(items[1].id).toBe(id2);
    expect(items[2].id).toBe(id1);
  });

  it('TODO-009: Non-member accessing trip returns 404', async () => {
    const { user: owner } = createUser(testDb);
    const { user: stranger } = createUser(testDb);
    const trip = createTrip(testDb, owner.id);

    const res = await request(app)
      .get(`/api/trips/${trip.id}/todo`)
      .set('Cookie', authCookie(stranger.id));
    expect(res.status).toBe(404);
  });

  it('TODO-010: Trip member can read and create todos', async () => {
    const { user: owner } = createUser(testDb);
    const { user: member } = createUser(testDb);
    const trip = createTrip(testDb, owner.id);
    addTripMember(testDb, trip.id, member.id);

    // Member can read
    const getRes = await request(app)
      .get(`/api/trips/${trip.id}/todo`)
      .set('Cookie', authCookie(member.id));
    expect(getRes.status).toBe(200);

    // Member can create
    const postRes = await request(app)
      .post(`/api/trips/${trip.id}/todo`)
      .set('Cookie', authCookie(member.id))
      .send({ name: 'Member task' });
    expect(postRes.status).toBe(201);
  });

  it('TODO-011: PUT /api/trips/:id/todo/:todoId - non-existent item returns 404', async () => {
    const { user } = createUser(testDb);
    const trip = createTrip(testDb, user.id);
    const res = await request(app)
      .put(`/api/trips/${trip.id}/todo/99999`)
      .set('Cookie', authCookie(user.id))
      .send({ name: 'Ghost' });
    expect(res.status).toBe(404);
  });

  it('TODO-012: GET /api/trips/:id/todo - unauthenticated returns 401', async () => {
    const { user } = createUser(testDb);
    const trip = createTrip(testDb, user.id);
    const res = await request(app).get(`/api/trips/${trip.id}/todo`);
    expect(res.status).toBe(401);
  });
});

describe('Todo category assignees', () => {
  it('TODO-013: GET /api/trips/:id/todo/category-assignees returns empty object for new trip', async () => {
    const { user } = createUser(testDb);
    const trip = createTrip(testDb, user.id);
    const res = await request(app)
      .get(`/api/trips/${trip.id}/todo/category-assignees`)
      .set('Cookie', authCookie(user.id));
    expect(res.status).toBe(200);
    expect(res.body.assignees).toEqual({});
  });

  it('TODO-014: PUT /api/trips/:id/todo/category-assignees/:name sets assignees', async () => {
    const { user: owner } = createUser(testDb);
    const { user: member } = createUser(testDb);
    const trip = createTrip(testDb, owner.id);
    addTripMember(testDb, trip.id, member.id);

    const res = await request(app)
      .put(`/api/trips/${trip.id}/todo/category-assignees/Shopping`)
      .set('Cookie', authCookie(owner.id))
      .send({ user_ids: [owner.id, member.id] });
    expect(res.status).toBe(200);
    expect(Array.isArray(res.body.assignees)).toBe(true);
    expect(res.body.assignees).toHaveLength(2);

    // Verify via GET
    const getRes = await request(app)
      .get(`/api/trips/${trip.id}/todo/category-assignees`)
      .set('Cookie', authCookie(owner.id));
    expect(getRes.body.assignees.Shopping).toBeDefined();
    expect(getRes.body.assignees.Shopping).toHaveLength(2);
  });

  it('TODO-015: PUT category-assignees with empty array clears assignees', async () => {
    const { user: owner } = createUser(testDb);
    const { user: member } = createUser(testDb);
    const trip = createTrip(testDb, owner.id);
    addTripMember(testDb, trip.id, member.id);

    // Set assignees
    await request(app)
      .put(`/api/trips/${trip.id}/todo/category-assignees/Shopping`)
      .set('Cookie', authCookie(owner.id))
      .send({ user_ids: [owner.id] });

    // Clear them
    const res = await request(app)
      .put(`/api/trips/${trip.id}/todo/category-assignees/Shopping`)
      .set('Cookie', authCookie(owner.id))
      .send({ user_ids: [] });
    expect(res.status).toBe(200);
    expect(res.body.assignees).toHaveLength(0);
  });
});