@agentdocstore/provider-tests 0.2.0

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/README.md ADDED
@@ -0,0 +1,54 @@
1
+ # `@agentdocstore/provider-tests`
2
+
3
+ The reusable conformance suite for
4
+ [AgentDocStore](https://github.com/koushikginjupally/agentdocstore) storage providers.
5
+
6
+ It registers 60 Vitest cases covering CRUD, compare-and-set conflicts, immutable
7
+ versions, stable pagination, opaque cursors, private-search isolation, Unicode
8
+ and size boundaries, expiry, comments, capabilities and delete cascades.
9
+
10
+ ## Install
11
+
12
+ ```bash
13
+ npm install --save-dev @agentdocstore/provider-tests vitest
14
+ npm install @agentdocstore/core
15
+ ```
16
+
17
+ Vitest is a peer dependency so the tests register in **your** runner.
18
+
19
+ ## Use
20
+
21
+ ```ts
22
+ import { runProviderConformance } from '@agentdocstore/provider-tests';
23
+ import { createProvider } from '../src/index.js';
24
+
25
+ runProviderConformance('my provider', async () => {
26
+ const provider = await createProvider({ testDatabase: true });
27
+ return {
28
+ provider,
29
+ cleanup: async () => provider.close(),
30
+ };
31
+ });
32
+ ```
33
+
34
+ ```bash
35
+ npx vitest run
36
+ ```
37
+
38
+ Use an isolated database/schema/bucket per test factory. The suite creates and
39
+ deletes records and must never point at production data.
40
+
41
+ Passing this suite is necessary but not sufficient: run
42
+ `agentdocstore doctor --provider <your-module>` against a realistic local setup,
43
+ and test provider-specific failure, reconnect, migration and concurrency paths.
44
+ See the full [provider SPI guide](../../docs/PROVIDERS.md).
45
+
46
+ ## Compatibility
47
+
48
+ Keep this package on the same minor version as `@agentdocstore/core`. AgentDocStore
49
+ is pre-1.0, so minor releases may evolve the contract; breaking changes are
50
+ called out in the root [CHANGELOG](../../CHANGELOG.md).
51
+
52
+ ## License
53
+
54
+ Apache-2.0.
@@ -0,0 +1,8 @@
1
+ import type { Provider } from '@agentdocstore/core';
2
+ /**
3
+ * Registers a vitest `describe(name, ...)` containing the entire provider
4
+ * conformance suite. A **fresh** provider is created per test via `factory()`
5
+ * and closed afterwards.
6
+ */
7
+ export declare function runProviderConformance(name: string, factory: () => Provider | Promise<Provider>): void;
8
+ //# sourceMappingURL=conformance.d.ts.map
@@ -0,0 +1,686 @@
1
+ /**
2
+ * Reusable provider conformance suite.
3
+ *
4
+ * Call {@link runProviderConformance} from any test file; it registers a full
5
+ * vitest `describe` that exercises every contract clause in the SPI.
6
+ */
7
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
8
+ import { VersionConflictError, NotFoundError, ContentTooLargeError, LIMITS, ID_ALPHABET, ID_LENGTH, isValidId, } from '@agentdocstore/core';
9
+ // ---------------------------------------------------------------------------
10
+ // Helpers
11
+ // ---------------------------------------------------------------------------
12
+ /** Default creation input. */
13
+ function defaultInput(overrides = {}) {
14
+ return {
15
+ title: overrides.title ?? 'Test Document',
16
+ language: (overrides.language ?? 'plaintext'),
17
+ visibility: (overrides.visibility ?? 'PUBLIC'),
18
+ content: overrides.content ?? 'hello world',
19
+ createdBy: overrides.createdBy ?? 'alice',
20
+ ...(overrides.expiresAt !== undefined ? { expiresAt: overrides.expiresAt } : {}),
21
+ };
22
+ }
23
+ /** ISO-8601 regex (loose β€” accepts any valid ISO date-time with or without ms). */
24
+ const ISO_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})$/;
25
+ function isIso(s) {
26
+ return ISO_RE.test(s);
27
+ }
28
+ /** Byte length of a string in UTF-8. */
29
+ function byteLen(s) {
30
+ return new TextEncoder().encode(s).length;
31
+ }
32
+ /** Collect all items across all pages via listByOwner. */
33
+ async function collectAll(repo, owner, pageSize) {
34
+ const all = [];
35
+ let query = { limit: pageSize };
36
+ for (;;) {
37
+ const page = await repo.listByOwner(owner, query);
38
+ all.push(...page.items);
39
+ if (!page.nextCursor)
40
+ break;
41
+ query = { limit: pageSize, cursor: page.nextCursor };
42
+ }
43
+ return all;
44
+ }
45
+ // ---------------------------------------------------------------------------
46
+ // Public API
47
+ // ---------------------------------------------------------------------------
48
+ /**
49
+ * Registers a vitest `describe(name, ...)` containing the entire provider
50
+ * conformance suite. A **fresh** provider is created per test via `factory()`
51
+ * and closed afterwards.
52
+ */
53
+ export function runProviderConformance(name, factory) {
54
+ describe(name, () => {
55
+ let provider;
56
+ beforeEach(async () => {
57
+ provider = await factory();
58
+ });
59
+ afterEach(async () => {
60
+ await provider.close();
61
+ });
62
+ // -----------------------------------------------------------------------
63
+ // 1. Document CRUD
64
+ // -----------------------------------------------------------------------
65
+ describe('Document CRUD', () => {
66
+ it('create returns a fully-populated Document with a valid id', async () => {
67
+ const doc = await provider.repository.create(defaultInput());
68
+ expect(isValidId(doc.id)).toBe(true);
69
+ expect(doc.id).toHaveLength(ID_LENGTH);
70
+ // every char from the id alphabet
71
+ for (const ch of doc.id) {
72
+ expect(ID_ALPHABET).toContain(ch);
73
+ }
74
+ expect(doc.title).toBe('Test Document');
75
+ expect(doc.language).toBe('plaintext');
76
+ expect(doc.visibility).toBe('PUBLIC');
77
+ expect(doc.createdBy).toBe('alice');
78
+ expect(doc.latestVersion).toBe(1);
79
+ expect(isIso(doc.createdAt)).toBe(true);
80
+ expect(isIso(doc.updatedAt)).toBe(true);
81
+ });
82
+ it('create sets sizeBytes equal to content byte length', async () => {
83
+ const content = 'hello 🌍'; // multi-byte
84
+ const doc = await provider.repository.create(defaultInput({ content }));
85
+ // sizeBytes may or may not be on the interface; if present, verify it
86
+ if ('sizeBytes' in doc) {
87
+ expect(doc['sizeBytes']).toBe(byteLen(content));
88
+ }
89
+ });
90
+ it('get round-trips a created doc', async () => {
91
+ const created = await provider.repository.create(defaultInput());
92
+ const got = await provider.repository.get(created.id);
93
+ expect(got).not.toBeNull();
94
+ expect(got.id).toBe(created.id);
95
+ expect(got.title).toBe(created.title);
96
+ expect(got.language).toBe(created.language);
97
+ expect(got.visibility).toBe(created.visibility);
98
+ expect(got.createdBy).toBe(created.createdBy);
99
+ expect(got.latestVersion).toBe(1);
100
+ });
101
+ it('delete removes the doc so get returns null', async () => {
102
+ const doc = await provider.repository.create(defaultInput());
103
+ await provider.repository.delete(doc.id);
104
+ const got = await provider.repository.get(doc.id);
105
+ expect(got).toBeNull();
106
+ });
107
+ it('getVersion retrieves the initial version after create', async () => {
108
+ const doc = await provider.repository.create(defaultInput({ content: 'v1 content' }));
109
+ const v = await provider.repository.getVersion(doc.id, 1);
110
+ expect(v).not.toBeNull();
111
+ expect(v.documentId).toBe(doc.id);
112
+ expect(v.version).toBe(1);
113
+ expect(v.content).toBe('v1 content');
114
+ expect(v.createdBy).toBe('alice');
115
+ expect(isIso(v.createdAt)).toBe(true);
116
+ });
117
+ it('listVersions returns all versions', async () => {
118
+ const doc = await provider.repository.create(defaultInput());
119
+ const versions = await provider.repository.listVersions(doc.id);
120
+ expect(versions).toHaveLength(1);
121
+ expect(versions[0].version).toBe(1);
122
+ });
123
+ });
124
+ // -----------------------------------------------------------------------
125
+ // 2. Versioning + CAS
126
+ // -----------------------------------------------------------------------
127
+ describe('Versioning + CAS', () => {
128
+ it('appendVersion increments latestVersion', async () => {
129
+ const doc = await provider.repository.create(defaultInput());
130
+ const updated = await provider.repository.appendVersion(doc.id, {
131
+ content: 'v2',
132
+ editedBy: 'alice',
133
+ expect: { latestVersion: 1 },
134
+ });
135
+ expect(updated.latestVersion).toBe(2);
136
+ });
137
+ it('each appended version is retrievable by number', async () => {
138
+ const doc = await provider.repository.create(defaultInput({ content: 'v1' }));
139
+ await provider.repository.appendVersion(doc.id, {
140
+ content: 'v2',
141
+ editedBy: 'alice',
142
+ expect: { latestVersion: 1 },
143
+ });
144
+ await provider.repository.appendVersion(doc.id, {
145
+ content: 'v3',
146
+ editedBy: 'bob',
147
+ expect: { latestVersion: 2 },
148
+ });
149
+ const ver1 = await provider.repository.getVersion(doc.id, 1);
150
+ const ver2 = await provider.repository.getVersion(doc.id, 2);
151
+ const ver3 = await provider.repository.getVersion(doc.id, 3);
152
+ expect(ver1.content).toBe('v1');
153
+ expect(ver2.content).toBe('v2');
154
+ expect(ver3.content).toBe('v3');
155
+ expect(ver3.createdBy).toBe('bob');
156
+ });
157
+ it('stores an optional edit message with the version it describes', async () => {
158
+ const doc = await provider.repository.create(defaultInput({ content: 'v1' }));
159
+ await provider.repository.appendVersion(doc.id, {
160
+ content: 'v2',
161
+ editedBy: 'alice',
162
+ message: 'Fix the intro',
163
+ expect: { latestVersion: 1 },
164
+ });
165
+ await provider.repository.appendVersion(doc.id, {
166
+ content: 'v3',
167
+ editedBy: 'alice',
168
+ expect: { latestVersion: 2 },
169
+ });
170
+ expect((await provider.repository.getVersion(doc.id, 2)).message).toBe('Fix the intro');
171
+ expect((await provider.repository.getVersion(doc.id, 3)).message).toBeUndefined();
172
+ const all = await provider.repository.listVersions(doc.id);
173
+ expect(all.map((v) => v.message)).toEqual([undefined, 'Fix the intro', undefined]);
174
+ });
175
+ it('stale expect.latestVersion throws VersionConflictError', async () => {
176
+ const doc = await provider.repository.create(defaultInput());
177
+ // advance to version 2
178
+ await provider.repository.appendVersion(doc.id, {
179
+ content: 'v2',
180
+ editedBy: 'alice',
181
+ expect: { latestVersion: 1 },
182
+ });
183
+ // try with stale version 1
184
+ await expect(provider.repository.appendVersion(doc.id, {
185
+ content: 'v3-stale',
186
+ editedBy: 'alice',
187
+ expect: { latestVersion: 1 },
188
+ })).rejects.toThrow(VersionConflictError);
189
+ });
190
+ it('correct expect.latestVersion succeeds after prior conflict', async () => {
191
+ const doc = await provider.repository.create(defaultInput());
192
+ await provider.repository.appendVersion(doc.id, {
193
+ content: 'v2',
194
+ editedBy: 'alice',
195
+ expect: { latestVersion: 1 },
196
+ });
197
+ // stale attempt
198
+ await expect(provider.repository.appendVersion(doc.id, {
199
+ content: 'v3-stale',
200
+ editedBy: 'alice',
201
+ expect: { latestVersion: 1 },
202
+ })).rejects.toThrow(VersionConflictError);
203
+ // correct attempt
204
+ const updated = await provider.repository.appendVersion(doc.id, {
205
+ content: 'v3-good',
206
+ editedBy: 'alice',
207
+ expect: { latestVersion: 2 },
208
+ });
209
+ expect(updated.latestVersion).toBe(3);
210
+ });
211
+ it('immutability: version 1 content unchanged after multiple appends', async () => {
212
+ const originalContent = 'original content preserved';
213
+ const doc = await provider.repository.create(defaultInput({ content: originalContent }));
214
+ await provider.repository.appendVersion(doc.id, {
215
+ content: 'version 2 overwrite',
216
+ editedBy: 'alice',
217
+ expect: { latestVersion: 1 },
218
+ });
219
+ await provider.repository.appendVersion(doc.id, {
220
+ content: 'version 3 overwrite',
221
+ editedBy: 'alice',
222
+ expect: { latestVersion: 2 },
223
+ });
224
+ const v1 = await provider.repository.getVersion(doc.id, 1);
225
+ expect(v1.content).toBe(originalContent);
226
+ });
227
+ it('listVersions returns all versions after appends', async () => {
228
+ const doc = await provider.repository.create(defaultInput());
229
+ await provider.repository.appendVersion(doc.id, {
230
+ content: 'v2',
231
+ editedBy: 'alice',
232
+ expect: { latestVersion: 1 },
233
+ });
234
+ const versions = await provider.repository.listVersions(doc.id);
235
+ expect(versions).toHaveLength(2);
236
+ const nums = versions.map((v) => v.version).sort();
237
+ expect(nums).toEqual([1, 2]);
238
+ });
239
+ });
240
+ // -----------------------------------------------------------------------
241
+ // 3. Unknown and malformed ids
242
+ // -----------------------------------------------------------------------
243
+ describe('Unknown and malformed ids', () => {
244
+ it('get returns null for a well-formed but absent id', async () => {
245
+ const result = await provider.repository.get('AAAAAAAAAA');
246
+ expect(result).toBeNull();
247
+ });
248
+ it('get returns null for malformed ids', async () => {
249
+ expect(await provider.repository.get('../etc/passwd')).toBeNull();
250
+ expect(await provider.repository.get('')).toBeNull();
251
+ expect(await provider.repository.get('short')).toBeNull();
252
+ expect(await provider.repository.get('has spaces!')).toBeNull();
253
+ });
254
+ it('getVersion returns null for absent doc', async () => {
255
+ expect(await provider.repository.getVersion('AAAAAAAAAA', 1)).toBeNull();
256
+ });
257
+ it('getVersion returns null for malformed id', async () => {
258
+ expect(await provider.repository.getVersion('../etc/passwd', 1)).toBeNull();
259
+ });
260
+ it('listVersions returns empty for absent doc', async () => {
261
+ const versions = await provider.repository.listVersions('AAAAAAAAAA');
262
+ expect(versions).toHaveLength(0);
263
+ });
264
+ it('listVersions returns empty for malformed id', async () => {
265
+ const versions = await provider.repository.listVersions('../etc/passwd');
266
+ expect(versions).toHaveLength(0);
267
+ });
268
+ it('appendVersion throws NotFoundError for absent doc', async () => {
269
+ await expect(provider.repository.appendVersion('AAAAAAAAAA', {
270
+ content: 'x',
271
+ editedBy: 'alice',
272
+ expect: { latestVersion: 1 },
273
+ })).rejects.toThrow(NotFoundError);
274
+ });
275
+ it('updateMeta throws NotFoundError for absent doc', async () => {
276
+ await expect(provider.repository.updateMeta('AAAAAAAAAA', { title: 'x' })).rejects.toThrow(NotFoundError);
277
+ });
278
+ it('setVisibility throws NotFoundError for absent doc', async () => {
279
+ await expect(provider.repository.setVisibility('AAAAAAAAAA', 'PRIVATE')).rejects.toThrow(NotFoundError);
280
+ });
281
+ it('delete throws NotFoundError for absent doc', async () => {
282
+ await expect(provider.repository.delete('AAAAAAAAAA')).rejects.toThrow(NotFoundError);
283
+ });
284
+ });
285
+ // -----------------------------------------------------------------------
286
+ // 4. Metadata
287
+ // -----------------------------------------------------------------------
288
+ describe('Metadata', () => {
289
+ it('updateMeta changes title', async () => {
290
+ const doc = await provider.repository.create(defaultInput());
291
+ const updated = await provider.repository.updateMeta(doc.id, { title: 'New Title' });
292
+ expect(updated.title).toBe('New Title');
293
+ const got = await provider.repository.get(doc.id);
294
+ expect(got.title).toBe('New Title');
295
+ });
296
+ it('updateMeta changes language', async () => {
297
+ const doc = await provider.repository.create(defaultInput());
298
+ const updated = await provider.repository.updateMeta(doc.id, { language: 'typescript' });
299
+ expect(updated.language).toBe('typescript');
300
+ });
301
+ it('expiresAt: null clears an existing expiry', async () => {
302
+ const future = new Date(Date.now() + 86400_000).toISOString();
303
+ const doc = await provider.repository.create(defaultInput({ expiresAt: future }));
304
+ expect(doc.expiresAt).toBeDefined();
305
+ const updated = await provider.repository.updateMeta(doc.id, { expiresAt: null });
306
+ expect(updated.expiresAt).toBeUndefined();
307
+ const got = await provider.repository.get(doc.id);
308
+ expect(got.expiresAt).toBeUndefined();
309
+ });
310
+ it('expiresAt: undefined leaves existing expiry untouched', async () => {
311
+ const future = new Date(Date.now() + 86400_000).toISOString();
312
+ const doc = await provider.repository.create(defaultInput({ expiresAt: future }));
313
+ // update title only, do not pass expiresAt
314
+ const updated = await provider.repository.updateMeta(doc.id, { title: 'Changed' });
315
+ expect(updated.expiresAt).toBe(future);
316
+ });
317
+ it('setVisibility flips PUBLIC to PRIVATE and back', async () => {
318
+ const doc = await provider.repository.create(defaultInput({ visibility: 'PUBLIC' }));
319
+ const priv = await provider.repository.setVisibility(doc.id, 'PRIVATE');
320
+ expect(priv.visibility).toBe('PRIVATE');
321
+ const pub = await provider.repository.setVisibility(doc.id, 'PUBLIC');
322
+ expect(pub.visibility).toBe('PUBLIC');
323
+ });
324
+ });
325
+ // -----------------------------------------------------------------------
326
+ // 5. Pagination
327
+ // -----------------------------------------------------------------------
328
+ describe('Pagination', () => {
329
+ it('pages return every item exactly once (no dups, no gaps)', async () => {
330
+ const owner = 'paginator';
331
+ const total = 7;
332
+ const ids = [];
333
+ for (let i = 0; i < total; i++) {
334
+ const doc = await provider.repository.create(defaultInput({ title: `Document ${i}`, createdBy: owner }));
335
+ ids.push(doc.id);
336
+ }
337
+ const collected = await collectAll(provider.repository, owner, 3);
338
+ const collectedIds = collected.map((b) => b.id);
339
+ expect(collectedIds).toHaveLength(total);
340
+ // every created id appears
341
+ for (const id of ids) {
342
+ expect(collectedIds).toContain(id);
343
+ }
344
+ // no duplicates
345
+ expect(new Set(collectedIds).size).toBe(total);
346
+ });
347
+ it('ordering is newest-first and stable across pages', async () => {
348
+ const owner = 'orderer';
349
+ const created = [];
350
+ for (let i = 0; i < 5; i++) {
351
+ const doc = await provider.repository.create(defaultInput({ title: `Document ${i}`, createdBy: owner }));
352
+ created.push(doc.id);
353
+ // `createdAt` is an ISO-8601 string with millisecond resolution, so a
354
+ // tight creation loop produces ties and "newest-first" becomes
355
+ // ambiguous β€” the contract then only guarantees a deterministic
356
+ // tie-break, not creation order. Separate the timestamps so this case
357
+ // tests the ordering rule rather than the host's execution speed.
358
+ await new Promise((resolve) => setTimeout(resolve, 2));
359
+ }
360
+ const collected = await collectAll(provider.repository, owner, 2);
361
+ const collectedIds = collected.map((b) => b.id);
362
+ // newest-first means reverse of creation order
363
+ expect(collectedIds).toEqual([...created].reverse());
364
+ });
365
+ it('ordering is deterministic when createdAt values tie', async () => {
366
+ const owner = 'tie-breaker';
367
+ // Created as fast as possible, so several documents are expected to share a
368
+ // millisecond. The contract does not say WHICH order ties take, only
369
+ // that it is a stable total order β€” so assert repeatability, which is
370
+ // the property pagination actually depends on.
371
+ for (let i = 0; i < 5; i++) {
372
+ await provider.repository.create(defaultInput({ title: `Tie ${i}`, createdBy: owner }));
373
+ }
374
+ const first = (await collectAll(provider.repository, owner, 2)).map((b) => b.id);
375
+ const second = (await collectAll(provider.repository, owner, 2)).map((b) => b.id);
376
+ expect(first).toHaveLength(5);
377
+ expect(second).toEqual(first);
378
+ });
379
+ it('garbage cursor does not corrupt results', async () => {
380
+ const owner = 'garbage-cursor';
381
+ await provider.repository.create(defaultInput({ createdBy: owner }));
382
+ // a garbage cursor should either: return an empty page, or throw,
383
+ // but never corrupt the data
384
+ const page = await provider.repository.listByOwner(owner, {
385
+ limit: 10,
386
+ cursor: 'ZZZZ-NOT-A-REAL-CURSOR',
387
+ });
388
+ // just verify it returned a valid page shape
389
+ expect(Array.isArray(page.items)).toBe(true);
390
+ });
391
+ });
392
+ // -----------------------------------------------------------------------
393
+ // 6. Search visibility
394
+ // -----------------------------------------------------------------------
395
+ describe('Search visibility', () => {
396
+ it('viewer sees PUBLIC documents', async () => {
397
+ const doc = await provider.repository.create(defaultInput({ title: 'public searchable', visibility: 'PUBLIC', createdBy: 'owner1' }));
398
+ provider.search.add({
399
+ documentId: doc.id,
400
+ owner: 'owner1',
401
+ visibility: 'PUBLIC',
402
+ title: 'public searchable',
403
+ content: 'hello',
404
+ });
405
+ const results = provider.search.query('searchable', 'viewer1');
406
+ expect(results.hits.length).toBeGreaterThanOrEqual(1);
407
+ expect(results.hits.some((h) => h.documentId === doc.id)).toBe(true);
408
+ });
409
+ it('viewer sees own PRIVATE documents', async () => {
410
+ const doc = await provider.repository.create(defaultInput({ title: 'private mine', visibility: 'PRIVATE', createdBy: 'owner1' }));
411
+ provider.search.add({
412
+ documentId: doc.id,
413
+ owner: 'owner1',
414
+ visibility: 'PRIVATE',
415
+ title: 'private mine',
416
+ content: 'secret',
417
+ });
418
+ const results = provider.search.query('private', 'owner1');
419
+ expect(results.hits.some((h) => h.documentId === doc.id)).toBe(true);
420
+ });
421
+ it('viewer cannot see another owner PRIVATE doc', async () => {
422
+ const doc = await provider.repository.create(defaultInput({ title: 'private other', visibility: 'PRIVATE', createdBy: 'owner1' }));
423
+ provider.search.add({
424
+ documentId: doc.id,
425
+ owner: 'owner1',
426
+ visibility: 'PRIVATE',
427
+ title: 'private other',
428
+ content: 'secret other',
429
+ });
430
+ const results = provider.search.query('private', 'intruder');
431
+ expect(results.hits.every((h) => h.documentId !== doc.id)).toBe(true);
432
+ });
433
+ it('null viewer sees only PUBLIC', async () => {
434
+ const pub = await provider.repository.create(defaultInput({ title: 'anon public', visibility: 'PUBLIC', createdBy: 'owner1' }));
435
+ const priv = await provider.repository.create(defaultInput({ title: 'anon private', visibility: 'PRIVATE', createdBy: 'owner1' }));
436
+ provider.search.add({
437
+ documentId: pub.id,
438
+ owner: 'owner1',
439
+ visibility: 'PUBLIC',
440
+ title: 'anon public',
441
+ content: 'public content',
442
+ });
443
+ provider.search.add({
444
+ documentId: priv.id,
445
+ owner: 'owner1',
446
+ visibility: 'PRIVATE',
447
+ title: 'anon private',
448
+ content: 'private content',
449
+ });
450
+ const results = provider.search.query('anon', null);
451
+ expect(results.hits.some((h) => h.documentId === pub.id)).toBe(true);
452
+ expect(results.hits.every((h) => h.documentId !== priv.id)).toBe(true);
453
+ });
454
+ it('total reflects visible matches before pagination', async () => {
455
+ const owner = 'totalowner';
456
+ for (let i = 0; i < 5; i++) {
457
+ const doc = await provider.repository.create(defaultInput({ title: `totaldoc ${i}`, visibility: 'PUBLIC', createdBy: owner }));
458
+ provider.search.add({
459
+ documentId: doc.id,
460
+ owner,
461
+ visibility: 'PUBLIC',
462
+ title: `totaldoc ${i}`,
463
+ content: `totaldoc content ${i}`,
464
+ });
465
+ }
466
+ const results = provider.search.query('totaldoc', owner, { limit: 2 });
467
+ expect(results.total).toBe(5);
468
+ expect(results.hits).toHaveLength(2);
469
+ });
470
+ it('remove de-indexes a doc', async () => {
471
+ const doc = await provider.repository.create(defaultInput({ title: 'removable search', visibility: 'PUBLIC', createdBy: 'alice' }));
472
+ provider.search.add({
473
+ documentId: doc.id,
474
+ owner: 'alice',
475
+ visibility: 'PUBLIC',
476
+ title: 'removable search',
477
+ content: 'removable content',
478
+ });
479
+ // confirm it's findable
480
+ let results = provider.search.query('removable', 'alice');
481
+ expect(results.hits.some((h) => h.documentId === doc.id)).toBe(true);
482
+ provider.search.remove(doc.id);
483
+ results = provider.search.query('removable', 'alice');
484
+ expect(results.hits.every((h) => h.documentId !== doc.id)).toBe(true);
485
+ });
486
+ it('snapshot/restore round-trips the index', async () => {
487
+ const doc = await provider.repository.create(defaultInput({ title: 'snapshot target', visibility: 'PUBLIC', createdBy: 'alice' }));
488
+ provider.search.add({
489
+ documentId: doc.id,
490
+ owner: 'alice',
491
+ visibility: 'PUBLIC',
492
+ title: 'snapshot target',
493
+ content: 'snap content',
494
+ });
495
+ const snap = provider.search.snapshot();
496
+ provider.search.clear();
497
+ expect(provider.search.size()).toBe(0);
498
+ provider.search.restore(snap);
499
+ expect(provider.search.size()).toBe(1);
500
+ const results = provider.search.query('snapshot', 'alice');
501
+ expect(results.hits.some((h) => h.documentId === doc.id)).toBe(true);
502
+ });
503
+ });
504
+ // -----------------------------------------------------------------------
505
+ // 7. Comments
506
+ // -----------------------------------------------------------------------
507
+ describe('Comments', () => {
508
+ it('add returns a populated comment', async () => {
509
+ const doc = await provider.repository.create(defaultInput());
510
+ const comment = await provider.comments.add(doc.id, {
511
+ author: 'commenter',
512
+ body: 'Nice doc!',
513
+ });
514
+ expect(comment.documentId).toBe(doc.id);
515
+ expect(comment.author).toBe('commenter');
516
+ expect(comment.body).toBe('Nice doc!');
517
+ expect(comment.resolved).toBe(false);
518
+ expect(isIso(comment.createdAt)).toBe(true);
519
+ expect(isValidId(comment.id)).toBe(true);
520
+ });
521
+ it('list returns all comments for a doc', async () => {
522
+ const doc = await provider.repository.create(defaultInput());
523
+ await provider.comments.add(doc.id, { author: 'a', body: 'first' });
524
+ await provider.comments.add(doc.id, { author: 'b', body: 'second' });
525
+ const comments = await provider.comments.list(doc.id);
526
+ expect(comments).toHaveLength(2);
527
+ const bodies = comments.map((c) => c.body);
528
+ expect(bodies).toContain('first');
529
+ expect(bodies).toContain('second');
530
+ });
531
+ it('setResolved toggles the resolved flag', async () => {
532
+ const doc = await provider.repository.create(defaultInput());
533
+ const comment = await provider.comments.add(doc.id, {
534
+ author: 'alice',
535
+ body: 'todo',
536
+ });
537
+ expect(comment.resolved).toBe(false);
538
+ const resolved = await provider.comments.setResolved(doc.id, comment.id, true);
539
+ expect(resolved.resolved).toBe(true);
540
+ const unresolved = await provider.comments.setResolved(doc.id, comment.id, false);
541
+ expect(unresolved.resolved).toBe(false);
542
+ });
543
+ it('delete removes a specific comment', async () => {
544
+ const doc = await provider.repository.create(defaultInput());
545
+ const c1 = await provider.comments.add(doc.id, { author: 'a', body: 'keep' });
546
+ const c2 = await provider.comments.add(doc.id, { author: 'b', body: 'remove' });
547
+ await provider.comments.delete(doc.id, c2.id);
548
+ const remaining = await provider.comments.list(doc.id);
549
+ expect(remaining).toHaveLength(1);
550
+ expect(remaining[0].id).toBe(c1.id);
551
+ });
552
+ it('deleting a doc cascades and removes its comments', async () => {
553
+ const doc = await provider.repository.create(defaultInput());
554
+ await provider.comments.add(doc.id, { author: 'a', body: 'orphaned' });
555
+ await provider.repository.delete(doc.id);
556
+ const comments = await provider.comments.list(doc.id);
557
+ expect(comments).toHaveLength(0);
558
+ });
559
+ });
560
+ // -----------------------------------------------------------------------
561
+ // 8. Expiry
562
+ // -----------------------------------------------------------------------
563
+ describe('Expiry', () => {
564
+ it('listExpired returns documents at or before nowIso', async () => {
565
+ const past = new Date(Date.now() - 86400_000).toISOString();
566
+ const future = new Date(Date.now() + 86400_000).toISOString();
567
+ const expired = await provider.repository.create(defaultInput({ title: 'expired', expiresAt: past }));
568
+ await provider.repository.create(defaultInput({ title: 'still alive', expiresAt: future }));
569
+ const now = new Date().toISOString();
570
+ const ids = await provider.repository.listExpired(now, 100);
571
+ expect(ids).toContain(expired.id);
572
+ });
573
+ it('listExpired does not return non-expired documents', async () => {
574
+ const future = new Date(Date.now() + 86400_000).toISOString();
575
+ const alive = await provider.repository.create(defaultInput({ title: 'alive', expiresAt: future }));
576
+ const now = new Date().toISOString();
577
+ const ids = await provider.repository.listExpired(now, 100);
578
+ expect(ids).not.toContain(alive.id);
579
+ });
580
+ it('listExpired honours limit', async () => {
581
+ const past = new Date(Date.now() - 86400_000).toISOString();
582
+ for (let i = 0; i < 5; i++) {
583
+ await provider.repository.create(defaultInput({ title: `exp ${i}`, expiresAt: past }));
584
+ }
585
+ const ids = await provider.repository.listExpired(new Date().toISOString(), 2);
586
+ expect(ids.length).toBeLessThanOrEqual(2);
587
+ expect(ids.length).toBeGreaterThanOrEqual(1);
588
+ });
589
+ it('documents without expiresAt are never returned by listExpired', async () => {
590
+ const doc = await provider.repository.create(defaultInput());
591
+ const ids = await provider.repository.listExpired(new Date(Date.now() + 86400_000).toISOString(), 100);
592
+ expect(ids).not.toContain(doc.id);
593
+ });
594
+ });
595
+ // -----------------------------------------------------------------------
596
+ // 9. Size limits
597
+ // -----------------------------------------------------------------------
598
+ describe('Size limits', () => {
599
+ it('create throws ContentTooLargeError for oversized content', async () => {
600
+ const oversize = 'x'.repeat(LIMITS.MAX_CONTENT_BYTES + 1);
601
+ await expect(provider.repository.create(defaultInput({ content: oversize }))).rejects.toThrow(ContentTooLargeError);
602
+ });
603
+ it('appendVersion throws ContentTooLargeError for oversized content', async () => {
604
+ const doc = await provider.repository.create(defaultInput());
605
+ const oversize = 'x'.repeat(LIMITS.MAX_CONTENT_BYTES + 1);
606
+ await expect(provider.repository.appendVersion(doc.id, {
607
+ content: oversize,
608
+ editedBy: 'alice',
609
+ expect: { latestVersion: 1 },
610
+ })).rejects.toThrow(ContentTooLargeError);
611
+ });
612
+ it('content exactly at size boundary is accepted', async () => {
613
+ const exact = 'x'.repeat(LIMITS.MAX_CONTENT_BYTES);
614
+ const doc = await provider.repository.create(defaultInput({ content: exact }));
615
+ expect(doc.latestVersion).toBe(1);
616
+ });
617
+ it('content one byte over boundary is rejected', async () => {
618
+ const overByOne = 'x'.repeat(LIMITS.MAX_CONTENT_BYTES + 1);
619
+ await expect(provider.repository.create(defaultInput({ content: overByOne }))).rejects.toThrow(ContentTooLargeError);
620
+ });
621
+ });
622
+ // -----------------------------------------------------------------------
623
+ // 10. Unicode and boundaries
624
+ // -----------------------------------------------------------------------
625
+ describe('Unicode and boundaries', () => {
626
+ it('multi-byte content (emoji) round-trips byte-exactly', async () => {
627
+ const content = 'πŸŽ‰πŸŒπŸ¦€ Hello, δΈ–η•Œ! Γ‘oΓ±o 😎';
628
+ const doc = await provider.repository.create(defaultInput({ content }));
629
+ const v = await provider.repository.getVersion(doc.id, 1);
630
+ expect(v.content).toBe(content);
631
+ });
632
+ it('CJK content round-trips', async () => {
633
+ const content = 'ζΌ’ε­—γƒ†γ‚Ήγƒˆν•œκ΅­μ–΄ζ΅‹θ―•';
634
+ const doc = await provider.repository.create(defaultInput({ content }));
635
+ const v = await provider.repository.getVersion(doc.id, 1);
636
+ expect(v.content).toBe(content);
637
+ });
638
+ it('combining marks round-trip', async () => {
639
+ // Γ© as e + combining acute accent
640
+ const content = 'e\u0301 cafe\u0301';
641
+ const doc = await provider.repository.create(defaultInput({ content }));
642
+ const v = await provider.repository.getVersion(doc.id, 1);
643
+ expect(v.content).toBe(content);
644
+ });
645
+ it('empty-string content is accepted and round-trips', async () => {
646
+ const doc = await provider.repository.create(defaultInput({ content: '' }));
647
+ const v = await provider.repository.getVersion(doc.id, 1);
648
+ expect(v.content).toBe('');
649
+ });
650
+ it('multi-byte boundary: content at exact byte limit accepted', async () => {
651
+ // Create content that uses multi-byte chars to exactly hit the limit.
652
+ // Each 'πŸŽ‰' is 4 bytes. Fill with ASCII then pad to exact boundary.
653
+ const asciiPart = 'x'.repeat(LIMITS.MAX_CONTENT_BYTES - 4);
654
+ const content = asciiPart + 'πŸŽ‰'; // exactly MAX_CONTENT_BYTES bytes
655
+ expect(byteLen(content)).toBe(LIMITS.MAX_CONTENT_BYTES);
656
+ const doc = await provider.repository.create(defaultInput({ content }));
657
+ expect(doc.latestVersion).toBe(1);
658
+ });
659
+ it('multi-byte boundary: one byte over limit rejected', async () => {
660
+ const asciiPart = 'x'.repeat(LIMITS.MAX_CONTENT_BYTES - 3);
661
+ const content = asciiPart + 'πŸŽ‰'; // MAX_CONTENT_BYTES + 1 bytes
662
+ expect(byteLen(content)).toBe(LIMITS.MAX_CONTENT_BYTES + 1);
663
+ await expect(provider.repository.create(defaultInput({ content }))).rejects.toThrow(ContentTooLargeError);
664
+ });
665
+ });
666
+ describe('Capabilities declaration', () => {
667
+ it('declares every capability field with the right type', () => {
668
+ const caps = provider.capabilities;
669
+ expect(['core-fallback', 'native']).toContain(caps.search);
670
+ expect(typeof caps.nativeTtl).toBe('boolean');
671
+ expect(typeof caps.atomicVersioning).toBe('boolean');
672
+ // requiresNetwork gates offline mode: an undeclared value would let a
673
+ // remote store start up inside an instance promising zero egress.
674
+ expect(typeof caps.requiresNetwork).toBe('boolean');
675
+ });
676
+ it('exposes healthCheck as a function when present, and it does not throw', async () => {
677
+ if (provider.healthCheck === undefined)
678
+ return; // optional by contract
679
+ expect(typeof provider.healthCheck).toBe('function');
680
+ const health = await provider.healthCheck();
681
+ expect(typeof health.healthy).toBe('boolean');
682
+ });
683
+ });
684
+ });
685
+ }
686
+ //# sourceMappingURL=conformance.js.map
@@ -0,0 +1,8 @@
1
+ /**
2
+ * @agentdocstore/provider-tests β€” reusable provider conformance suite.
3
+ *
4
+ * Third-party storage backends import {@link runProviderConformance} and call
5
+ * it from their own test file to prove SPI compliance.
6
+ */
7
+ export { runProviderConformance } from './conformance.js';
8
+ //# sourceMappingURL=index.d.ts.map
package/dist/index.js ADDED
@@ -0,0 +1,8 @@
1
+ /**
2
+ * @agentdocstore/provider-tests β€” reusable provider conformance suite.
3
+ *
4
+ * Third-party storage backends import {@link runProviderConformance} and call
5
+ * it from their own test file to prove SPI compliance.
6
+ */
7
+ export { runProviderConformance } from './conformance.js';
8
+ //# sourceMappingURL=index.js.map
package/package.json ADDED
@@ -0,0 +1,60 @@
1
+ {
2
+ "name": "@agentdocstore/provider-tests",
3
+ "version": "0.2.0",
4
+ "description": "Reusable conformance suite for AgentDocStore storage providers. Run it against your own Provider implementation to prove SPI compliance.",
5
+ "homepage": "https://github.com/koushikginjupally/agentdocstore/tree/main/packages/provider-tests#readme",
6
+ "bugs": {
7
+ "url": "https://github.com/koushikginjupally/agentdocstore/issues"
8
+ },
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/koushikginjupally/agentdocstore.git",
12
+ "directory": "packages/provider-tests"
13
+ },
14
+ "license": "Apache-2.0",
15
+ "type": "module",
16
+ "main": "./dist/index.js",
17
+ "types": "./dist/index.d.ts",
18
+ "exports": {
19
+ ".": {
20
+ "types": "./dist/index.d.ts",
21
+ "default": "./dist/index.js"
22
+ }
23
+ },
24
+ "files": [
25
+ "dist",
26
+ "!dist/**/*.map",
27
+ "!dist/self-check.*"
28
+ ],
29
+ "publishConfig": {
30
+ "access": "public",
31
+ "provenance": true
32
+ },
33
+ "engines": {
34
+ "node": ">=20"
35
+ },
36
+ "keywords": [
37
+ "agentdocstore",
38
+ "conformance",
39
+ "contract-tests",
40
+ "storage-provider"
41
+ ],
42
+ "scripts": {
43
+ "clean": "node ../../scripts/clean.mjs packages/provider-tests",
44
+ "prebuild": "npm run clean",
45
+ "build": "tsc -p tsconfig.json",
46
+ "prepack": "npm run build",
47
+ "typecheck": "tsc -p tsconfig.json --noEmit",
48
+ "test": "vitest run"
49
+ },
50
+ "dependencies": {
51
+ "@agentdocstore/core": "^0.2.0"
52
+ },
53
+ "peerDependencies": {
54
+ "vitest": ">=2.1.0"
55
+ },
56
+ "devDependencies": {
57
+ "typescript": "^5.6.0",
58
+ "vitest": "^5.0.0"
59
+ }
60
+ }