javascript-solid-server 0.0.7 → 0.0.9
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 +167 -146
- package/package.json +4 -2
- package/src/handlers/container.js +41 -3
- package/src/handlers/resource.js +104 -7
- package/src/ldp/headers.js +16 -9
- package/src/notifications/events.js +22 -0
- package/src/notifications/index.js +49 -0
- package/src/notifications/websocket.js +183 -0
- package/src/rdf/conneg.js +215 -0
- package/src/rdf/turtle.js +411 -0
- package/src/server.js +29 -0
- package/test/conneg.test.js +289 -0
- package/test/helpers.js +4 -2
- package/test/notifications.test.js +348 -0
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Content Negotiation Tests
|
|
3
|
+
*
|
|
4
|
+
* Tests Turtle <-> JSON-LD conversion with conneg enabled.
|
|
5
|
+
* Note: Content negotiation is OFF by default (JSON-LD native server).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, it, before, after } from 'node:test';
|
|
9
|
+
import assert from 'node:assert';
|
|
10
|
+
import {
|
|
11
|
+
startTestServer,
|
|
12
|
+
stopTestServer,
|
|
13
|
+
request,
|
|
14
|
+
createTestPod,
|
|
15
|
+
assertStatus,
|
|
16
|
+
assertHeader,
|
|
17
|
+
assertHeaderContains
|
|
18
|
+
} from './helpers.js';
|
|
19
|
+
|
|
20
|
+
describe('Content Negotiation (conneg enabled)', () => {
|
|
21
|
+
before(async () => {
|
|
22
|
+
// Start server with conneg ENABLED
|
|
23
|
+
await startTestServer({ conneg: true });
|
|
24
|
+
await createTestPod('connegtest');
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
after(async () => {
|
|
28
|
+
await stopTestServer();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe('GET with Accept header', () => {
|
|
32
|
+
it('should return JSON-LD when Accept: application/ld+json', async () => {
|
|
33
|
+
// Create a JSON-LD resource
|
|
34
|
+
const data = {
|
|
35
|
+
'@context': { 'foaf': 'http://xmlns.com/foaf/0.1/' },
|
|
36
|
+
'@id': '#me',
|
|
37
|
+
'foaf:name': 'Alice'
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
await request('/connegtest/public/alice.json', {
|
|
41
|
+
method: 'PUT',
|
|
42
|
+
headers: { 'Content-Type': 'application/ld+json' },
|
|
43
|
+
body: JSON.stringify(data),
|
|
44
|
+
auth: 'connegtest'
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
const res = await request('/connegtest/public/alice.json', {
|
|
48
|
+
headers: { 'Accept': 'application/ld+json' }
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
assertStatus(res, 200);
|
|
52
|
+
assertHeaderContains(res, 'Content-Type', 'application/ld+json');
|
|
53
|
+
|
|
54
|
+
const body = await res.json();
|
|
55
|
+
assert.strictEqual(body['foaf:name'], 'Alice');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('should return Turtle when Accept: text/turtle', async () => {
|
|
59
|
+
// Create a JSON-LD resource
|
|
60
|
+
const data = {
|
|
61
|
+
'@context': { 'foaf': 'http://xmlns.com/foaf/0.1/' },
|
|
62
|
+
'@id': '#me',
|
|
63
|
+
'foaf:name': 'Bob'
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
await request('/connegtest/public/bob.json', {
|
|
67
|
+
method: 'PUT',
|
|
68
|
+
headers: { 'Content-Type': 'application/ld+json' },
|
|
69
|
+
body: JSON.stringify(data),
|
|
70
|
+
auth: 'connegtest'
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
const res = await request('/connegtest/public/bob.json', {
|
|
74
|
+
headers: { 'Accept': 'text/turtle' }
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
assertStatus(res, 200);
|
|
78
|
+
assertHeaderContains(res, 'Content-Type', 'text/turtle');
|
|
79
|
+
|
|
80
|
+
const turtle = await res.text();
|
|
81
|
+
// Should contain foaf prefix and name
|
|
82
|
+
assert.ok(turtle.includes('foaf:') || turtle.includes('http://xmlns.com/foaf/0.1/'),
|
|
83
|
+
'Turtle should contain foaf prefix or URI');
|
|
84
|
+
assert.ok(turtle.includes('Bob'), 'Turtle should contain the name');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('should default to JSON-LD for */* Accept', async () => {
|
|
88
|
+
const res = await request('/connegtest/public/alice.json', {
|
|
89
|
+
headers: { 'Accept': '*/*' }
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
assertStatus(res, 200);
|
|
93
|
+
assertHeaderContains(res, 'Content-Type', 'application/ld+json');
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('should include Vary header with Accept', async () => {
|
|
97
|
+
const res = await request('/connegtest/public/alice.json');
|
|
98
|
+
const vary = res.headers.get('Vary');
|
|
99
|
+
assert.ok(vary && vary.includes('Accept'), 'Should have Vary: Accept');
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
describe('PUT with Content-Type', () => {
|
|
104
|
+
it('should accept Turtle input and store as JSON-LD', async () => {
|
|
105
|
+
const turtle = `
|
|
106
|
+
@prefix foaf: <http://xmlns.com/foaf/0.1/>.
|
|
107
|
+
<#me> foaf:name "Charlie".
|
|
108
|
+
`;
|
|
109
|
+
|
|
110
|
+
const res = await request('/connegtest/public/charlie.json', {
|
|
111
|
+
method: 'PUT',
|
|
112
|
+
headers: { 'Content-Type': 'text/turtle' },
|
|
113
|
+
body: turtle,
|
|
114
|
+
auth: 'connegtest'
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
assertStatus(res, 201);
|
|
118
|
+
|
|
119
|
+
// Verify it's stored as JSON-LD
|
|
120
|
+
const getRes = await request('/connegtest/public/charlie.json', {
|
|
121
|
+
headers: { 'Accept': 'application/ld+json' }
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
assertStatus(getRes, 200);
|
|
125
|
+
const data = await getRes.json();
|
|
126
|
+
assert.ok(data['@context'], 'Should have @context');
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('should accept N3 input', async () => {
|
|
130
|
+
const n3 = `
|
|
131
|
+
@prefix schema: <http://schema.org/>.
|
|
132
|
+
<#item> schema:name "Widget".
|
|
133
|
+
`;
|
|
134
|
+
|
|
135
|
+
const res = await request('/connegtest/public/widget.json', {
|
|
136
|
+
method: 'PUT',
|
|
137
|
+
headers: { 'Content-Type': 'text/n3' },
|
|
138
|
+
body: n3,
|
|
139
|
+
auth: 'connegtest'
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
assertStatus(res, 201);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('should return 400 for invalid Turtle', async () => {
|
|
146
|
+
const invalidTurtle = 'this is not valid turtle {{{';
|
|
147
|
+
|
|
148
|
+
const res = await request('/connegtest/public/invalid.json', {
|
|
149
|
+
method: 'PUT',
|
|
150
|
+
headers: { 'Content-Type': 'text/turtle' },
|
|
151
|
+
body: invalidTurtle,
|
|
152
|
+
auth: 'connegtest'
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
assertStatus(res, 400);
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
describe('POST with Content-Type', () => {
|
|
160
|
+
it('should accept Turtle input in POST', async () => {
|
|
161
|
+
const turtle = `
|
|
162
|
+
@prefix dc: <http://purl.org/dc/terms/>.
|
|
163
|
+
<#doc> dc:title "My Document".
|
|
164
|
+
`;
|
|
165
|
+
|
|
166
|
+
const res = await request('/connegtest/public/', {
|
|
167
|
+
method: 'POST',
|
|
168
|
+
headers: {
|
|
169
|
+
'Content-Type': 'text/turtle',
|
|
170
|
+
'Slug': 'turtle-doc.json'
|
|
171
|
+
},
|
|
172
|
+
body: turtle,
|
|
173
|
+
auth: 'connegtest'
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
assertStatus(res, 201);
|
|
177
|
+
const location = res.headers.get('Location');
|
|
178
|
+
assert.ok(location, 'Should have Location header');
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
describe('Accept-* Headers', () => {
|
|
183
|
+
it('should advertise Turtle support in Accept-Put', async () => {
|
|
184
|
+
const res = await request('/connegtest/public/alice.json');
|
|
185
|
+
const acceptPut = res.headers.get('Accept-Put');
|
|
186
|
+
assert.ok(acceptPut && acceptPut.includes('text/turtle'),
|
|
187
|
+
'Accept-Put should include text/turtle');
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('should advertise Turtle support in Accept-Post for containers', async () => {
|
|
191
|
+
const res = await request('/connegtest/public/');
|
|
192
|
+
const acceptPost = res.headers.get('Accept-Post');
|
|
193
|
+
assert.ok(acceptPost && acceptPost.includes('text/turtle'),
|
|
194
|
+
'Accept-Post should include text/turtle');
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
describe('Content Negotiation (conneg disabled - default)', () => {
|
|
200
|
+
before(async () => {
|
|
201
|
+
// Start server with conneg DISABLED (default)
|
|
202
|
+
await startTestServer({ conneg: false });
|
|
203
|
+
await createTestPod('noconneg');
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
after(async () => {
|
|
207
|
+
await stopTestServer();
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
describe('Default JSON-LD behavior', () => {
|
|
211
|
+
it('should always return JSON-LD regardless of Accept header', async () => {
|
|
212
|
+
// Create resource
|
|
213
|
+
const data = {
|
|
214
|
+
'@context': { 'foaf': 'http://xmlns.com/foaf/0.1/' },
|
|
215
|
+
'@id': '#me',
|
|
216
|
+
'foaf:name': 'DefaultUser'
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
await request('/noconneg/public/user.json', {
|
|
220
|
+
method: 'PUT',
|
|
221
|
+
headers: { 'Content-Type': 'application/ld+json' },
|
|
222
|
+
body: JSON.stringify(data),
|
|
223
|
+
auth: 'noconneg'
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
// Request Turtle
|
|
227
|
+
const res = await request('/noconneg/public/user.json', {
|
|
228
|
+
headers: { 'Accept': 'text/turtle' }
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
assertStatus(res, 200);
|
|
232
|
+
// Should still return JSON-LD when conneg disabled
|
|
233
|
+
const body = await res.json();
|
|
234
|
+
assert.strictEqual(body['foaf:name'], 'DefaultUser');
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it('should accept JSON-LD input', async () => {
|
|
238
|
+
const data = { '@id': '#test', 'http://example.org/p': 'value' };
|
|
239
|
+
|
|
240
|
+
const res = await request('/noconneg/public/test.json', {
|
|
241
|
+
method: 'PUT',
|
|
242
|
+
headers: { 'Content-Type': 'application/ld+json' },
|
|
243
|
+
body: JSON.stringify(data),
|
|
244
|
+
auth: 'noconneg'
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
assertStatus(res, 201);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it('should accept plain JSON input', async () => {
|
|
251
|
+
const data = { foo: 'bar' };
|
|
252
|
+
|
|
253
|
+
const res = await request('/noconneg/public/plain.json', {
|
|
254
|
+
method: 'PUT',
|
|
255
|
+
headers: { 'Content-Type': 'application/json' },
|
|
256
|
+
body: JSON.stringify(data),
|
|
257
|
+
auth: 'noconneg'
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
assertStatus(res, 201);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it('should accept non-RDF content types', async () => {
|
|
264
|
+
const res = await request('/noconneg/public/readme.txt', {
|
|
265
|
+
method: 'PUT',
|
|
266
|
+
headers: { 'Content-Type': 'text/plain' },
|
|
267
|
+
body: 'Hello World',
|
|
268
|
+
auth: 'noconneg'
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
assertStatus(res, 201);
|
|
272
|
+
|
|
273
|
+
const getRes = await request('/noconneg/public/readme.txt');
|
|
274
|
+
assertStatus(getRes, 200);
|
|
275
|
+
const text = await getRes.text();
|
|
276
|
+
assert.strictEqual(text, 'Hello World');
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it('should not advertise Turtle in Accept-Put when conneg disabled', async () => {
|
|
280
|
+
const res = await request('/noconneg/public/');
|
|
281
|
+
const acceptPut = res.headers.get('Accept-Put');
|
|
282
|
+
// Should only advertise JSON-LD, not Turtle
|
|
283
|
+
assert.ok(acceptPut && acceptPut.includes('application/ld+json'),
|
|
284
|
+
'Accept-Put should include application/ld+json');
|
|
285
|
+
assert.ok(!acceptPut || !acceptPut.includes('text/turtle'),
|
|
286
|
+
'Accept-Put should NOT include text/turtle when conneg disabled');
|
|
287
|
+
});
|
|
288
|
+
});
|
|
289
|
+
});
|
package/test/helpers.js
CHANGED
|
@@ -16,13 +16,15 @@ const podTokens = new Map();
|
|
|
16
16
|
|
|
17
17
|
/**
|
|
18
18
|
* Start a test server on a random available port
|
|
19
|
+
* @param {object} options - Server options
|
|
20
|
+
* @param {boolean} options.conneg - Enable content negotiation (default false)
|
|
19
21
|
* @returns {Promise<{server: object, baseUrl: string}>}
|
|
20
22
|
*/
|
|
21
|
-
export async function startTestServer() {
|
|
23
|
+
export async function startTestServer(options = {}) {
|
|
22
24
|
// Clean up any existing test data
|
|
23
25
|
await fs.emptyDir(TEST_DATA_DIR);
|
|
24
26
|
|
|
25
|
-
server = createServer({ logger: false });
|
|
27
|
+
server = createServer({ logger: false, ...options });
|
|
26
28
|
// Use port 0 to let OS assign available port
|
|
27
29
|
await server.listen({ port: 0, host: '127.0.0.1' });
|
|
28
30
|
|
|
@@ -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
|
+
});
|