File size: 8,392 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
import { describe, it, expect, vi } from 'vitest';
import { HttpException } from '@nestjs/common';
import { NotificationsController } from '../../../src/nest/notifications/notifications.controller';
import type { NotificationsService } from '../../../src/nest/notifications/notifications.service';
import type { User } from '../../../src/types';

const MASKED = '••••••••';
const user = { id: 4, role: 'user', email: 'u@example.test' } as User;
const admin = { id: 1, role: 'admin', email: 'admin@example.test' } as User;

function makeController(svc: Partial<NotificationsService>) {
  return new NotificationsController(svc as NotificationsService);
}

async function thrown(fn: () => unknown): Promise<{ status: number; body: unknown }> {
  try {
    await fn();
  } catch (err) {
    expect(err).toBeInstanceOf(HttpException);
    const e = err as HttpException;
    return { status: e.getStatus(), body: e.getResponse() };
  }
  throw new Error('expected the handler to throw');
}

describe('NotificationsController (parity with the legacy /api/notifications route)', () => {
  describe('preferences', () => {
    it('GET returns the matrix for the user', () => {
      const getPreferences = vi.fn().mockReturnValue({ preferences: {} });
      expect(makeController({ getPreferences }).getPreferences(user)).toEqual({ preferences: {} });
      expect(getPreferences).toHaveBeenCalledWith(4, 'user');
    });

    it('PUT saves then returns the refreshed matrix', () => {
      const setPreferences = vi.fn();
      const getPreferences = vi.fn().mockReturnValue({ preferences: { a: { inapp: true } } });
      const body = { a: { inapp: true } };
      expect(makeController({ setPreferences, getPreferences }).setPreferences(user, body)).toEqual({ preferences: { a: { inapp: true } } });
      expect(setPreferences).toHaveBeenCalledWith(4, body);
    });
  });

  describe('test-smtp', () => {
    it('403 { error: Admin only } for a non-admin (distinct from AdminGuard wording)', async () => {
      const testSmtp = vi.fn();
      expect(await thrown(() => makeController({ testSmtp }).testSmtp(user))).toEqual({
        status: 403, body: { error: 'Admin only' },
      });
      expect(testSmtp).not.toHaveBeenCalled();
    });

    it('falls back to the admin\'s own email when none given', async () => {
      const testSmtp = vi.fn().mockResolvedValue({ success: true });
      await makeController({ testSmtp }).testSmtp(admin);
      expect(testSmtp).toHaveBeenCalledWith('admin@example.test');
    });
  });

  describe('test-webhook', () => {
    it('uses the provided url', async () => {
      const testWebhook = vi.fn().mockResolvedValue({ success: true });
      await makeController({ testWebhook }).testWebhook(user, 'https://hooks.example/x');
      expect(testWebhook).toHaveBeenCalledWith('https://hooks.example/x');
    });

    it('falls back to the saved user url when the masked placeholder is sent', async () => {
      const testWebhook = vi.fn().mockResolvedValue({ success: true });
      const userWebhookUrl = vi.fn().mockReturnValue('https://saved.example/u');
      await makeController({ testWebhook, userWebhookUrl }).testWebhook(user, MASKED);
      expect(userWebhookUrl).toHaveBeenCalledWith(4);
      expect(testWebhook).toHaveBeenCalledWith('https://saved.example/u');
    });

    it('400 when no url is configured', async () => {
      const userWebhookUrl = vi.fn().mockReturnValue(null);
      expect(await thrown(() => makeController({ userWebhookUrl }).testWebhook(user, undefined))).toEqual({
        status: 400, body: { error: 'No webhook URL configured' },
      });
    });

    it('400 on an invalid url', async () => {
      expect(await thrown(() => makeController({}).testWebhook(user, 'not a url'))).toEqual({
        status: 400, body: { error: 'Invalid URL' },
      });
    });
  });

  describe('test-ntfy', () => {
    it('400 when no topic can be resolved', async () => {
      const userNtfyConfig = vi.fn().mockReturnValue(null);
      const adminNtfyConfig = vi.fn().mockReturnValue({ server: null, token: null });
      expect(await thrown(() => makeController({ userNtfyConfig, adminNtfyConfig }).testNtfy(user))).toEqual({
        status: 400, body: { error: 'No ntfy topic configured' },
      });
    });

    it('resolves topic/server/token with fallbacks and reuses a saved token for the placeholder', async () => {
      const testNtfy = vi.fn().mockResolvedValue({ success: true });
      const userNtfyConfig = vi.fn().mockReturnValue({ topic: 'saved-topic', server: 'https://ntfy.me', token: 'saved-token' });
      const adminNtfyConfig = vi.fn().mockReturnValue({ server: null, token: null });
      await makeController({ testNtfy, userNtfyConfig, adminNtfyConfig }).testNtfy(user, undefined, undefined, MASKED);
      expect(testNtfy).toHaveBeenCalledWith({ topic: 'saved-topic', server: 'https://ntfy.me', token: 'saved-token' });
    });
  });

  describe('in-app list + counts', () => {
    it('clamps limit to 50 and defaults offset/unread', () => {
      const listInApp = vi.fn().mockReturnValue({ notifications: [], total: 0, unread_count: 0 });
      makeController({ listInApp }).listInApp(user, '100', '5', 'true');
      expect(listInApp).toHaveBeenCalledWith(4, { limit: 50, offset: 5, unreadOnly: true });
    });

    it('defaults limit to 20 when absent/non-numeric', () => {
      const listInApp = vi.fn().mockReturnValue({ notifications: [], total: 0, unread_count: 0 });
      makeController({ listInApp }).listInApp(user, undefined, undefined, undefined);
      expect(listInApp).toHaveBeenCalledWith(4, { limit: 20, offset: 0, unreadOnly: false });
    });

    it('GET unread-count wraps the number', () => {
      const unreadCount = vi.fn().mockReturnValue(7);
      expect(makeController({ unreadCount }).unreadCount(user)).toEqual({ count: 7 });
    });
  });

  describe('bulk + single mutations', () => {
    it('read-all returns success + count', () => {
      const markAllRead = vi.fn().mockReturnValue(3);
      expect(makeController({ markAllRead }).readAll(user)).toEqual({ success: true, count: 3 });
    });

    it('delete-all returns success + count', () => {
      const deleteAll = vi.fn().mockReturnValue(5);
      expect(makeController({ deleteAll }).deleteAll(user)).toEqual({ success: true, count: 5 });
    });

    it('400 on a non-numeric id', () => {
      const markRead = vi.fn();
      return thrown(() => makeController({ markRead }).markRead(user, 'abc')).then((r) =>
        expect(r).toEqual({ status: 400, body: { error: 'Invalid id' } }));
    });

    it('404 when mark-read finds nothing', async () => {
      const markRead = vi.fn().mockReturnValue(false);
      expect(await thrown(() => makeController({ markRead }).markRead(user, '9'))).toEqual({
        status: 404, body: { error: 'Not found' },
      });
    });

    it('mark-read success', () => {
      const markRead = vi.fn().mockReturnValue(true);
      expect(makeController({ markRead }).markRead(user, '5')).toEqual({ success: true });
      expect(markRead).toHaveBeenCalledWith(5, 4);
    });

    it('delete single success', () => {
      const deleteOne = vi.fn().mockReturnValue(true);
      expect(makeController({ deleteOne }).deleteOne(user, '5')).toEqual({ success: true });
    });
  });

  describe('respond', () => {
    it('400 on an invalid response value', async () => {
      expect(await thrown(() => makeController({}).respond(user, '5', 'maybe'))).toEqual({
        status: 400, body: { error: 'response must be "positive" or "negative"' },
      });
    });

    it('400 with the service error when the response fails', async () => {
      const respond = vi.fn().mockResolvedValue({ success: false, error: 'Already responded' });
      expect(await thrown(() => makeController({ respond }).respond(user, '5', 'positive'))).toEqual({
        status: 400, body: { error: 'Already responded' },
      });
    });

    it('returns success + the updated notification', async () => {
      const respond = vi.fn().mockResolvedValue({ success: true, notification: { id: 5, response: 'positive' } });
      expect(await makeController({ respond }).respond(user, '5', 'positive')).toEqual({
        success: true, notification: { id: 5, response: 'positive' },
      });
      expect(respond).toHaveBeenCalledWith(5, 4, 'positive');
    });
  });
});