File size: 11,577 Bytes
46252cd
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
import { generateIdempotencyKey, generateDeliveryId } from './idempotency.util';

describe('Idempotency Utils', () => {
  describe('generateIdempotencyKey', () => {
    it('should generate a session-scoped key for message.received', () => {
      const key = generateIdempotencyKey('message.received', { messageId: 'ABC123', sessionId: 'A' });
      expect(key).toBe('msg_A_ABC123');
    });

    it('falls back to the legacy `ack` integer for message.ack when no `status` is present', () => {
      const key = generateIdempotencyKey('message.ack', { messageId: 'ABC123', ack: 3, sessionId: 'A' });
      expect(key).toBe('ack_A_ABC123_3');
    });

    it('should use the IncomingMessage `id` field for message.received (the real dispatch shape)', () => {
      // session.service dispatches the IncomingMessage object, which carries `id`, not `messageId`.
      const key = generateIdempotencyKey('message.received', { id: 'ABC123', sessionId: 'A' });
      expect(key).toBe('msg_A_ABC123');
    });

    it('should prefer `id` over a legacy `messageId` when both are present for message.received', () => {
      const key = generateIdempotencyKey('message.received', { id: 'REAL', messageId: 'LEGACY', sessionId: 'A' });
      expect(key).toBe('msg_A_REAL');
    });

    it('keys message.ack on the neutral `status` (the real dispatch shape), preferring it over `ack`', () => {
      const key = generateIdempotencyKey('message.ack', { id: 'ABC123', status: 'read', ack: 3, sessionId: 'A' });
      expect(key).toBe('ack_A_ABC123_read');
    });

    it('should use the `id` field for message.revoked (the real dispatch shape)', () => {
      const key = generateIdempotencyKey('message.revoked', { id: 'ABC123', sessionId: 'A' });
      expect(key).toBe('rev_A_ABC123');
    });

    it('gives the same waMessageId in different sessions DISTINCT keys', () => {
      const a = generateIdempotencyKey('message.ack', { id: 'X', status: 'delivered', sessionId: 'A' });
      const b = generateIdempotencyKey('message.ack', { id: 'X', status: 'delivered', sessionId: 'B' });
      expect(a).not.toBe(b);
    });

    it('should generate key for session.status', () => {
      const key = generateIdempotencyKey('session.status', {
        sessionId: 'sess_1',
        status: 'CONNECTED',
      });
      expect(key).toBe('sess_sess_1_CONNECTED');
    });

    it('salts session.status keys with the occurrence time so repeated transitions to the same status stay distinct', () => {
      const a = generateIdempotencyKey(
        'session.status',
        { sessionId: 'A', status: 'DISCONNECTED' },
        '2026-06-19T00:00:00.000Z',
      );
      const b = generateIdempotencyKey(
        'session.status',
        { sessionId: 'A', status: 'DISCONNECTED' },
        '2026-06-19T02:00:00.000Z',
      );
      expect(a).not.toBe(b);
    });

    it('salts session.authenticated keys so re-authentication (same phone, later time) is a distinct event', () => {
      const a = generateIdempotencyKey(
        'session.authenticated',
        { sessionId: 'A', phone: '628', pushName: 'Me' },
        '2026-06-19T00:00:00.000Z',
      );
      const b = generateIdempotencyKey(
        'session.authenticated',
        { sessionId: 'A', phone: '628', pushName: 'Me' },
        '2026-06-19T01:00:00.000Z',
      );
      expect(a).not.toBe(b);
    });

    it('salts session.disconnected keys so repeat disconnects with the same reason stay distinct', () => {
      const a = generateIdempotencyKey(
        'session.disconnected',
        { sessionId: 'A', reason: 'logged out' },
        '2026-06-19T00:00:00.000Z',
      );
      const b = generateIdempotencyKey(
        'session.disconnected',
        { sessionId: 'A', reason: 'logged out' },
        '2026-06-19T03:00:00.000Z',
      );
      expect(a).not.toBe(b);
    });

    it('is retry-stable: the same lifecycle occurrence regenerates the same key', () => {
      const at = '2026-06-19T00:00:00.000Z';
      const a = generateIdempotencyKey('session.disconnected', { sessionId: 'A', reason: 'logged out' }, at);
      const b = generateIdempotencyKey('session.disconnected', { sessionId: 'A', reason: 'logged out' }, at);
      expect(a).toBe(b);
    });

    it('does not salt message-event keys with the occurrence time (content-based dedup preserved)', () => {
      const a = generateIdempotencyKey(
        'message.ack',
        { id: 'X', status: 'read', sessionId: 'A' },
        '2026-06-19T00:00:00.000Z',
      );
      const b = generateIdempotencyKey(
        'message.ack',
        { id: 'X', status: 'read', sessionId: 'A' },
        '2026-06-19T09:00:00.000Z',
      );
      expect(a).toBe(b);
      expect(a).toBe('ack_A_X_read');
    });

    it('salts message.reaction keys so a re-reaction (same sender/emoji, later time) is a distinct event', () => {
      // A reaction has no unique id and is a read-modify-write: the same sender can go πŸ‘ β†’ remove β†’ πŸ‘.
      // Keying on (sender, message, emoji) alone would collapse the re-reaction onto the earlier one.
      const a = generateIdempotencyKey(
        'message.reaction',
        { sessionId: 'A', messageId: 'MSG1', senderId: '628111@c.us', reaction: 'πŸ‘' },
        '2026-06-20T00:00:00.000Z',
      );
      const b = generateIdempotencyKey(
        'message.reaction',
        { sessionId: 'A', messageId: 'MSG1', senderId: '628111@c.us', reaction: 'πŸ‘' },
        '2026-06-20T00:05:00.000Z',
      );
      expect(a).not.toBe(b);
    });

    it('is retry-stable for message.reaction: the same occurrence regenerates the same key', () => {
      const at = '2026-06-20T00:00:00.000Z';
      const data = { sessionId: 'A', messageId: 'MSG1', senderId: '628111@c.us', reaction: 'πŸ‘' };
      expect(generateIdempotencyKey('message.reaction', data, at)).toBe(
        generateIdempotencyKey('message.reaction', data, at),
      );
    });

    it('gives two senders reacting to the same message DISTINCT message.reaction keys', () => {
      const at = '2026-06-20T00:00:00.000Z';
      const a = generateIdempotencyKey('message.reaction', { sessionId: 'A', messageId: 'M', senderId: 'S1' }, at);
      const b = generateIdempotencyKey('message.reaction', { sessionId: 'A', messageId: 'M', senderId: 'S2' }, at);
      expect(a).not.toBe(b);
    });

    it('salts message.edited keys so an edit (same message, later time) is a distinct event', () => {
      const a = generateIdempotencyKey(
        'message.edited',
        { sessionId: 'A', messageId: 'MSG1' },
        '2026-06-20T00:00:00.000Z',
      );
      const b = generateIdempotencyKey(
        'message.edited',
        { sessionId: 'A', messageId: 'MSG1' },
        '2026-06-20T00:05:00.000Z',
      );
      expect(a).not.toBe(b);
    });

    it('is retry-stable for message.edited: the same occurrence regenerates the same key', () => {
      const at = '2026-06-20T00:00:00.000Z';
      const data = { sessionId: 'A', messageId: 'MSG1' };
      expect(generateIdempotencyKey('message.edited', data, at)).toBe(
        generateIdempotencyKey('message.edited', data, at),
      );
    });

    it('keys group.join on the group + affected participants (the real participantIds payload)', () => {
      const at = '2026-07-20T00:00:00.000Z';
      const key = generateIdempotencyKey(
        'group.join',
        { groupId: '123@g.us', participantIds: ['6281@c.us'], timestamp: 1782000000 },
        at,
      );
      expect(key).toMatch(/^grp_123@g\.us_[a-f0-9]{12}_join_2026-07-20T00:00:00\.000Z$/);
    });

    it('is retry-stable for group.join: the same occurrence regenerates the same key', () => {
      const at = '2026-07-20T00:00:00.000Z';
      const data = { groupId: '123@g.us', participantIds: ['6281@c.us'], timestamp: 1782000000 };
      expect(generateIdempotencyKey('group.join', data, at)).toBe(generateIdempotencyKey('group.join', data, at));
    });

    it('salts group.join keys so a leave-then-rejoin of the same user stays a distinct event', () => {
      const data = { groupId: '123@g.us', participantIds: ['6281@c.us'], timestamp: 1782000000 };
      const a = generateIdempotencyKey('group.join', data, '2026-07-20T00:00:00.000Z');
      const b = generateIdempotencyKey('group.join', data, '2026-07-20T01:00:00.000Z');
      expect(a).not.toBe(b);
    });

    it('gives different participants distinct group.join keys for the same occurrence', () => {
      const at = '2026-07-20T00:00:00.000Z';
      const a = generateIdempotencyKey('group.join', { groupId: '123@g.us', participantIds: ['u1@c.us'] }, at);
      const b = generateIdempotencyKey('group.join', { groupId: '123@g.us', participantIds: ['u2@c.us'] }, at);
      expect(a).not.toBe(b);
    });

    it('keys group.leave symmetrically to group.join (and distinct from it)', () => {
      const at = '2026-07-20T00:00:00.000Z';
      const data = { groupId: '123@g.us', participantIds: ['6281@c.us'] };
      const join = generateIdempotencyKey('group.join', data, at);
      const leave = generateIdempotencyKey('group.leave', data, at);
      expect(leave).toMatch(/^grp_123@g\.us_[a-f0-9]{12}_leave_/);
      expect(leave).not.toBe(join);
    });

    it('keys group.update on WHAT changed, salted per occurrence', () => {
      const at = '2026-07-20T00:00:00.000Z';
      const data = { groupId: '123@g.us', changes: { subject: 'New name' }, timestamp: 1782000000 };
      const a = generateIdempotencyKey('group.update', data, at);
      expect(a).toMatch(/^grp_123@g\.us_update_[a-f0-9]{12}_2026-07-20T00:00:00\.000Z$/);
      // Retry of the same delivery stays stable...
      expect(generateIdempotencyKey('group.update', data, at)).toBe(a);
      // ...a later identical update is a distinct occurrence...
      expect(generateIdempotencyKey('group.update', data, '2026-07-20T00:05:00.000Z')).not.toBe(a);
      // ...and a different change differs even inside the same dispatch window.
      expect(generateIdempotencyKey('group.update', { groupId: '123@g.us', changes: { announce: true } }, at)).not.toBe(
        a,
      );
    });

    it('keys call.received on the session + call id (unique per call, no occurrence salt needed)', () => {
      const at = '2026-07-20T00:00:00.000Z';
      const data = { sessionId: 'A', callId: 'CALL1', from: '628111@c.us', timestamp: 1782000000 };
      const key = generateIdempotencyKey('call.received', data, at);
      expect(key).toBe('call_A_CALL1');
      // Retry of the same dispatch regenerates the same key β€” occurredAt is ignored by design...
      expect(generateIdempotencyKey('call.received', data, '2026-07-20T01:00:00.000Z')).toBe(key);
      // ...while a distinct call (a new occurrence, always a new call id) gets a distinct key.
      expect(generateIdempotencyKey('call.received', { ...data, callId: 'CALL2' }, at)).not.toBe(key);
      // Scoped by session like the message keys: the same call id on another session must not dedupe.
      expect(generateIdempotencyKey('call.received', { ...data, sessionId: 'B' }, at)).not.toBe(key);
    });

    it('should generate fallback key for unknown events', () => {
      const key = generateIdempotencyKey('custom.event', {});
      expect(key).toMatch(/^evt_custom_event_[a-f0-9]{12}$/);
    });
  });

  describe('generateDeliveryId', () => {
    it('should generate unique delivery IDs', () => {
      const id1 = generateDeliveryId();
      const id2 = generateDeliveryId();

      expect(id1).toMatch(/^dlv_[a-f0-9-]{36}$/);
      expect(id2).toMatch(/^dlv_[a-f0-9-]{36}$/);
      expect(id1).not.toBe(id2);
    });
  });
});