File size: 12,283 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
/**
 * Unit tests for in-app notification preference filtering in createNotification().
 * Covers INOTIF-001 to INOTIF-004.
 */
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';

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');
  const mock = {
    db,
    closeDb: () => {},
    reinitialize: () => {},
    getPlaceWithTags: () => null,
    canAccessTrip: () => null,
    isOwner: () => false,
  };
  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: () => {},
}));

// Mock WebSocket broadcast β€” must use vi.hoisted() so broadcastMock is available
// when the vi.mock factory is evaluated (factories are hoisted before const declarations)
const { broadcastMock } = vi.hoisted(() => ({ broadcastMock: vi.fn() }));
vi.mock('../../../src/websocket', () => ({ broadcastToUser: broadcastMock }));

import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { resetTestDb } from '../../helpers/test-db';
import { createUser, createAdmin, disableNotificationPref } from '../../helpers/factories';
import { createNotification, createNotificationForRecipient, respondToBoolean } from '../../../src/services/inAppNotifications';

beforeAll(() => {
  createTables(testDb);
  runMigrations(testDb);
});

beforeEach(() => {
  resetTestDb(testDb);
  broadcastMock.mockClear();
});

afterAll(() => {
  testDb.close();
});

// ─────────────────────────────────────────────────────────────────────────────
// createNotification β€” preference filtering
// ─────────────────────────────────────────────────────────────────────────────

describe('createNotification β€” preference filtering', () => {
  it('INOTIF-001 β€” notification without event_type is delivered to all recipients (backward compat)', () => {
    const { user: admin } = createAdmin(testDb);
    const { user: recipient } = createUser(testDb);
    // The admin scope targets all admins β€” create a second admin as the sender
    const { user: sender } = createAdmin(testDb);

    // Send to a specific user (user scope) without event_type
    const ids = createNotification({
      type: 'simple',
      scope: 'user',
      target: recipient.id,
      sender_id: sender.id,
      title_key: 'notifications.test.title',
      text_key: 'notifications.test.text',
      // no event_type
    });

    expect(ids.length).toBe(1);
    const row = testDb.prepare('SELECT * FROM notifications WHERE recipient_id = ?').get(recipient.id);
    expect(row).toBeDefined();
    // Also verify the admin who disabled all prefs still gets messages without event_type
    disableNotificationPref(testDb, admin.id, 'trip_invite', 'inapp');
    // admin still gets this since no event_type check
    const adminIds = createNotification({
      type: 'simple',
      scope: 'user',
      target: admin.id,
      sender_id: sender.id,
      title_key: 'notifications.test.title',
      text_key: 'notifications.test.text',
    });
    expect(adminIds.length).toBe(1);
  });

  it('INOTIF-002 β€” notification with event_type skips recipients who have disabled that event on inapp', () => {
    const { user: sender } = createAdmin(testDb);
    const { user: recipient1 } = createUser(testDb);
    const { user: recipient2 } = createUser(testDb);

    // recipient2 has disabled inapp for trip_invite
    disableNotificationPref(testDb, recipient2.id, 'trip_invite', 'inapp');

    // Use a trip to target both members
    const tripId = (testDb.prepare('INSERT INTO trips (title, user_id) VALUES (?, ?)').run('Test Trip', sender.id)).lastInsertRowid as number;
    testDb.prepare('INSERT INTO trip_members (trip_id, user_id) VALUES (?, ?)').run(tripId, recipient1.id);
    testDb.prepare('INSERT INTO trip_members (trip_id, user_id) VALUES (?, ?)').run(tripId, recipient2.id);

    const ids = createNotification({
      type: 'simple',
      scope: 'trip',
      target: tripId,
      sender_id: sender.id,
      event_type: 'trip_invite',
      title_key: 'notifications.test.title',
      text_key: 'notifications.test.text',
    });

    // sender excluded, recipient1 included, recipient2 skipped (disabled pref)
    expect(ids.length).toBe(1);
    const r1 = testDb.prepare('SELECT id FROM notifications WHERE recipient_id = ?').get(recipient1.id);
    const r2 = testDb.prepare('SELECT id FROM notifications WHERE recipient_id = ?').get(recipient2.id);
    expect(r1).toBeDefined();
    expect(r2).toBeUndefined();
  });

  it('INOTIF-003 β€” notification with event_type delivers to recipients with no stored preferences', () => {
    const { user: sender } = createAdmin(testDb);
    const { user: recipient } = createUser(testDb);

    // No preferences stored for recipient β€” should default to enabled
    const ids = createNotification({
      type: 'simple',
      scope: 'user',
      target: recipient.id,
      sender_id: sender.id,
      event_type: 'trip_invite',
      title_key: 'notifications.test.title',
      text_key: 'notifications.test.text',
    });

    expect(ids.length).toBe(1);
    const row = testDb.prepare('SELECT id FROM notifications WHERE recipient_id = ?').get(recipient.id);
    expect(row).toBeDefined();
  });

  it('INOTIF-003b β€” createNotificationForRecipient inserts a single notification and broadcasts via WS', () => {
    const { user: sender } = createAdmin(testDb);
    const { user: recipient } = createUser(testDb);

    const id = createNotificationForRecipient(
      {
        type: 'navigate',
        scope: 'user',
        target: recipient.id,
        sender_id: sender.id,
        event_type: 'trip_invite',
        title_key: 'notif.trip_invite.title',
        text_key: 'notif.trip_invite.text',
        navigate_text_key: 'notif.action.view_trip',
        navigate_target: '/trips/99',
      },
      recipient.id,
      { username: 'admin', avatar: null }
    );

    expect(id).toBeTypeOf('number');
    const row = testDb.prepare('SELECT * FROM notifications WHERE id = ?').get(id) as { recipient_id: number; navigate_target: string } | undefined;
    expect(row).toBeDefined();
    expect(row!.recipient_id).toBe(recipient.id);
    expect(row!.navigate_target).toBe('/trips/99');
    expect(broadcastMock).toHaveBeenCalledTimes(1);
    expect(broadcastMock.mock.calls[0][0]).toBe(recipient.id);
  });

  it('INOTIF-004 β€” admin-scope version_available only reaches admins with enabled pref', () => {
    const { user: admin1 } = createAdmin(testDb);
    const { user: admin2 } = createAdmin(testDb);

    // admin2 disables version_available inapp notifications
    disableNotificationPref(testDb, admin2.id, 'version_available', 'inapp');

    const ids = createNotification({
      type: 'navigate',
      scope: 'admin',
      target: 0,
      sender_id: null,
      event_type: 'version_available',
      title_key: 'notifications.versionAvailable.title',
      text_key: 'notifications.versionAvailable.text',
      navigate_text_key: 'notifications.versionAvailable.button',
      navigate_target: '/admin',
    });

    // Only admin1 should receive it
    expect(ids.length).toBe(1);
    const admin1Row = testDb.prepare('SELECT id FROM notifications WHERE recipient_id = ?').get(admin1.id);
    const admin2Row = testDb.prepare('SELECT id FROM notifications WHERE recipient_id = ?').get(admin2.id);
    expect(admin1Row).toBeDefined();
    expect(admin2Row).toBeUndefined();
  });
});

