javascript-solid-server 0.0.8 → 0.0.10
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.
- package/.claude/settings.local.json +3 -1
- package/README.md +95 -6
- package/benchmark.js +145 -249
- package/package.json +16 -3
- package/src/handlers/container.js +7 -0
- package/src/handlers/resource.js +117 -33
- package/src/ldp/headers.js +9 -4
- package/src/notifications/events.js +22 -0
- package/src/notifications/index.js +49 -0
- package/src/notifications/websocket.js +183 -0
- package/src/patch/sparql-update.js +401 -0
- package/src/server.js +17 -0
- package/src/utils/conditional.js +153 -0
- package/test/conditional.test.js +250 -0
- package/test/notifications.test.js +348 -0
- package/test/sparql-update.test.js +219 -0
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Conditional Request Tests
|
|
3
|
+
*
|
|
4
|
+
* Tests If-Match and If-None-Match header support.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { describe, it, before, after } from 'node:test';
|
|
8
|
+
import assert from 'node:assert';
|
|
9
|
+
import {
|
|
10
|
+
startTestServer,
|
|
11
|
+
stopTestServer,
|
|
12
|
+
request,
|
|
13
|
+
createTestPod,
|
|
14
|
+
assertStatus
|
|
15
|
+
} from './helpers.js';
|
|
16
|
+
|
|
17
|
+
describe('Conditional Requests', () => {
|
|
18
|
+
before(async () => {
|
|
19
|
+
await startTestServer();
|
|
20
|
+
await createTestPod('condtest');
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
after(async () => {
|
|
24
|
+
await stopTestServer();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe('If-None-Match on GET', () => {
|
|
28
|
+
it('should return 304 Not Modified when ETag matches', async () => {
|
|
29
|
+
// Create a resource
|
|
30
|
+
await request('/condtest/public/etag-test.json', {
|
|
31
|
+
method: 'PUT',
|
|
32
|
+
headers: { 'Content-Type': 'application/ld+json' },
|
|
33
|
+
body: JSON.stringify({ '@id': '#test', 'http://example.org/value': 1 }),
|
|
34
|
+
auth: 'condtest'
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
// Get the ETag
|
|
38
|
+
const res1 = await request('/condtest/public/etag-test.json');
|
|
39
|
+
const etag = res1.headers.get('ETag');
|
|
40
|
+
assert.ok(etag, 'Response should have ETag');
|
|
41
|
+
|
|
42
|
+
// Request with matching If-None-Match
|
|
43
|
+
const res2 = await request('/condtest/public/etag-test.json', {
|
|
44
|
+
headers: { 'If-None-Match': etag }
|
|
45
|
+
});
|
|
46
|
+
assertStatus(res2, 304);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('should return 200 when ETag does not match', async () => {
|
|
50
|
+
const res = await request('/condtest/public/etag-test.json', {
|
|
51
|
+
headers: { 'If-None-Match': '"different-etag"' }
|
|
52
|
+
});
|
|
53
|
+
assertStatus(res, 200);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('should return 304 with If-None-Match: *', async () => {
|
|
57
|
+
const res = await request('/condtest/public/etag-test.json', {
|
|
58
|
+
headers: { 'If-None-Match': '*' }
|
|
59
|
+
});
|
|
60
|
+
assertStatus(res, 304);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe('If-Match on PUT', () => {
|
|
65
|
+
it('should succeed when ETag matches', async () => {
|
|
66
|
+
// Create resource
|
|
67
|
+
await request('/condtest/public/match-test.json', {
|
|
68
|
+
method: 'PUT',
|
|
69
|
+
headers: { 'Content-Type': 'application/ld+json' },
|
|
70
|
+
body: JSON.stringify({ '@id': '#test', 'http://example.org/v': 1 }),
|
|
71
|
+
auth: 'condtest'
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
// Get ETag
|
|
75
|
+
const res1 = await request('/condtest/public/match-test.json');
|
|
76
|
+
const etag = res1.headers.get('ETag');
|
|
77
|
+
|
|
78
|
+
// Update with matching If-Match
|
|
79
|
+
const res2 = await request('/condtest/public/match-test.json', {
|
|
80
|
+
method: 'PUT',
|
|
81
|
+
headers: {
|
|
82
|
+
'Content-Type': 'application/ld+json',
|
|
83
|
+
'If-Match': etag
|
|
84
|
+
},
|
|
85
|
+
body: JSON.stringify({ '@id': '#test', 'http://example.org/v': 2 }),
|
|
86
|
+
auth: 'condtest'
|
|
87
|
+
});
|
|
88
|
+
assertStatus(res2, 204);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('should return 412 when ETag does not match', async () => {
|
|
92
|
+
const res = await request('/condtest/public/match-test.json', {
|
|
93
|
+
method: 'PUT',
|
|
94
|
+
headers: {
|
|
95
|
+
'Content-Type': 'application/ld+json',
|
|
96
|
+
'If-Match': '"wrong-etag"'
|
|
97
|
+
},
|
|
98
|
+
body: JSON.stringify({ '@id': '#test', 'http://example.org/v': 3 }),
|
|
99
|
+
auth: 'condtest'
|
|
100
|
+
});
|
|
101
|
+
assertStatus(res, 412);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('should succeed with If-Match: * on existing resource', async () => {
|
|
105
|
+
const res = await request('/condtest/public/match-test.json', {
|
|
106
|
+
method: 'PUT',
|
|
107
|
+
headers: {
|
|
108
|
+
'Content-Type': 'application/ld+json',
|
|
109
|
+
'If-Match': '*'
|
|
110
|
+
},
|
|
111
|
+
body: JSON.stringify({ '@id': '#test', 'http://example.org/v': 4 }),
|
|
112
|
+
auth: 'condtest'
|
|
113
|
+
});
|
|
114
|
+
assertStatus(res, 204);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('should return 412 with If-Match: * on non-existent resource', async () => {
|
|
118
|
+
const res = await request('/condtest/public/nonexistent.json', {
|
|
119
|
+
method: 'PUT',
|
|
120
|
+
headers: {
|
|
121
|
+
'Content-Type': 'application/ld+json',
|
|
122
|
+
'If-Match': '*'
|
|
123
|
+
},
|
|
124
|
+
body: JSON.stringify({ '@id': '#new' }),
|
|
125
|
+
auth: 'condtest'
|
|
126
|
+
});
|
|
127
|
+
assertStatus(res, 412);
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
describe('If-None-Match on PUT', () => {
|
|
132
|
+
it('should succeed with If-None-Match: * on new resource', async () => {
|
|
133
|
+
const res = await request('/condtest/public/create-only.json', {
|
|
134
|
+
method: 'PUT',
|
|
135
|
+
headers: {
|
|
136
|
+
'Content-Type': 'application/ld+json',
|
|
137
|
+
'If-None-Match': '*'
|
|
138
|
+
},
|
|
139
|
+
body: JSON.stringify({ '@id': '#new' }),
|
|
140
|
+
auth: 'condtest'
|
|
141
|
+
});
|
|
142
|
+
assertStatus(res, 201);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('should return 412 with If-None-Match: * on existing resource', async () => {
|
|
146
|
+
const res = await request('/condtest/public/create-only.json', {
|
|
147
|
+
method: 'PUT',
|
|
148
|
+
headers: {
|
|
149
|
+
'Content-Type': 'application/ld+json',
|
|
150
|
+
'If-None-Match': '*'
|
|
151
|
+
},
|
|
152
|
+
body: JSON.stringify({ '@id': '#update' }),
|
|
153
|
+
auth: 'condtest'
|
|
154
|
+
});
|
|
155
|
+
assertStatus(res, 412);
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
describe('If-Match on DELETE', () => {
|
|
160
|
+
it('should succeed when ETag matches', async () => {
|
|
161
|
+
// Create resource
|
|
162
|
+
await request('/condtest/public/delete-match.json', {
|
|
163
|
+
method: 'PUT',
|
|
164
|
+
headers: { 'Content-Type': 'application/ld+json' },
|
|
165
|
+
body: JSON.stringify({ '@id': '#delete' }),
|
|
166
|
+
auth: 'condtest'
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
// Get ETag
|
|
170
|
+
const res1 = await request('/condtest/public/delete-match.json');
|
|
171
|
+
const etag = res1.headers.get('ETag');
|
|
172
|
+
|
|
173
|
+
// Delete with matching If-Match
|
|
174
|
+
const res2 = await request('/condtest/public/delete-match.json', {
|
|
175
|
+
method: 'DELETE',
|
|
176
|
+
headers: { 'If-Match': etag },
|
|
177
|
+
auth: 'condtest'
|
|
178
|
+
});
|
|
179
|
+
assertStatus(res2, 204);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('should return 412 when ETag does not match', async () => {
|
|
183
|
+
// Create resource
|
|
184
|
+
await request('/condtest/public/delete-nomatch.json', {
|
|
185
|
+
method: 'PUT',
|
|
186
|
+
headers: { 'Content-Type': 'application/ld+json' },
|
|
187
|
+
body: JSON.stringify({ '@id': '#delete' }),
|
|
188
|
+
auth: 'condtest'
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
const res = await request('/condtest/public/delete-nomatch.json', {
|
|
192
|
+
method: 'DELETE',
|
|
193
|
+
headers: { 'If-Match': '"wrong-etag"' },
|
|
194
|
+
auth: 'condtest'
|
|
195
|
+
});
|
|
196
|
+
assertStatus(res, 412);
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
describe('If-Match on PATCH', () => {
|
|
201
|
+
it('should succeed when ETag matches', async () => {
|
|
202
|
+
// Create resource
|
|
203
|
+
await request('/condtest/public/patch-match.json', {
|
|
204
|
+
method: 'PUT',
|
|
205
|
+
headers: { 'Content-Type': 'application/ld+json' },
|
|
206
|
+
body: JSON.stringify({ '@id': '#test', 'http://example.org/name': 'Original' }),
|
|
207
|
+
auth: 'condtest'
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
// Get ETag
|
|
211
|
+
const res1 = await request('/condtest/public/patch-match.json');
|
|
212
|
+
const etag = res1.headers.get('ETag');
|
|
213
|
+
|
|
214
|
+
// Patch with matching If-Match
|
|
215
|
+
const patch = `
|
|
216
|
+
@prefix solid: <http://www.w3.org/ns/solid/terms#>.
|
|
217
|
+
_:patch a solid:InsertDeletePatch;
|
|
218
|
+
solid:inserts { <#test> <http://example.org/updated> "true" }.
|
|
219
|
+
`;
|
|
220
|
+
const res2 = await request('/condtest/public/patch-match.json', {
|
|
221
|
+
method: 'PATCH',
|
|
222
|
+
headers: {
|
|
223
|
+
'Content-Type': 'text/n3',
|
|
224
|
+
'If-Match': etag
|
|
225
|
+
},
|
|
226
|
+
body: patch,
|
|
227
|
+
auth: 'condtest'
|
|
228
|
+
});
|
|
229
|
+
assertStatus(res2, 204);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it('should return 412 when ETag does not match', async () => {
|
|
233
|
+
const patch = `
|
|
234
|
+
@prefix solid: <http://www.w3.org/ns/solid/terms#>.
|
|
235
|
+
_:patch a solid:InsertDeletePatch;
|
|
236
|
+
solid:inserts { <#test> <http://example.org/bad> "true" }.
|
|
237
|
+
`;
|
|
238
|
+
const res = await request('/condtest/public/patch-match.json', {
|
|
239
|
+
method: 'PATCH',
|
|
240
|
+
headers: {
|
|
241
|
+
'Content-Type': 'text/n3',
|
|
242
|
+
'If-Match': '"wrong-etag"'
|
|
243
|
+
},
|
|
244
|
+
body: patch,
|
|
245
|
+
auth: 'condtest'
|
|
246
|
+
});
|
|
247
|
+
assertStatus(res, 412);
|
|
248
|
+
});
|
|
249
|
+
});
|
|
250
|
+
});
|
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WebSocket Notifications Tests
|
|
3
|
+
*
|
|
4
|
+
* Tests the solid-0.1 WebSocket notification protocol.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { describe, it, before, after } from 'node:test';
|
|
8
|
+
import assert from 'node:assert';
|
|
9
|
+
import { WebSocket } from 'ws';
|
|
10
|
+
import {
|
|
11
|
+
startTestServer,
|
|
12
|
+
stopTestServer,
|
|
13
|
+
request,
|
|
14
|
+
createTestPod,
|
|
15
|
+
getBaseUrl,
|
|
16
|
+
assertStatus,
|
|
17
|
+
assertHeader,
|
|
18
|
+
assertHeaderContains
|
|
19
|
+
} from './helpers.js';
|
|
20
|
+
|
|
21
|
+
describe('WebSocket Notifications (notifications enabled)', () => {
|
|
22
|
+
let wsUrl;
|
|
23
|
+
|
|
24
|
+
before(async () => {
|
|
25
|
+
// Start server with notifications ENABLED
|
|
26
|
+
await startTestServer({ notifications: true });
|
|
27
|
+
await createTestPod('notifytest');
|
|
28
|
+
|
|
29
|
+
// Get WebSocket URL from Updates-Via header
|
|
30
|
+
const res = await request('/notifytest/', { method: 'OPTIONS' });
|
|
31
|
+
wsUrl = res.headers.get('Updates-Via');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
after(async () => {
|
|
35
|
+
await stopTestServer();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe('Discovery', () => {
|
|
39
|
+
it('should return Updates-Via header in OPTIONS response', async () => {
|
|
40
|
+
const res = await request('/notifytest/public/', { method: 'OPTIONS' });
|
|
41
|
+
assertStatus(res, 204);
|
|
42
|
+
const updatesVia = res.headers.get('Updates-Via');
|
|
43
|
+
assert.ok(updatesVia, 'Should have Updates-Via header');
|
|
44
|
+
assert.ok(updatesVia.startsWith('ws://') || updatesVia.startsWith('wss://'),
|
|
45
|
+
'Updates-Via should be a WebSocket URL');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('should return Updates-Via header in GET response', async () => {
|
|
49
|
+
const res = await request('/notifytest/');
|
|
50
|
+
assertStatus(res, 200);
|
|
51
|
+
const updatesVia = res.headers.get('Updates-Via');
|
|
52
|
+
assert.ok(updatesVia, 'GET response should have Updates-Via header');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('should expose Updates-Via in CORS headers', async () => {
|
|
56
|
+
const res = await request('/notifytest/', {
|
|
57
|
+
headers: { 'Origin': 'http://example.org' }
|
|
58
|
+
});
|
|
59
|
+
const expose = res.headers.get('Access-Control-Expose-Headers');
|
|
60
|
+
assert.ok(expose && expose.includes('Updates-Via'),
|
|
61
|
+
'Updates-Via should be in exposed headers');
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
describe('WebSocket Protocol', () => {
|
|
66
|
+
it('should connect and receive protocol greeting', async () => {
|
|
67
|
+
const ws = new WebSocket(wsUrl);
|
|
68
|
+
|
|
69
|
+
const message = await new Promise((resolve, reject) => {
|
|
70
|
+
ws.on('open', () => {});
|
|
71
|
+
ws.on('message', (data) => resolve(data.toString()));
|
|
72
|
+
ws.on('error', reject);
|
|
73
|
+
setTimeout(() => reject(new Error('Timeout')), 5000);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
assert.strictEqual(message, 'protocol solid-0.1');
|
|
77
|
+
ws.close();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('should acknowledge subscription', async () => {
|
|
81
|
+
const ws = new WebSocket(wsUrl);
|
|
82
|
+
const baseUrl = getBaseUrl();
|
|
83
|
+
const resourceUrl = `${baseUrl}/notifytest/public/test.json`;
|
|
84
|
+
|
|
85
|
+
const messages = [];
|
|
86
|
+
|
|
87
|
+
await new Promise((resolve, reject) => {
|
|
88
|
+
ws.on('open', () => {
|
|
89
|
+
ws.send(`sub ${resourceUrl}`);
|
|
90
|
+
});
|
|
91
|
+
ws.on('message', (data) => {
|
|
92
|
+
messages.push(data.toString());
|
|
93
|
+
if (messages.length >= 2) resolve();
|
|
94
|
+
});
|
|
95
|
+
ws.on('error', reject);
|
|
96
|
+
setTimeout(() => resolve(), 2000);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
assert.ok(messages.includes('protocol solid-0.1'), 'Should receive protocol greeting');
|
|
100
|
+
assert.ok(messages.some(m => m.startsWith('ack ')), 'Should receive ack');
|
|
101
|
+
ws.close();
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
describe('Notifications', () => {
|
|
106
|
+
it('should receive pub notification on PUT', async () => {
|
|
107
|
+
const ws = new WebSocket(wsUrl);
|
|
108
|
+
const baseUrl = getBaseUrl();
|
|
109
|
+
const resourceUrl = `${baseUrl}/notifytest/public/notify-put.json`;
|
|
110
|
+
|
|
111
|
+
const notifications = [];
|
|
112
|
+
|
|
113
|
+
await new Promise((resolve) => {
|
|
114
|
+
ws.on('open', () => {
|
|
115
|
+
ws.send(`sub ${resourceUrl}`);
|
|
116
|
+
});
|
|
117
|
+
ws.on('message', (data) => {
|
|
118
|
+
const msg = data.toString();
|
|
119
|
+
if (msg.startsWith('pub ')) {
|
|
120
|
+
notifications.push(msg);
|
|
121
|
+
resolve();
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
// Wait for subscription to be established
|
|
126
|
+
setTimeout(async () => {
|
|
127
|
+
// Create the resource
|
|
128
|
+
await request('/notifytest/public/notify-put.json', {
|
|
129
|
+
method: 'PUT',
|
|
130
|
+
headers: { 'Content-Type': 'application/ld+json' },
|
|
131
|
+
body: JSON.stringify({ '@id': '#test', 'http://example.org/p': 'value' }),
|
|
132
|
+
auth: 'notifytest'
|
|
133
|
+
});
|
|
134
|
+
}, 500);
|
|
135
|
+
|
|
136
|
+
setTimeout(() => resolve(), 3000);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
assert.ok(notifications.length > 0, 'Should receive pub notification');
|
|
140
|
+
assert.ok(notifications[0].includes(resourceUrl), 'Notification should include resource URL');
|
|
141
|
+
ws.close();
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('should receive pub notification on PATCH', async () => {
|
|
145
|
+
const baseUrl = getBaseUrl();
|
|
146
|
+
const resourceUrl = `${baseUrl}/notifytest/public/notify-patch2.json`;
|
|
147
|
+
|
|
148
|
+
// First create the resource
|
|
149
|
+
await request('/notifytest/public/notify-patch2.json', {
|
|
150
|
+
method: 'PUT',
|
|
151
|
+
headers: { 'Content-Type': 'application/ld+json' },
|
|
152
|
+
body: JSON.stringify({ '@id': '#test', 'http://example.org/name': 'Original' }),
|
|
153
|
+
auth: 'notifytest'
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
// Create WebSocket AFTER the initial PUT to avoid race condition
|
|
157
|
+
const ws = new WebSocket(wsUrl);
|
|
158
|
+
const notifications = [];
|
|
159
|
+
|
|
160
|
+
await new Promise((resolve) => {
|
|
161
|
+
ws.on('open', () => {
|
|
162
|
+
ws.send(`sub ${resourceUrl}`);
|
|
163
|
+
});
|
|
164
|
+
ws.on('message', (data) => {
|
|
165
|
+
const msg = data.toString();
|
|
166
|
+
if (msg.startsWith('pub ')) {
|
|
167
|
+
notifications.push(msg);
|
|
168
|
+
resolve();
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
// Wait for subscription to be established, then patch
|
|
173
|
+
setTimeout(async () => {
|
|
174
|
+
const patch = `
|
|
175
|
+
@prefix solid: <http://www.w3.org/ns/solid/terms#>.
|
|
176
|
+
_:patch a solid:InsertDeletePatch;
|
|
177
|
+
solid:inserts { <#test> <http://example.org/name> "Updated" }.
|
|
178
|
+
`;
|
|
179
|
+
await request('/notifytest/public/notify-patch2.json', {
|
|
180
|
+
method: 'PATCH',
|
|
181
|
+
headers: { 'Content-Type': 'text/n3' },
|
|
182
|
+
body: patch,
|
|
183
|
+
auth: 'notifytest'
|
|
184
|
+
});
|
|
185
|
+
}, 500);
|
|
186
|
+
|
|
187
|
+
setTimeout(() => resolve(), 3000);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
assert.ok(notifications.length > 0, 'Should receive pub notification for PATCH');
|
|
191
|
+
ws.close();
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it('should receive pub notification on DELETE', async () => {
|
|
195
|
+
const baseUrl = getBaseUrl();
|
|
196
|
+
const resourceUrl = `${baseUrl}/notifytest/public/notify-delete2.json`;
|
|
197
|
+
|
|
198
|
+
// First create the resource
|
|
199
|
+
await request('/notifytest/public/notify-delete2.json', {
|
|
200
|
+
method: 'PUT',
|
|
201
|
+
headers: { 'Content-Type': 'application/ld+json' },
|
|
202
|
+
body: JSON.stringify({ '@id': '#test' }),
|
|
203
|
+
auth: 'notifytest'
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
// Create WebSocket AFTER the initial PUT to avoid race condition
|
|
207
|
+
const ws = new WebSocket(wsUrl);
|
|
208
|
+
const notifications = [];
|
|
209
|
+
|
|
210
|
+
await new Promise((resolve) => {
|
|
211
|
+
ws.on('open', () => {
|
|
212
|
+
ws.send(`sub ${resourceUrl}`);
|
|
213
|
+
});
|
|
214
|
+
ws.on('message', (data) => {
|
|
215
|
+
const msg = data.toString();
|
|
216
|
+
if (msg.startsWith('pub ')) {
|
|
217
|
+
notifications.push(msg);
|
|
218
|
+
resolve();
|
|
219
|
+
}
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
// Wait for subscription, then delete
|
|
223
|
+
setTimeout(async () => {
|
|
224
|
+
await request('/notifytest/public/notify-delete2.json', {
|
|
225
|
+
method: 'DELETE',
|
|
226
|
+
auth: 'notifytest'
|
|
227
|
+
});
|
|
228
|
+
}, 500);
|
|
229
|
+
|
|
230
|
+
setTimeout(() => resolve(), 3000);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
assert.ok(notifications.length > 0, 'Should receive pub notification for DELETE');
|
|
234
|
+
ws.close();
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it('should receive container notification when child changes', async () => {
|
|
238
|
+
const ws = new WebSocket(wsUrl);
|
|
239
|
+
const baseUrl = getBaseUrl();
|
|
240
|
+
const containerUrl = `${baseUrl}/notifytest/public/`;
|
|
241
|
+
|
|
242
|
+
const notifications = [];
|
|
243
|
+
|
|
244
|
+
await new Promise((resolve) => {
|
|
245
|
+
ws.on('open', () => {
|
|
246
|
+
// Subscribe to container
|
|
247
|
+
ws.send(`sub ${containerUrl}`);
|
|
248
|
+
});
|
|
249
|
+
ws.on('message', (data) => {
|
|
250
|
+
const msg = data.toString();
|
|
251
|
+
if (msg.startsWith('pub ')) {
|
|
252
|
+
notifications.push(msg);
|
|
253
|
+
resolve();
|
|
254
|
+
}
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
// Wait for subscription, then create a child resource
|
|
258
|
+
setTimeout(async () => {
|
|
259
|
+
await request('/notifytest/public/child-resource.json', {
|
|
260
|
+
method: 'PUT',
|
|
261
|
+
headers: { 'Content-Type': 'application/ld+json' },
|
|
262
|
+
body: JSON.stringify({ '@id': '#child' }),
|
|
263
|
+
auth: 'notifytest'
|
|
264
|
+
});
|
|
265
|
+
}, 500);
|
|
266
|
+
|
|
267
|
+
setTimeout(() => resolve(), 3000);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
assert.ok(notifications.length > 0, 'Container should receive notification for child changes');
|
|
271
|
+
ws.close();
|
|
272
|
+
});
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
describe('Multiple Subscribers', () => {
|
|
276
|
+
it('should notify all subscribers', async () => {
|
|
277
|
+
const ws1 = new WebSocket(wsUrl);
|
|
278
|
+
const ws2 = new WebSocket(wsUrl);
|
|
279
|
+
const baseUrl = getBaseUrl();
|
|
280
|
+
const resourceUrl = `${baseUrl}/notifytest/public/multi-sub.json`;
|
|
281
|
+
|
|
282
|
+
const notifications1 = [];
|
|
283
|
+
const notifications2 = [];
|
|
284
|
+
|
|
285
|
+
await new Promise((resolve) => {
|
|
286
|
+
let ready = 0;
|
|
287
|
+
|
|
288
|
+
const setupWs = (ws, notifications) => {
|
|
289
|
+
ws.on('open', () => {
|
|
290
|
+
ws.send(`sub ${resourceUrl}`);
|
|
291
|
+
});
|
|
292
|
+
ws.on('message', (data) => {
|
|
293
|
+
const msg = data.toString();
|
|
294
|
+
if (msg.startsWith('ack ')) {
|
|
295
|
+
ready++;
|
|
296
|
+
if (ready === 2) {
|
|
297
|
+
// Both subscribed, trigger change
|
|
298
|
+
setTimeout(async () => {
|
|
299
|
+
await request('/notifytest/public/multi-sub.json', {
|
|
300
|
+
method: 'PUT',
|
|
301
|
+
headers: { 'Content-Type': 'application/json' },
|
|
302
|
+
body: JSON.stringify({ test: true }),
|
|
303
|
+
auth: 'notifytest'
|
|
304
|
+
});
|
|
305
|
+
}, 100);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
if (msg.startsWith('pub ')) {
|
|
309
|
+
notifications.push(msg);
|
|
310
|
+
if (notifications1.length > 0 && notifications2.length > 0) {
|
|
311
|
+
resolve();
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
});
|
|
315
|
+
};
|
|
316
|
+
|
|
317
|
+
setupWs(ws1, notifications1);
|
|
318
|
+
setupWs(ws2, notifications2);
|
|
319
|
+
|
|
320
|
+
setTimeout(() => resolve(), 4000);
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
assert.ok(notifications1.length > 0, 'First subscriber should receive notification');
|
|
324
|
+
assert.ok(notifications2.length > 0, 'Second subscriber should receive notification');
|
|
325
|
+
ws1.close();
|
|
326
|
+
ws2.close();
|
|
327
|
+
});
|
|
328
|
+
});
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
describe('WebSocket Notifications (notifications disabled - default)', () => {
|
|
332
|
+
before(async () => {
|
|
333
|
+
// Start server with notifications DISABLED (default)
|
|
334
|
+
await startTestServer({ notifications: false });
|
|
335
|
+
await createTestPod('nonotify');
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
after(async () => {
|
|
339
|
+
await stopTestServer();
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
it('should NOT return Updates-Via header when notifications disabled', async () => {
|
|
343
|
+
const res = await request('/nonotify/', { method: 'OPTIONS' });
|
|
344
|
+
assertStatus(res, 204);
|
|
345
|
+
const updatesVia = res.headers.get('Updates-Via');
|
|
346
|
+
assert.strictEqual(updatesVia, null, 'Should NOT have Updates-Via header when disabled');
|
|
347
|
+
});
|
|
348
|
+
});
|