@stackflo-labs/n8n-nodes-retainr 0.2.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -451,14 +451,14 @@ class Retainr {
|
|
|
451
451
|
description: 'Filter by agent ID',
|
|
452
452
|
},
|
|
453
453
|
{
|
|
454
|
-
displayName: '
|
|
455
|
-
name: '
|
|
454
|
+
displayName: 'Max Memories',
|
|
455
|
+
name: 'maxMemories',
|
|
456
456
|
type: 'number',
|
|
457
457
|
typeOptions: {
|
|
458
458
|
minValue: 1,
|
|
459
459
|
},
|
|
460
|
-
default:
|
|
461
|
-
description: '
|
|
460
|
+
default: 5,
|
|
461
|
+
description: 'Maximum number of memories to include in the context (API max 20)',
|
|
462
462
|
},
|
|
463
463
|
{
|
|
464
464
|
displayName: 'Namespace',
|
|
@@ -673,10 +673,10 @@ class Retainr {
|
|
|
673
673
|
const returnData = [];
|
|
674
674
|
const resource = this.getNodeParameter('resource', 0);
|
|
675
675
|
const operation = this.getNodeParameter('operation', 0);
|
|
676
|
+
const credentials = await this.getCredentials('retainrApi');
|
|
677
|
+
const baseUrl = credentials.baseUrl.replace(/\/+$/, '');
|
|
676
678
|
for (let i = 0; i < items.length; i++) {
|
|
677
679
|
try {
|
|
678
|
-
const credentials = await this.getCredentials('retainrApi');
|
|
679
|
-
const baseUrl = credentials.baseUrl.replace(/\/+$/, '');
|
|
680
680
|
let responseData;
|
|
681
681
|
// --------------------------------------------------------------
|
|
682
682
|
// Memory
|
|
@@ -837,8 +837,8 @@ async function getContext(i, baseUrl) {
|
|
|
837
837
|
body.namespace = additional.namespace;
|
|
838
838
|
if (additional.tags)
|
|
839
839
|
body.tags = parseTags(additional.tags);
|
|
840
|
-
if (additional.
|
|
841
|
-
body.limit = additional.
|
|
840
|
+
if (additional.maxMemories)
|
|
841
|
+
body.limit = additional.maxMemories;
|
|
842
842
|
if (additional.threshold)
|
|
843
843
|
body.threshold = additional.threshold;
|
|
844
844
|
return apiRequest.call(this, 'POST', baseUrl, '/v1/memories/context', body);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,503 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const Retainr_node_1 = require("../Retainr.node");
|
|
4
|
+
const BASE_URL = 'https://api.retainr.dev';
|
|
5
|
+
const API_KEY = 'rec_live_testkey_base58';
|
|
6
|
+
function createMock(parameters, mockResponse = { id: 'mem_test123' }, continueOnFail = false) {
|
|
7
|
+
const httpRequestWithAuthentication = jest
|
|
8
|
+
.fn()
|
|
9
|
+
.mockResolvedValue(mockResponse);
|
|
10
|
+
return {
|
|
11
|
+
getInputData: () => [{ json: {}, pairedItem: 0 }],
|
|
12
|
+
getNodeParameter: (name, _index) => parameters[name],
|
|
13
|
+
getCredentials: jest.fn().mockResolvedValue({
|
|
14
|
+
apiKey: API_KEY,
|
|
15
|
+
baseUrl: BASE_URL,
|
|
16
|
+
}),
|
|
17
|
+
helpers: { httpRequestWithAuthentication },
|
|
18
|
+
continueOnFail: () => continueOnFail,
|
|
19
|
+
getNode: () => ({
|
|
20
|
+
name: 'Retainr',
|
|
21
|
+
type: 'n8n-nodes-retainr.retainr',
|
|
22
|
+
id: 'n1',
|
|
23
|
+
position: [0, 0],
|
|
24
|
+
parameters: {},
|
|
25
|
+
}),
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
/** Extract the IHttpRequestOptions passed to httpRequestWithAuthentication */
|
|
29
|
+
function getRequestOptions(mock) {
|
|
30
|
+
return mock.helpers.httpRequestWithAuthentication.mock.calls[0][1];
|
|
31
|
+
}
|
|
32
|
+
describe('Retainr node', () => {
|
|
33
|
+
const node = new Retainr_node_1.Retainr();
|
|
34
|
+
// ── Memory > Store ──────────────────────────────────────────────────────
|
|
35
|
+
describe('Memory > Store', () => {
|
|
36
|
+
it('POSTs to /v1/memories with user scope', async () => {
|
|
37
|
+
const mock = createMock({
|
|
38
|
+
resource: 'memory',
|
|
39
|
+
operation: 'store',
|
|
40
|
+
content: 'User prefers dark mode',
|
|
41
|
+
scope: 'user',
|
|
42
|
+
userId: 'user-abc-123',
|
|
43
|
+
storeAdditionalFields: {},
|
|
44
|
+
});
|
|
45
|
+
const result = await node.execute.call(mock);
|
|
46
|
+
const opts = getRequestOptions(mock);
|
|
47
|
+
expect(opts.method).toBe('POST');
|
|
48
|
+
expect(opts.url).toBe(`${BASE_URL}/v1/memories`);
|
|
49
|
+
expect(opts.body).toEqual({
|
|
50
|
+
content: 'User prefers dark mode',
|
|
51
|
+
scope: 'user',
|
|
52
|
+
user_id: 'user-abc-123',
|
|
53
|
+
});
|
|
54
|
+
expect(result[0][0].json).toEqual({ id: 'mem_test123' });
|
|
55
|
+
});
|
|
56
|
+
it('POSTs with session scope and session_id', async () => {
|
|
57
|
+
const mock = createMock({
|
|
58
|
+
resource: 'memory',
|
|
59
|
+
operation: 'store',
|
|
60
|
+
content: 'Workflow context',
|
|
61
|
+
scope: 'session',
|
|
62
|
+
sessionId: 'run-xyz-789',
|
|
63
|
+
storeAdditionalFields: {},
|
|
64
|
+
});
|
|
65
|
+
await node.execute.call(mock);
|
|
66
|
+
const opts = getRequestOptions(mock);
|
|
67
|
+
expect(opts.body).toEqual(expect.objectContaining({
|
|
68
|
+
scope: 'session',
|
|
69
|
+
session_id: 'run-xyz-789',
|
|
70
|
+
}));
|
|
71
|
+
});
|
|
72
|
+
it('POSTs with agent scope and agent_id', async () => {
|
|
73
|
+
const mock = createMock({
|
|
74
|
+
resource: 'memory',
|
|
75
|
+
operation: 'store',
|
|
76
|
+
content: 'Agent instruction',
|
|
77
|
+
scope: 'agent',
|
|
78
|
+
agentId: 'crm-bot',
|
|
79
|
+
storeAdditionalFields: {},
|
|
80
|
+
});
|
|
81
|
+
await node.execute.call(mock);
|
|
82
|
+
const opts = getRequestOptions(mock);
|
|
83
|
+
expect(opts.body).toEqual(expect.objectContaining({
|
|
84
|
+
scope: 'agent',
|
|
85
|
+
agent_id: 'crm-bot',
|
|
86
|
+
}));
|
|
87
|
+
});
|
|
88
|
+
it('POSTs with global scope (no scope-specific ID)', async () => {
|
|
89
|
+
const mock = createMock({
|
|
90
|
+
resource: 'memory',
|
|
91
|
+
operation: 'store',
|
|
92
|
+
content: 'Global note',
|
|
93
|
+
scope: 'global',
|
|
94
|
+
storeAdditionalFields: {},
|
|
95
|
+
});
|
|
96
|
+
await node.execute.call(mock);
|
|
97
|
+
const body = getRequestOptions(mock).body;
|
|
98
|
+
expect(body.scope).toBe('global');
|
|
99
|
+
expect(body).not.toHaveProperty('user_id');
|
|
100
|
+
expect(body).not.toHaveProperty('session_id');
|
|
101
|
+
expect(body).not.toHaveProperty('agent_id');
|
|
102
|
+
});
|
|
103
|
+
it('includes ttl_seconds from additional fields', async () => {
|
|
104
|
+
const mock = createMock({
|
|
105
|
+
resource: 'memory',
|
|
106
|
+
operation: 'store',
|
|
107
|
+
content: 'Ephemeral',
|
|
108
|
+
scope: 'global',
|
|
109
|
+
storeAdditionalFields: { ttlSeconds: 3600 },
|
|
110
|
+
});
|
|
111
|
+
await node.execute.call(mock);
|
|
112
|
+
const body = getRequestOptions(mock).body;
|
|
113
|
+
expect(body.ttl_seconds).toBe(3600);
|
|
114
|
+
});
|
|
115
|
+
it('omits ttl_seconds when 0 (falsy)', async () => {
|
|
116
|
+
const mock = createMock({
|
|
117
|
+
resource: 'memory',
|
|
118
|
+
operation: 'store',
|
|
119
|
+
content: 'Permanent',
|
|
120
|
+
scope: 'global',
|
|
121
|
+
storeAdditionalFields: { ttlSeconds: 0 },
|
|
122
|
+
});
|
|
123
|
+
await node.execute.call(mock);
|
|
124
|
+
const body = getRequestOptions(mock).body;
|
|
125
|
+
expect(body).not.toHaveProperty('ttl_seconds');
|
|
126
|
+
});
|
|
127
|
+
it('parses comma-separated tags into array', async () => {
|
|
128
|
+
const mock = createMock({
|
|
129
|
+
resource: 'memory',
|
|
130
|
+
operation: 'store',
|
|
131
|
+
content: 'Tagged memory',
|
|
132
|
+
scope: 'global',
|
|
133
|
+
storeAdditionalFields: {
|
|
134
|
+
tags: 'preference, communication, important',
|
|
135
|
+
},
|
|
136
|
+
});
|
|
137
|
+
await node.execute.call(mock);
|
|
138
|
+
const body = getRequestOptions(mock).body;
|
|
139
|
+
expect(body.tags).toEqual([
|
|
140
|
+
'preference',
|
|
141
|
+
'communication',
|
|
142
|
+
'important',
|
|
143
|
+
]);
|
|
144
|
+
});
|
|
145
|
+
it('omits tags when empty string', async () => {
|
|
146
|
+
const mock = createMock({
|
|
147
|
+
resource: 'memory',
|
|
148
|
+
operation: 'store',
|
|
149
|
+
content: 'No tags',
|
|
150
|
+
scope: 'global',
|
|
151
|
+
storeAdditionalFields: { tags: '' },
|
|
152
|
+
});
|
|
153
|
+
await node.execute.call(mock);
|
|
154
|
+
const body = getRequestOptions(mock).body;
|
|
155
|
+
expect(body).not.toHaveProperty('tags');
|
|
156
|
+
});
|
|
157
|
+
it('includes dedup_threshold when set', async () => {
|
|
158
|
+
const mock = createMock({
|
|
159
|
+
resource: 'memory',
|
|
160
|
+
operation: 'store',
|
|
161
|
+
content: 'Dedup test',
|
|
162
|
+
scope: 'global',
|
|
163
|
+
storeAdditionalFields: { dedupThreshold: 0.95 },
|
|
164
|
+
});
|
|
165
|
+
await node.execute.call(mock);
|
|
166
|
+
const body = getRequestOptions(mock).body;
|
|
167
|
+
expect(body.dedup_threshold).toBe(0.95);
|
|
168
|
+
});
|
|
169
|
+
it('includes namespace when set', async () => {
|
|
170
|
+
const mock = createMock({
|
|
171
|
+
resource: 'memory',
|
|
172
|
+
operation: 'store',
|
|
173
|
+
content: 'Namespaced',
|
|
174
|
+
scope: 'global',
|
|
175
|
+
storeAdditionalFields: { namespace: 'onboarding' },
|
|
176
|
+
});
|
|
177
|
+
await node.execute.call(mock);
|
|
178
|
+
const body = getRequestOptions(mock).body;
|
|
179
|
+
expect(body.namespace).toBe('onboarding');
|
|
180
|
+
});
|
|
181
|
+
it('parses metadata JSON string', async () => {
|
|
182
|
+
const mock = createMock({
|
|
183
|
+
resource: 'memory',
|
|
184
|
+
operation: 'store',
|
|
185
|
+
content: 'With metadata',
|
|
186
|
+
scope: 'global',
|
|
187
|
+
storeAdditionalFields: {
|
|
188
|
+
metadata: '{"source":"n8n","version":2}',
|
|
189
|
+
},
|
|
190
|
+
});
|
|
191
|
+
await node.execute.call(mock);
|
|
192
|
+
const body = getRequestOptions(mock).body;
|
|
193
|
+
expect(body.metadata).toEqual({ source: 'n8n', version: 2 });
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
// ── Memory > Search ─────────────────────────────────────────────────────
|
|
197
|
+
describe('Memory > Search', () => {
|
|
198
|
+
it('POSTs to /v1/memories/search with query', async () => {
|
|
199
|
+
const mock = createMock({
|
|
200
|
+
resource: 'memory',
|
|
201
|
+
operation: 'search',
|
|
202
|
+
query: 'user preferences',
|
|
203
|
+
searchAdditionalFields: {
|
|
204
|
+
scope: 'user',
|
|
205
|
+
userId: 'user-abc-123',
|
|
206
|
+
limit: 3,
|
|
207
|
+
threshold: 0.8,
|
|
208
|
+
},
|
|
209
|
+
}, {
|
|
210
|
+
results: [
|
|
211
|
+
{ id: 'mem_1', content: 'dark mode', score: 0.92 },
|
|
212
|
+
],
|
|
213
|
+
});
|
|
214
|
+
const result = await node.execute.call(mock);
|
|
215
|
+
const opts = getRequestOptions(mock);
|
|
216
|
+
expect(opts.method).toBe('POST');
|
|
217
|
+
expect(opts.url).toBe(`${BASE_URL}/v1/memories/search`);
|
|
218
|
+
expect(opts.body).toEqual({
|
|
219
|
+
query: 'user preferences',
|
|
220
|
+
scope: 'user',
|
|
221
|
+
user_id: 'user-abc-123',
|
|
222
|
+
limit: 3,
|
|
223
|
+
threshold: 0.8,
|
|
224
|
+
});
|
|
225
|
+
expect(result[0][0].json).toEqual({
|
|
226
|
+
results: [
|
|
227
|
+
{ id: 'mem_1', content: 'dark mode', score: 0.92 },
|
|
228
|
+
],
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
it('omits unset optional fields', async () => {
|
|
232
|
+
const mock = createMock({
|
|
233
|
+
resource: 'memory',
|
|
234
|
+
operation: 'search',
|
|
235
|
+
query: 'anything',
|
|
236
|
+
searchAdditionalFields: {},
|
|
237
|
+
});
|
|
238
|
+
await node.execute.call(mock);
|
|
239
|
+
const body = getRequestOptions(mock).body;
|
|
240
|
+
expect(body).toEqual({ query: 'anything' });
|
|
241
|
+
});
|
|
242
|
+
it('includes tags filter', async () => {
|
|
243
|
+
const mock = createMock({
|
|
244
|
+
resource: 'memory',
|
|
245
|
+
operation: 'search',
|
|
246
|
+
query: 'tagged search',
|
|
247
|
+
searchAdditionalFields: { tags: 'preference, urgent' },
|
|
248
|
+
});
|
|
249
|
+
await node.execute.call(mock);
|
|
250
|
+
const body = getRequestOptions(mock).body;
|
|
251
|
+
expect(body.tags).toEqual(['preference', 'urgent']);
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
// ── Memory > Get Context ────────────────────────────────────────────────
|
|
255
|
+
describe('Memory > Get Context', () => {
|
|
256
|
+
it('POSTs to /v1/memories/context with query and format', async () => {
|
|
257
|
+
const mock = createMock({
|
|
258
|
+
resource: 'memory',
|
|
259
|
+
operation: 'getContext',
|
|
260
|
+
query: 'customer summary',
|
|
261
|
+
format: 'system_prompt',
|
|
262
|
+
contextAdditionalFields: {
|
|
263
|
+
scope: 'user',
|
|
264
|
+
userId: 'u-42',
|
|
265
|
+
maxMemories: 10,
|
|
266
|
+
threshold: 0.35,
|
|
267
|
+
},
|
|
268
|
+
}, { context: 'You know the user prefers dark mode.' });
|
|
269
|
+
const result = await node.execute.call(mock);
|
|
270
|
+
const opts = getRequestOptions(mock);
|
|
271
|
+
expect(opts.method).toBe('POST');
|
|
272
|
+
expect(opts.url).toBe(`${BASE_URL}/v1/memories/context`);
|
|
273
|
+
expect(opts.body).toEqual({
|
|
274
|
+
query: 'customer summary',
|
|
275
|
+
format: 'system_prompt',
|
|
276
|
+
scope: 'user',
|
|
277
|
+
user_id: 'u-42',
|
|
278
|
+
limit: 10,
|
|
279
|
+
threshold: 0.35,
|
|
280
|
+
});
|
|
281
|
+
expect(result[0][0].json).toEqual({
|
|
282
|
+
context: 'You know the user prefers dark mode.',
|
|
283
|
+
});
|
|
284
|
+
});
|
|
285
|
+
it('sends only query and format when no additional fields', async () => {
|
|
286
|
+
const mock = createMock({
|
|
287
|
+
resource: 'memory',
|
|
288
|
+
operation: 'getContext',
|
|
289
|
+
query: 'basics',
|
|
290
|
+
format: 'bullet_list',
|
|
291
|
+
contextAdditionalFields: {},
|
|
292
|
+
});
|
|
293
|
+
await node.execute.call(mock);
|
|
294
|
+
const body = getRequestOptions(mock).body;
|
|
295
|
+
expect(body).toEqual({ query: 'basics', format: 'bullet_list' });
|
|
296
|
+
});
|
|
297
|
+
});
|
|
298
|
+
// ── Memory > List ───────────────────────────────────────────────────────
|
|
299
|
+
describe('Memory > List', () => {
|
|
300
|
+
it('GETs /v1/memories with query params', async () => {
|
|
301
|
+
const mock = createMock({
|
|
302
|
+
resource: 'memory',
|
|
303
|
+
operation: 'list',
|
|
304
|
+
listAdditionalFields: {
|
|
305
|
+
scope: 'session',
|
|
306
|
+
sessionId: 'sess-xyz',
|
|
307
|
+
limit: 10,
|
|
308
|
+
},
|
|
309
|
+
}, { memories: [] });
|
|
310
|
+
await node.execute.call(mock);
|
|
311
|
+
const opts = getRequestOptions(mock);
|
|
312
|
+
expect(opts.method).toBe('GET');
|
|
313
|
+
expect(opts.url).toBe(`${BASE_URL}/v1/memories`);
|
|
314
|
+
expect(opts.qs).toEqual({
|
|
315
|
+
scope: 'session',
|
|
316
|
+
session_id: 'sess-xyz',
|
|
317
|
+
limit: 10,
|
|
318
|
+
});
|
|
319
|
+
});
|
|
320
|
+
it('includes offset and namespace', async () => {
|
|
321
|
+
const mock = createMock({
|
|
322
|
+
resource: 'memory',
|
|
323
|
+
operation: 'list',
|
|
324
|
+
listAdditionalFields: {
|
|
325
|
+
scope: 'agent',
|
|
326
|
+
agentId: 'support-bot',
|
|
327
|
+
limit: 20,
|
|
328
|
+
offset: 50,
|
|
329
|
+
namespace: 'tickets',
|
|
330
|
+
},
|
|
331
|
+
});
|
|
332
|
+
await node.execute.call(mock);
|
|
333
|
+
const opts = getRequestOptions(mock);
|
|
334
|
+
expect(opts.qs).toEqual(expect.objectContaining({
|
|
335
|
+
scope: 'agent',
|
|
336
|
+
agent_id: 'support-bot',
|
|
337
|
+
limit: 20,
|
|
338
|
+
offset: 50,
|
|
339
|
+
namespace: 'tickets',
|
|
340
|
+
}));
|
|
341
|
+
});
|
|
342
|
+
it('sends no qs when no additional fields', async () => {
|
|
343
|
+
const mock = createMock({
|
|
344
|
+
resource: 'memory',
|
|
345
|
+
operation: 'list',
|
|
346
|
+
listAdditionalFields: {},
|
|
347
|
+
});
|
|
348
|
+
await node.execute.call(mock);
|
|
349
|
+
const opts = getRequestOptions(mock);
|
|
350
|
+
// No qs should be set (or empty)
|
|
351
|
+
expect(opts.qs).toBeUndefined();
|
|
352
|
+
});
|
|
353
|
+
});
|
|
354
|
+
// ── Memory > Delete ─────────────────────────────────────────────────────
|
|
355
|
+
describe('Memory > Delete', () => {
|
|
356
|
+
it('DELETEs /v1/memories with scope and filter', async () => {
|
|
357
|
+
const mock = createMock({
|
|
358
|
+
resource: 'memory',
|
|
359
|
+
operation: 'delete',
|
|
360
|
+
deleteScope: 'agent',
|
|
361
|
+
deleteAdditionalFields: { agentId: 'crm-bot' },
|
|
362
|
+
}, { deleted: 5 });
|
|
363
|
+
await node.execute.call(mock);
|
|
364
|
+
const opts = getRequestOptions(mock);
|
|
365
|
+
expect(opts.method).toBe('DELETE');
|
|
366
|
+
expect(opts.url).toBe(`${BASE_URL}/v1/memories`);
|
|
367
|
+
expect(opts.body).toEqual({
|
|
368
|
+
scope: 'agent',
|
|
369
|
+
agent_id: 'crm-bot',
|
|
370
|
+
});
|
|
371
|
+
});
|
|
372
|
+
it('DELETEs with user scope', async () => {
|
|
373
|
+
const mock = createMock({
|
|
374
|
+
resource: 'memory',
|
|
375
|
+
operation: 'delete',
|
|
376
|
+
deleteScope: 'user',
|
|
377
|
+
deleteAdditionalFields: { userId: 'churned-user-99' },
|
|
378
|
+
}, { deleted: 12 });
|
|
379
|
+
await node.execute.call(mock);
|
|
380
|
+
const body = getRequestOptions(mock).body;
|
|
381
|
+
expect(body).toEqual({
|
|
382
|
+
scope: 'user',
|
|
383
|
+
user_id: 'churned-user-99',
|
|
384
|
+
});
|
|
385
|
+
});
|
|
386
|
+
it('DELETEs with global scope and namespace', async () => {
|
|
387
|
+
const mock = createMock({
|
|
388
|
+
resource: 'memory',
|
|
389
|
+
operation: 'delete',
|
|
390
|
+
deleteScope: 'global',
|
|
391
|
+
deleteAdditionalFields: { namespace: 'temp' },
|
|
392
|
+
}, { deleted: 3 });
|
|
393
|
+
await node.execute.call(mock);
|
|
394
|
+
const body = getRequestOptions(mock).body;
|
|
395
|
+
expect(body).toEqual({ scope: 'global', namespace: 'temp' });
|
|
396
|
+
});
|
|
397
|
+
});
|
|
398
|
+
// ── Workspace > Get Info ────────────────────────────────────────────────
|
|
399
|
+
describe('Workspace > Get Info', () => {
|
|
400
|
+
it('GETs /v1/workspace', async () => {
|
|
401
|
+
const mock = createMock({
|
|
402
|
+
resource: 'workspace',
|
|
403
|
+
operation: 'getInfo',
|
|
404
|
+
}, {
|
|
405
|
+
workspace_id: 'ws_abc',
|
|
406
|
+
plan: 'pro',
|
|
407
|
+
memory_count: 42,
|
|
408
|
+
});
|
|
409
|
+
const result = await node.execute.call(mock);
|
|
410
|
+
const opts = getRequestOptions(mock);
|
|
411
|
+
expect(opts.method).toBe('GET');
|
|
412
|
+
expect(opts.url).toBe(`${BASE_URL}/v1/workspace`);
|
|
413
|
+
expect(result[0][0].json).toEqual({
|
|
414
|
+
workspace_id: 'ws_abc',
|
|
415
|
+
plan: 'pro',
|
|
416
|
+
memory_count: 42,
|
|
417
|
+
});
|
|
418
|
+
});
|
|
419
|
+
});
|
|
420
|
+
// ── Error handling ──────────────────────────────────────────────────────
|
|
421
|
+
describe('error handling', () => {
|
|
422
|
+
it('returns error item when continueOnFail is true', async () => {
|
|
423
|
+
const mock = createMock({
|
|
424
|
+
resource: 'memory',
|
|
425
|
+
operation: 'store',
|
|
426
|
+
content: 'test',
|
|
427
|
+
scope: 'global',
|
|
428
|
+
storeAdditionalFields: {},
|
|
429
|
+
}, undefined, true);
|
|
430
|
+
mock.helpers.httpRequestWithAuthentication.mockRejectedValue(new Error('Connection refused'));
|
|
431
|
+
const result = await node.execute.call(mock);
|
|
432
|
+
expect(result[0][0].json).toEqual({
|
|
433
|
+
error: 'Connection refused',
|
|
434
|
+
});
|
|
435
|
+
});
|
|
436
|
+
it('throws NodeApiError when continueOnFail is false', async () => {
|
|
437
|
+
const mock = createMock({
|
|
438
|
+
resource: 'memory',
|
|
439
|
+
operation: 'store',
|
|
440
|
+
content: 'test',
|
|
441
|
+
scope: 'global',
|
|
442
|
+
storeAdditionalFields: {},
|
|
443
|
+
}, undefined, false);
|
|
444
|
+
mock.helpers.httpRequestWithAuthentication.mockRejectedValue(new Error('Unauthorized'));
|
|
445
|
+
await expect(node.execute.call(mock)).rejects.toThrow();
|
|
446
|
+
});
|
|
447
|
+
});
|
|
448
|
+
// ── Multiple items ──────────────────────────────────────────────────────
|
|
449
|
+
describe('multiple input items', () => {
|
|
450
|
+
it('processes each item independently', async () => {
|
|
451
|
+
const items = [
|
|
452
|
+
{ content: 'first memory', userId: 'u1' },
|
|
453
|
+
{ content: 'second memory', userId: 'u2' },
|
|
454
|
+
];
|
|
455
|
+
const httpMock = jest.fn().mockResolvedValue({ id: 'ok' });
|
|
456
|
+
const mock = {
|
|
457
|
+
getInputData: () => items.map((item) => ({ json: item, pairedItem: 0 })),
|
|
458
|
+
getNodeParameter: (name, i) => {
|
|
459
|
+
if (name === 'resource')
|
|
460
|
+
return 'memory';
|
|
461
|
+
if (name === 'operation')
|
|
462
|
+
return 'store';
|
|
463
|
+
if (name === 'content')
|
|
464
|
+
return items[i].content;
|
|
465
|
+
if (name === 'scope')
|
|
466
|
+
return 'user';
|
|
467
|
+
if (name === 'userId')
|
|
468
|
+
return items[i].userId;
|
|
469
|
+
if (name === 'storeAdditionalFields')
|
|
470
|
+
return {};
|
|
471
|
+
return undefined;
|
|
472
|
+
},
|
|
473
|
+
getCredentials: jest.fn().mockResolvedValue({
|
|
474
|
+
apiKey: API_KEY,
|
|
475
|
+
baseUrl: BASE_URL,
|
|
476
|
+
}),
|
|
477
|
+
helpers: { httpRequestWithAuthentication: httpMock },
|
|
478
|
+
continueOnFail: () => false,
|
|
479
|
+
getNode: () => ({
|
|
480
|
+
name: 'Retainr',
|
|
481
|
+
type: 'n8n-nodes-retainr.retainr',
|
|
482
|
+
id: 'n1',
|
|
483
|
+
position: [0, 0],
|
|
484
|
+
parameters: {},
|
|
485
|
+
}),
|
|
486
|
+
};
|
|
487
|
+
const result = await node.execute.call(mock);
|
|
488
|
+
expect(httpMock).toHaveBeenCalledTimes(2);
|
|
489
|
+
expect(result[0]).toHaveLength(2);
|
|
490
|
+
});
|
|
491
|
+
});
|
|
492
|
+
// ── Uses httpRequestWithAuthentication ───────────────────────────────────
|
|
493
|
+
describe('authentication', () => {
|
|
494
|
+
it('calls httpRequestWithAuthentication with retainrApi credential name', async () => {
|
|
495
|
+
const mock = createMock({
|
|
496
|
+
resource: 'workspace',
|
|
497
|
+
operation: 'getInfo',
|
|
498
|
+
});
|
|
499
|
+
await node.execute.call(mock);
|
|
500
|
+
expect(mock.helpers.httpRequestWithAuthentication).toHaveBeenCalledWith('retainrApi', expect.any(Object));
|
|
501
|
+
});
|
|
502
|
+
});
|
|
503
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stackflo-labs/n8n-nodes-retainr",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "git+https://github.com/stackflo-labs/n8n-nodes-retainr.git"
|
|
@@ -28,7 +28,14 @@
|
|
|
28
28
|
"scripts": {
|
|
29
29
|
"build": "tsc && copyfiles -u 1 \"src/**/*.svg\" dist",
|
|
30
30
|
"lint": "npx @n8n/scan-community-package @stackflo-labs/n8n-nodes-retainr",
|
|
31
|
-
"clean": "rimraf dist"
|
|
31
|
+
"clean": "rimraf dist",
|
|
32
|
+
"test": "jest",
|
|
33
|
+
"test:coverage": "jest --coverage",
|
|
34
|
+
"typecheck": "tsc --noEmit",
|
|
35
|
+
"e2e": "node e2e/integration-test.mjs",
|
|
36
|
+
"e2e:up": "docker compose -f e2e/docker-compose.yml up --build -d",
|
|
37
|
+
"e2e:down": "docker compose -f e2e/docker-compose.yml down",
|
|
38
|
+
"e2e:run": "npm run e2e:up && npm run e2e -- && npm run e2e:down"
|
|
32
39
|
},
|
|
33
40
|
"publishConfig": {
|
|
34
41
|
"access": "public"
|
|
@@ -48,8 +55,12 @@
|
|
|
48
55
|
"n8n-workflow": ">=1.0.0"
|
|
49
56
|
},
|
|
50
57
|
"devDependencies": {
|
|
58
|
+
"@types/jest": "^29.5.0",
|
|
51
59
|
"copyfiles": "^2.4.1",
|
|
60
|
+
"jest": "^29.7.0",
|
|
52
61
|
"n8n-workflow": "^1.70.0",
|
|
62
|
+
"rimraf": "^5.0.0",
|
|
63
|
+
"ts-jest": "^29.2.0",
|
|
53
64
|
"typescript": "^5.7.0"
|
|
54
65
|
}
|
|
55
66
|
}
|