// ─────────────────────────────────────────────────────────────────────────────
// respondToBoolean
// ─────────────────────────────────────────────────────────────────────────────

function insertBooleanNotification(recipientId: number, senderId: number | null = null): number {
  const result = testDb.prepare(`
    INSERT INTO notifications (
      type, scope, target, sender_id, recipient_id,
      title_key, title_params, text_key, text_params,
      positive_text_key, negative_text_key, positive_callback, negative_callback
    ) VALUES ('boolean', 'user', ?, ?, ?, 'notif.test.title', '{}', 'notif.test.text', '{}',
      'notif.action.accept', 'notif.action.decline',
      '{"action":"test_approve","payload":{}}', '{"action":"test_deny","payload":{}}'
    )
  `).run(recipientId, senderId, recipientId);
  return result.lastInsertRowid as number;
}

function insertSimpleNotification(recipientId: number): number {
  const result = testDb.prepare(`
    INSERT INTO notifications (
      type, scope, target, sender_id, recipient_id,
      title_key, title_params, text_key, text_params
    ) VALUES ('simple', 'user', ?, NULL, ?, 'notif.test.title', '{}', 'notif.test.text', '{}')
  `).run(recipientId, recipientId);
  return result.lastInsertRowid as number;
}

describe('respondToBoolean', () => {
  it('INOTIF-005 β€” positive response sets response=positive, marks read, broadcasts update', async () => {
    const { user } = createUser(testDb);
    const id = insertBooleanNotification(user.id);

    const result = await respondToBoolean(id, user.id, 'positive');

    expect(result.success).toBe(true);
    expect(result.notification).toBeDefined();
    const row = testDb.prepare('SELECT * FROM notifications WHERE id = ?').get(id) as any;
    expect(row.response).toBe('positive');
    expect(row.is_read).toBe(1);
    expect(broadcastMock).toHaveBeenCalledWith(user.id, expect.objectContaining({ type: 'notification:updated' }));
  });

  it('INOTIF-006 β€” negative response sets response=negative', async () => {
    const { user } = createUser(testDb);
    const id = insertBooleanNotification(user.id);

    const result = await respondToBoolean(id, user.id, 'negative');

    expect(result.success).toBe(true);
    const row = testDb.prepare('SELECT response FROM notifications WHERE id = ?').get(id) as any;
    expect(row.response).toBe('negative');
  });

  it('INOTIF-007 β€” double-response prevention returns error on second call', async () => {
    const { user } = createUser(testDb);
    const id = insertBooleanNotification(user.id);

    await respondToBoolean(id, user.id, 'positive');
    const result = await respondToBoolean(id, user.id, 'negative');

    expect(result.success).toBe(false);
    expect(result.error).toMatch(/already responded/i);
  });

  it('INOTIF-008 β€” response on a simple notification returns error', async () => {
    const { user } = createUser(testDb);
    const id = insertSimpleNotification(user.id);

    const result = await respondToBoolean(id, user.id, 'positive');

    expect(result.success).toBe(false);
    expect(result.error).toMatch(/not a boolean/i);
  });

  it('INOTIF-009 β€” response on a non-existent notification returns error', async () => {
    const { user } = createUser(testDb);
    const result = await respondToBoolean(99999, user.id, 'positive');
    expect(result.success).toBe(false);
    expect(result.error).toMatch(/not found/i);
  });

  it('INOTIF-010 β€” response on notification belonging to another user returns error', async () => {
    const { user: owner } = createUser(testDb);
    const { user: other } = createUser(testDb);
    const id = insertBooleanNotification(owner.id);

    const result = await respondToBoolean(id, other.id, 'positive');

    expect(result.success).toBe(false);
    expect(result.error).toMatch(/not found/i);
  });
});