experiments in a post-browser web
at main 1660 lines 56 kB view raw
1/** 2 * Comprehensive tests for the Unified Sync Engine. 3 * 4 * Covers all data + sync behavior against the memory adapter. 5 * Run: node --test sync/test.js 6 */ 7 8import { describe, it, before, beforeEach } from 'node:test'; 9import assert from 'node:assert'; 10import { createEngine } from './index.js'; 11import { createMemoryAdapter } from './adapters/memory.js'; 12import { calculateFrecency } from './frecency.js'; 13import { DATASTORE_VERSION, PROTOCOL_VERSION } from './version.js'; 14 15// ==================== Helpers ==================== 16 17function createTestEngine() { 18 const adapter = createMemoryAdapter(); 19 const { data } = createEngine(adapter); 20 return { adapter, data }; 21} 22 23function createSyncTestEngine(serverItems = [], pushResponses = []) { 24 const adapter = createMemoryAdapter(); 25 let syncConfig = { 26 serverUrl: 'http://test-server.local', 27 apiKey: 'test-api-key', 28 serverProfileId: 'test-profile', 29 lastSyncTime: 0, 30 }; 31 32 let pushIndex = 0; 33 const fetchedUrls = []; 34 35 // Mock fetch 36 const mockFetch = async (url, options) => { 37 fetchedUrls.push(url); 38 const method = options?.method || 'GET'; 39 40 if (method === 'GET') { 41 // Pull response 42 return { 43 ok: true, 44 status: 200, 45 headers: new Map([ 46 ['X-Peek-Datastore-Version', String(DATASTORE_VERSION)], 47 ['X-Peek-Protocol-Version', String(PROTOCOL_VERSION)], 48 ]), 49 text: async () => '', 50 json: async () => ({ items: serverItems }), 51 }; 52 } 53 54 if (method === 'POST') { 55 // Push response 56 const body = JSON.parse(options.body); 57 const response = pushResponses[pushIndex] || { 58 id: `server-${body.sync_id}`, 59 created: true, 60 }; 61 pushIndex++; 62 return { 63 ok: true, 64 status: 200, 65 headers: new Map([ 66 ['X-Peek-Datastore-Version', String(DATASTORE_VERSION)], 67 ['X-Peek-Protocol-Version', String(PROTOCOL_VERSION)], 68 ]), 69 text: async () => '', 70 json: async () => response, 71 }; 72 } 73 74 return { ok: false, status: 404, text: async () => 'Not found' }; 75 }; 76 77 // Mock headers.get 78 mockFetch._patchHeaders = true; 79 80 const { data, sync } = createEngine(adapter, { 81 getConfig: () => syncConfig, 82 setConfig: (updates) => { 83 syncConfig = { ...syncConfig, ...updates }; 84 }, 85 fetch: mockFetch, 86 }); 87 88 return { adapter, data, sync, getConfig: () => syncConfig, fetchedUrls }; 89} 90 91// Small delay to ensure different timestamps 92function tick() { 93 return new Promise(resolve => setTimeout(resolve, 2)); 94} 95 96// ==================== Version Tests ==================== 97 98describe('Version Constants', () => { 99 it('should export version constants', () => { 100 assert.strictEqual(typeof DATASTORE_VERSION, 'number'); 101 assert.strictEqual(typeof PROTOCOL_VERSION, 'number'); 102 assert.strictEqual(DATASTORE_VERSION, 1); 103 assert.strictEqual(PROTOCOL_VERSION, 1); 104 }); 105}); 106 107// ==================== Frecency Tests ==================== 108 109describe('Frecency', () => { 110 it('should calculate positive score for recent usage', () => { 111 const score = calculateFrecency(1, Date.now()); 112 assert.ok(score > 0, 'score should be positive'); 113 assert.strictEqual(score, 10); // frequency(1) * 10 * decayFactor(~1.0) 114 }); 115 116 it('should calculate higher score for higher frequency', () => { 117 const now = Date.now(); 118 const score1 = calculateFrecency(1, now); 119 const score3 = calculateFrecency(3, now); 120 assert.ok(score3 > score1); 121 }); 122 123 it('should decay score over time', () => { 124 const now = Date.now(); 125 const recent = calculateFrecency(5, now); 126 const weekAgo = calculateFrecency(5, now - 7 * 24 * 60 * 60 * 1000); 127 assert.ok(recent > weekAgo, 'recent should score higher than week-old'); 128 }); 129}); 130 131// ==================== Data Engine: Item CRUD ==================== 132 133describe('DataEngine: Items', () => { 134 let adapter, data; 135 136 beforeEach(async () => { 137 ({ adapter, data } = createTestEngine()); 138 await adapter.open(); 139 }); 140 141 it('should add an item and return id', async () => { 142 const { id } = await data.addItem('url', { content: 'https://example.com' }); 143 assert.ok(id, 'should return an id'); 144 assert.match(id, /^[0-9a-f-]{36}$/, 'id should be a UUID'); 145 }); 146 147 it('should get an item by id', async () => { 148 const { id } = await data.addItem('url', { content: 'https://example.com' }); 149 const item = await data.getItem(id); 150 assert.ok(item); 151 assert.strictEqual(item.type, 'url'); 152 assert.strictEqual(item.content, 'https://example.com'); 153 assert.strictEqual(item.deletedAt, 0); 154 }); 155 156 it('should return null for non-existent item', async () => { 157 const item = await data.getItem('non-existent'); 158 assert.strictEqual(item, null); 159 }); 160 161 it('should update an item', async () => { 162 const { id } = await data.addItem('text', { content: 'original' }); 163 await data.updateItem(id, { content: 'updated' }); 164 const item = await data.getItem(id); 165 assert.strictEqual(item.content, 'updated'); 166 }); 167 168 it('should soft-delete an item', async () => { 169 const { id } = await data.addItem('url', { content: 'https://example.com' }); 170 await data.deleteItem(id); 171 const item = await data.getItem(id); 172 assert.strictEqual(item, null, 'soft-deleted item should not be returned'); 173 }); 174 175 it('should hard-delete an item', async () => { 176 const { id } = await data.addItem('url', { content: 'https://example.com' }); 177 await data.hardDeleteItem(id); 178 const item = await data.getItem(id); 179 assert.strictEqual(item, null); 180 }); 181 182 it('should query all items', async () => { 183 await data.addItem('url', { content: 'https://example.com' }); 184 await data.addItem('text', { content: 'A note' }); 185 await data.addItem('tagset', {}); 186 187 const items = await data.queryItems(); 188 assert.strictEqual(items.length, 3); 189 }); 190 191 it('should filter items by type', async () => { 192 await data.addItem('url', { content: 'https://example.com' }); 193 await data.addItem('text', { content: 'A note' }); 194 await data.addItem('tagset', {}); 195 196 const urls = await data.queryItems({ type: 'url' }); 197 assert.strictEqual(urls.length, 1); 198 assert.strictEqual(urls[0].type, 'url'); 199 }); 200 201 it('should exclude soft-deleted items by default', async () => { 202 const { id } = await data.addItem('url', { content: 'https://example.com' }); 203 await data.addItem('text', { content: 'A note' }); 204 await data.deleteItem(id); 205 206 const items = await data.queryItems(); 207 assert.strictEqual(items.length, 1); 208 }); 209 210 it('should include deleted items when requested', async () => { 211 const { id } = await data.addItem('url', { content: 'https://example.com' }); 212 await data.addItem('text', { content: 'A note' }); 213 await data.deleteItem(id); 214 215 const items = await data.queryItems({ includeDeleted: true }); 216 assert.strictEqual(items.length, 2); 217 }); 218 219 it('should store metadata as JSON string', async () => { 220 const { id } = await data.addItem('url', { 221 content: 'https://example.com', 222 metadata: JSON.stringify({ title: 'Example' }), 223 }); 224 const item = await data.getItem(id); 225 assert.strictEqual(item.metadata, '{"title":"Example"}'); 226 }); 227 228 it('should handle null content for tagsets', async () => { 229 const { id } = await data.addItem('tagset', {}); 230 const item = await data.getItem(id); 231 assert.strictEqual(item.content, null); 232 }); 233 234 it('should set sync fields on creation', async () => { 235 const { id } = await data.addItem('url', { 236 content: 'https://example.com', 237 syncId: 'server-123', 238 syncSource: 'server', 239 }); 240 const item = await data.getItem(id); 241 assert.strictEqual(item.syncId, 'server-123'); 242 assert.strictEqual(item.syncSource, 'server'); 243 }); 244}); 245 246// ==================== Data Engine: Tags ==================== 247 248describe('DataEngine: Tags', () => { 249 let adapter, data; 250 251 beforeEach(async () => { 252 ({ adapter, data } = createTestEngine()); 253 await adapter.open(); 254 }); 255 256 it('should create a new tag', async () => { 257 const { tag, created } = await data.getOrCreateTag('test'); 258 assert.ok(tag.id); 259 assert.strictEqual(tag.name, 'test'); 260 assert.strictEqual(tag.frequency, 1); 261 assert.strictEqual(created, true); 262 }); 263 264 it('should return existing tag and increment frequency', async () => { 265 const { tag: first } = await data.getOrCreateTag('test'); 266 const { tag: second, created } = await data.getOrCreateTag('test'); 267 assert.strictEqual(second.id, first.id); 268 assert.strictEqual(second.frequency, 2); 269 assert.strictEqual(created, false); 270 }); 271 272 it('should be case-insensitive for tag lookup', async () => { 273 await data.getOrCreateTag('Test'); 274 const { tag, created } = await data.getOrCreateTag('test'); 275 assert.strictEqual(created, false); 276 assert.strictEqual(tag.name, 'Test'); // preserves original casing 277 }); 278 279 it('should trim tag names', async () => { 280 const { tag } = await data.getOrCreateTag(' spaced '); 281 assert.strictEqual(tag.name, 'spaced'); 282 }); 283 284 it('should tag an item', async () => { 285 const { id } = await data.addItem('url', { content: 'https://example.com' }); 286 const { tag } = await data.getOrCreateTag('web'); 287 await data.tagItem(id, tag.id); 288 289 const tags = await data.getItemTags(id); 290 assert.strictEqual(tags.length, 1); 291 assert.strictEqual(tags[0].name, 'web'); 292 }); 293 294 it('should untag an item', async () => { 295 const { id } = await data.addItem('url', { content: 'https://example.com' }); 296 const { tag } = await data.getOrCreateTag('web'); 297 await data.tagItem(id, tag.id); 298 await data.untagItem(id, tag.id); 299 300 const tags = await data.getItemTags(id); 301 assert.strictEqual(tags.length, 0); 302 }); 303 304 it('should get tags sorted by frecency', async () => { 305 // Create tags with different frequencies 306 await data.getOrCreateTag('rare'); 307 await data.getOrCreateTag('common'); 308 await data.getOrCreateTag('common'); 309 await data.getOrCreateTag('common'); 310 311 const tags = await data.getTagsByFrecency(); 312 assert.strictEqual(tags[0].name, 'common'); 313 assert.strictEqual(tags[1].name, 'rare'); 314 assert.ok(tags[0].frecencyScore > tags[1].frecencyScore); 315 }); 316 317 it('should return empty array when no tags', async () => { 318 const tags = await data.getTagsByFrecency(); 319 assert.deepStrictEqual(tags, []); 320 }); 321 322 it('should track tag frequency through saveItem', async () => { 323 await data.saveItem('url', 'https://example1.com', ['common']); 324 await data.saveItem('url', 'https://example2.com', ['common']); 325 await data.saveItem('url', 'https://example3.com', ['common']); 326 await data.saveItem('url', 'https://example4.com', ['rare']); 327 328 const tags = await data.getTagsByFrecency(); 329 const common = tags.find(t => t.name === 'common'); 330 const rare = tags.find(t => t.name === 'rare'); 331 assert.strictEqual(common.frequency, 3); 332 assert.strictEqual(rare.frequency, 1); 333 }); 334}); 335 336// ==================== Data Engine: saveItem ==================== 337 338describe('DataEngine: saveItem', () => { 339 let adapter, data; 340 341 beforeEach(async () => { 342 ({ adapter, data } = createTestEngine()); 343 await adapter.open(); 344 }); 345 346 // --- URL saves --- 347 348 it('should save a URL without tags', async () => { 349 const { id } = await data.saveItem('url', 'https://example.com'); 350 assert.ok(id); 351 assert.match(id, /^[0-9a-f-]{36}$/); 352 }); 353 354 it('should save a URL with tags', async () => { 355 const { id } = await data.saveItem('url', 'https://example.com', [ 356 'test', 357 'demo', 358 ]); 359 const tags = await data.getItemTags(id); 360 assert.strictEqual(tags.length, 2); 361 const names = tags.map(t => t.name).sort(); 362 assert.deepStrictEqual(names, ['demo', 'test']); 363 }); 364 365 it('should create separate items for same URL content (no content dedup)', async () => { 366 const { id: id1 } = await data.saveItem('url', 'https://example.com', [ 367 'tag1', 368 ]); 369 const { id: id2 } = await data.saveItem('url', 'https://example.com', [ 370 'tag2', 371 ]); 372 assert.notStrictEqual(id1, id2, 'should create separate items without syncId'); 373 374 const items = await data.queryItems({ type: 'url' }); 375 assert.strictEqual(items.length, 2); 376 }); 377 378 it('should save multiple different URLs', async () => { 379 await data.saveItem('url', 'https://example1.com'); 380 await data.saveItem('url', 'https://example2.com'); 381 await data.saveItem('url', 'https://example3.com'); 382 const items = await data.queryItems({ type: 'url' }); 383 assert.strictEqual(items.length, 3); 384 }); 385 386 // --- Text saves --- 387 388 it('should save text with tags', async () => { 389 const { id } = await data.saveItem('text', 'My note', ['personal', 'todo']); 390 const item = await data.getItem(id); 391 assert.strictEqual(item.content, 'My note'); 392 const tags = await data.getItemTags(id); 393 assert.strictEqual(tags.length, 2); 394 }); 395 396 it('should create separate items for same text content (no content dedup)', async () => { 397 const { id: id1 } = await data.saveItem('text', 'Same content', ['tag1']); 398 const { id: id2 } = await data.saveItem('text', 'Same content', ['tag2']); 399 assert.notStrictEqual(id1, id2); 400 401 const items = await data.queryItems({ type: 'text' }); 402 assert.strictEqual(items.length, 2); 403 }); 404 405 // --- Tagset saves --- 406 407 it('should save a tagset', async () => { 408 const { id } = await data.saveItem('tagset', null, ['pushups', '10']); 409 assert.ok(id); 410 assert.match(id, /^[0-9a-f-]{36}$/); 411 }); 412 413 it('should create separate items for tagsets with same tags (no content dedup)', async () => { 414 const { id: id1 } = await data.saveItem('tagset', null, ['pushups', '10']); 415 const { id: id2 } = await data.saveItem('tagset', null, ['pushups', '10']); 416 assert.notStrictEqual(id1, id2); 417 418 const items = await data.queryItems({ type: 'tagset' }); 419 assert.strictEqual(items.length, 2); 420 }); 421 422 it('should not deduplicate tagsets with different tags', async () => { 423 const { id: id1 } = await data.saveItem('tagset', null, ['pushups', '10']); 424 const { id: id2 } = await data.saveItem('tagset', null, ['pushups', '20']); 425 assert.notStrictEqual(id1, id2); 426 }); 427 428 it('should retrieve tagset with its tags', async () => { 429 const { id } = await data.saveItem('tagset', null, [ 430 'exercise', 431 'pushups', 432 '20', 433 ]); 434 const tags = await data.getItemTags(id); 435 const names = tags.map(t => t.name).sort(); 436 assert.deepStrictEqual(names, ['20', 'exercise', 'pushups']); 437 }); 438 439 // --- Metadata --- 440 441 it('should save item with metadata', async () => { 442 const { id } = await data.saveItem( 443 'url', 444 'https://example.com', 445 [], 446 { title: 'Example' } 447 ); 448 const item = await data.getItem(id); 449 assert.strictEqual(item.metadata, '{"title":"Example"}'); 450 }); 451 452 // --- created flag --- 453 454 it('should report created=true for new items', async () => { 455 const { created } = await data.saveItem('url', 'https://example.com'); 456 assert.strictEqual(created, true); 457 }); 458 459 it('should report created=true for same content without syncId', async () => { 460 await data.saveItem('url', 'https://example.com'); 461 const { created } = await data.saveItem('url', 'https://example.com'); 462 assert.strictEqual(created, true); 463 }); 464}); 465 466// ==================== Data Engine: syncId Dedup ==================== 467 468describe('DataEngine: syncId Deduplication', () => { 469 let adapter, data; 470 471 beforeEach(async () => { 472 ({ adapter, data } = createTestEngine()); 473 await adapter.open(); 474 }); 475 476 it('should deduplicate by sync_id', async () => { 477 const syncId = 'client-item-abc123'; 478 const { id: id1 } = await data.saveItem( 479 'url', 'https://example.com', ['tag1'], null, syncId 480 ); 481 const { id: id2 } = await data.saveItem( 482 'url', 'https://example.com', ['tag2'], null, syncId 483 ); 484 485 assert.strictEqual(id1, id2, 'should return same id for same sync_id'); 486 487 const items = await data.queryItems(); 488 assert.strictEqual(items.length, 1); 489 490 const tags = await data.getItemTags(id1); 491 assert.strictEqual(tags.length, 1); 492 assert.strictEqual(tags[0].name, 'tag2'); 493 }); 494 495 it('should deduplicate by sync_id even with different content', async () => { 496 const syncId = 'client-item-xyz789'; 497 const { id: id1 } = await data.saveItem( 498 'url', 'https://old-url.com', [], null, syncId 499 ); 500 const { id: id2 } = await data.saveItem( 501 'url', 'https://new-url.com', [], null, syncId 502 ); 503 504 assert.strictEqual(id1, id2); 505 const items = await data.queryItems(); 506 assert.strictEqual(items.length, 1); 507 // Content should be updated 508 assert.strictEqual(items[0].content, 'https://new-url.com'); 509 }); 510 511 it('should create separate items when no sync_id (no content dedup)', async () => { 512 const { id: id1 } = await data.saveItem('url', 'https://example.com', ['tag1']); 513 const { id: id2 } = await data.saveItem('url', 'https://example.com', ['tag2']); 514 assert.notStrictEqual(id1, id2); 515 }); 516 517 it('should create new items for different sync_ids', async () => { 518 const { id: id1 } = await data.saveItem( 519 'url', 'https://first.com', [], null, 'sync-1' 520 ); 521 const { id: id2 } = await data.saveItem( 522 'url', 'https://second.com', [], null, 'sync-2' 523 ); 524 525 assert.notStrictEqual(id1, id2); 526 const items = await data.queryItems(); 527 assert.strictEqual(items.length, 2); 528 }); 529 530 it('should not use content dedup in sync path', async () => { 531 const { id: id1 } = await data.saveItem( 532 'url', 'https://example.com', [], null, 'device-a-id' 533 ); 534 const { id: id2 } = await data.saveItem( 535 'url', 'https://example.com', [], null, 'device-b-id' 536 ); 537 538 assert.notStrictEqual(id1, id2); 539 const items = await data.queryItems(); 540 assert.strictEqual(items.length, 2); 541 }); 542 543 it('should not match deleted items by sync_id', async () => { 544 const { id: id1 } = await data.saveItem( 545 'url', 'https://example.com', [], null, 'deleted-sync-id' 546 ); 547 // Server deleteItem does hard delete in test.js 548 await data.hardDeleteItem(id1); 549 550 const { id: id2 } = await data.saveItem( 551 'url', 'https://example.com', [], null, 'deleted-sync-id' 552 ); 553 assert.notStrictEqual(id1, id2); 554 }); 555 556 it('should match when device re-pushes with server ID as sync_id', async () => { 557 const { id: id1 } = await data.saveItem( 558 'url', 'https://shared.com', ['v1'], null, 'device-local-id' 559 ); 560 561 // Device re-pushes with the server-assigned ID (id1) 562 const { id: id2 } = await data.saveItem( 563 'url', 'https://shared.com/updated', ['v2'], null, id1 564 ); 565 566 assert.strictEqual(id1, id2); 567 const items = await data.queryItems(); 568 assert.strictEqual(items.length, 1); 569 assert.strictEqual(items[0].content, 'https://shared.com/updated'); 570 }); 571}); 572 573 574// ==================== Data Engine: Settings ==================== 575 576describe('DataEngine: Settings', () => { 577 let adapter, data; 578 579 beforeEach(async () => { 580 ({ adapter, data } = createTestEngine()); 581 await adapter.open(); 582 }); 583 584 it('should save and retrieve a setting', async () => { 585 await data.setSetting('test_key', 'test_value'); 586 const value = await data.getSetting('test_key'); 587 assert.strictEqual(value, 'test_value'); 588 }); 589 590 it('should return null for non-existent setting', async () => { 591 const value = await data.getSetting('non_existent'); 592 assert.strictEqual(value, null); 593 }); 594 595 it('should update existing setting', async () => { 596 await data.setSetting('key', 'value1'); 597 await data.setSetting('key', 'value2'); 598 const value = await data.getSetting('key'); 599 assert.strictEqual(value, 'value2'); 600 }); 601}); 602 603// ==================== Data Engine: Stats ==================== 604 605describe('DataEngine: Stats', () => { 606 let adapter, data; 607 608 beforeEach(async () => { 609 ({ adapter, data } = createTestEngine()); 610 await adapter.open(); 611 }); 612 613 it('should return correct stats', async () => { 614 await data.saveItem('url', 'https://example.com'); 615 await data.saveItem('text', 'A note'); 616 await data.saveItem('tagset', null, ['tag1', 'tag2']); 617 618 const stats = await data.getStats(); 619 assert.strictEqual(stats.totalItems, 3); 620 assert.strictEqual(stats.deletedItems, 0); 621 assert.strictEqual(stats.totalTags, 2); 622 assert.strictEqual(stats.itemsByType.url, 1); 623 assert.strictEqual(stats.itemsByType.text, 1); 624 assert.strictEqual(stats.itemsByType.tagset, 1); 625 assert.strictEqual(stats.itemsByType.image, 0); 626 }); 627 628 it('should count deleted items separately', async () => { 629 const { id } = await data.saveItem('url', 'https://example.com'); 630 await data.saveItem('text', 'A note'); 631 await data.deleteItem(id); 632 633 const stats = await data.getStats(); 634 assert.strictEqual(stats.totalItems, 1); 635 assert.strictEqual(stats.deletedItems, 1); 636 }); 637}); 638 639// ==================== Sync Engine: Pull ==================== 640 641describe('SyncEngine: Pull', () => { 642 it('should pull new items from server', async () => { 643 const serverItems = [ 644 { 645 id: 'server-1', 646 type: 'url', 647 content: 'https://from-server.com', 648 tags: ['imported'], 649 metadata: null, 650 createdAt: new Date(1000).toISOString(), 651 updatedAt: new Date(2000).toISOString(), 652 }, 653 ]; 654 const { adapter, data, sync } = createSyncTestEngine(serverItems); 655 await adapter.open(); 656 657 const result = await sync.pullFromServer(); 658 assert.strictEqual(result.pulled, 1); 659 assert.strictEqual(result.conflicts, 0); 660 661 const items = await data.queryItems(); 662 assert.strictEqual(items.length, 1); 663 assert.strictEqual(items[0].content, 'https://from-server.com'); 664 assert.strictEqual(items[0].syncId, 'server-1'); 665 assert.strictEqual(items[0].syncSource, 'server'); 666 667 const tags = await data.getItemTags(items[0].id); 668 assert.strictEqual(tags.length, 1); 669 assert.strictEqual(tags[0].name, 'imported'); 670 }); 671 672 it('should update local item when server is newer', async () => { 673 const serverItems = [ 674 { 675 id: 'server-1', 676 type: 'url', 677 content: 'https://updated.com', 678 tags: ['new-tag'], 679 metadata: null, 680 createdAt: new Date(1000).toISOString(), 681 updatedAt: new Date(Date.now() + 10000).toISOString(), // future = newer 682 }, 683 ]; 684 const { adapter, data, sync } = createSyncTestEngine(serverItems); 685 await adapter.open(); 686 687 // Insert local item with same syncId 688 await adapter.insertItem({ 689 id: 'local-1', 690 type: 'url', 691 content: 'https://old.com', 692 metadata: null, 693 syncId: 'server-1', 694 syncSource: 'server', 695 syncedAt: 1000, 696 createdAt: 1000, 697 updatedAt: 2000, 698 deletedAt: 0, 699 }); 700 701 const result = await sync.pullFromServer(); 702 assert.strictEqual(result.pulled, 1); 703 704 const item = await data.getItem('local-1'); 705 assert.strictEqual(item.content, 'https://updated.com'); 706 }); 707 708 it('should report conflict when local is newer', async () => { 709 const serverItems = [ 710 { 711 id: 'server-1', 712 type: 'url', 713 content: 'https://server-old.com', 714 tags: [], 715 metadata: null, 716 createdAt: new Date(1000).toISOString(), 717 updatedAt: new Date(1000).toISOString(), // very old 718 }, 719 ]; 720 const { adapter, data, sync } = createSyncTestEngine(serverItems); 721 await adapter.open(); 722 723 // Local item is newer 724 await adapter.insertItem({ 725 id: 'local-1', 726 type: 'url', 727 content: 'https://local-new.com', 728 metadata: null, 729 syncId: 'server-1', 730 syncSource: 'server', 731 syncedAt: 500, 732 createdAt: 500, 733 updatedAt: Date.now() + 5000, // much newer 734 deletedAt: 0, 735 }); 736 737 const result = await sync.pullFromServer(); 738 assert.strictEqual(result.conflicts, 1); 739 assert.strictEqual(result.pulled, 0); 740 741 // Local content should be unchanged 742 const item = await data.getItem('local-1'); 743 assert.strictEqual(item.content, 'https://local-new.com'); 744 }); 745 746 it('should return zeros when not configured', async () => { 747 const { adapter, sync } = createSyncTestEngine(); 748 await adapter.open(); 749 750 // Override config to remove server URL 751 sync.getConfig = () => ({ serverUrl: '', apiKey: '', lastSyncTime: 0 }); 752 753 const result = await sync.pullFromServer(); 754 assert.strictEqual(result.pulled, 0); 755 assert.strictEqual(result.conflicts, 0); 756 }); 757 758 it('should pull multiple items', async () => { 759 const serverItems = [ 760 { 761 id: 'server-1', type: 'url', content: 'https://first.com', 762 tags: [], metadata: null, 763 createdAt: new Date(1000).toISOString(), 764 updatedAt: new Date(2000).toISOString(), 765 }, 766 { 767 id: 'server-2', type: 'text', content: 'Server note', 768 tags: ['note'], metadata: null, 769 createdAt: new Date(1000).toISOString(), 770 updatedAt: new Date(2000).toISOString(), 771 }, 772 ]; 773 const { adapter, data, sync } = createSyncTestEngine(serverItems); 774 await adapter.open(); 775 776 const result = await sync.pullFromServer(); 777 assert.strictEqual(result.pulled, 2); 778 779 const items = await data.queryItems(); 780 assert.strictEqual(items.length, 2); 781 }); 782 783 it('should include includeDeleted=true in pull URL', async () => { 784 const { adapter, sync, fetchedUrls } = createSyncTestEngine(); 785 await adapter.open(); 786 787 await sync.pullFromServer(); 788 const pullUrl = fetchedUrls.find(u => u.includes('/items')); 789 assert.ok(pullUrl, 'should have fetched items URL'); 790 assert.ok(pullUrl.includes('includeDeleted=true'), 'pull URL should include includeDeleted=true'); 791 }); 792 793 it('should use correct query param separators with profile', async () => { 794 const { adapter, sync, fetchedUrls } = createSyncTestEngine(); 795 await adapter.open(); 796 797 await sync.pullFromServer(); 798 const pullUrl = fetchedUrls.find(u => u.includes('/items')); 799 assert.ok(pullUrl, 'should have fetched items URL'); 800 // Should have ?profile=...&includeDeleted=true (not ??profile or ?&profile) 801 assert.ok(!pullUrl.includes('??'), 'should not have double question marks'); 802 assert.ok(!pullUrl.includes('?&'), 'should not have ?& sequence'); 803 const qCount = (pullUrl.match(/\?/g) || []).length; 804 assert.strictEqual(qCount, 1, 'should have exactly one ? in query string'); 805 }); 806}); 807 808// ==================== Sync Engine: Push ==================== 809 810describe('SyncEngine: Push', () => { 811 it('should push unsynced items to server', async () => { 812 const { adapter, data, sync } = createSyncTestEngine(); 813 await adapter.open(); 814 815 await data.saveItem('url', 'https://local.com', ['local-tag']); 816 817 const result = await sync.pushToServer(); 818 assert.strictEqual(result.pushed, 1); 819 assert.strictEqual(result.failed, 0); 820 821 // Item should now have sync info 822 const items = await data.queryItems(); 823 assert.strictEqual(items[0].syncSource, 'server'); 824 assert.ok(items[0].syncedAt > 0); 825 }); 826 827 it('should not push server-sourced items', async () => { 828 const { adapter, data, sync } = createSyncTestEngine(); 829 await adapter.open(); 830 831 // Insert an item that came from server 832 await adapter.insertItem({ 833 id: 'from-server', 834 type: 'url', 835 content: 'https://server.com', 836 metadata: null, 837 syncId: 'server-id', 838 syncSource: 'server', 839 syncedAt: Date.now(), 840 createdAt: 1000, 841 updatedAt: 1000, 842 deletedAt: 0, 843 }); 844 845 const result = await sync.pushToServer(); 846 assert.strictEqual(result.pushed, 0); 847 }); 848 849 it('should return zeros when not configured', async () => { 850 const { adapter, sync } = createSyncTestEngine(); 851 await adapter.open(); 852 sync.getConfig = () => ({ serverUrl: '', apiKey: '', lastSyncTime: 0 }); 853 854 const result = await sync.pushToServer(); 855 assert.strictEqual(result.pushed, 0); 856 assert.strictEqual(result.failed, 0); 857 }); 858}); 859 860// ==================== Sync Engine: syncAll ==================== 861 862describe('SyncEngine: syncAll', () => { 863 it('should perform full pull + push cycle', async () => { 864 const serverItems = [ 865 { 866 id: 'server-1', type: 'url', content: 'https://from-server.com', 867 tags: [], metadata: null, 868 createdAt: new Date(1000).toISOString(), 869 updatedAt: new Date(2000).toISOString(), 870 }, 871 ]; 872 const { adapter, data, sync, getConfig } = createSyncTestEngine(serverItems); 873 await adapter.open(); 874 875 // Add a local item to push 876 await data.saveItem('text', 'Local note'); 877 878 const result = await sync.syncAll(); 879 assert.strictEqual(result.pulled, 1); 880 assert.strictEqual(result.pushed, 1); 881 assert.ok(result.lastSyncTime > 0); 882 883 // Config should be updated 884 assert.ok(getConfig().lastSyncTime > 0); 885 }); 886 887 it('should save sync server config after sync', async () => { 888 const { adapter, data, sync } = createSyncTestEngine(); 889 await adapter.open(); 890 891 await sync.syncAll(); 892 893 const storedUrl = await data.getSetting('sync_lastSyncServerUrl'); 894 assert.strictEqual(JSON.parse(storedUrl), 'http://test-server.local'); 895 }); 896 897 it('should return zeros when no server configured', async () => { 898 const { adapter, sync } = createSyncTestEngine(); 899 await adapter.open(); 900 sync.getConfig = () => ({ 901 serverUrl: '', 902 apiKey: '', 903 lastSyncTime: 0, 904 }); 905 906 const result = await sync.syncAll(); 907 assert.strictEqual(result.pulled, 0); 908 assert.strictEqual(result.pushed, 0); 909 assert.strictEqual(result.lastSyncTime, 0); 910 }); 911}); 912 913// ==================== Sync Engine: Status ==================== 914 915describe('SyncEngine: Status', () => { 916 it('should report sync status', async () => { 917 const { adapter, data, sync } = createSyncTestEngine(); 918 await adapter.open(); 919 920 await data.saveItem('url', 'https://local.com'); 921 922 const status = await sync.getSyncStatus(); 923 assert.strictEqual(status.configured, true); 924 assert.strictEqual(status.pendingCount, 1); 925 }); 926 927 it('should report unconfigured when no server URL', async () => { 928 const { adapter, sync } = createSyncTestEngine(); 929 await adapter.open(); 930 sync.getConfig = () => ({ 931 serverUrl: '', 932 apiKey: '', 933 lastSyncTime: 0, 934 }); 935 936 const status = await sync.getSyncStatus(); 937 assert.strictEqual(status.configured, false); 938 }); 939}); 940 941// ==================== Sync Engine: Delete Propagation ==================== 942 943describe('SyncEngine: Delete Propagation', () => { 944 it('should pull a deleted item (tombstone) and soft-delete locally', async () => { 945 const serverItems = [ 946 { 947 id: 'server-del-1', 948 type: 'url', 949 content: 'https://deleted-on-server.com', 950 tags: ['old'], 951 metadata: null, 952 createdAt: new Date(1000).toISOString(), 953 updatedAt: new Date(Date.now() + 10000).toISOString(), 954 deleted_at: Date.now() + 5000, 955 }, 956 ]; 957 const { adapter, data, sync } = createSyncTestEngine(serverItems); 958 await adapter.open(); 959 960 // Insert local item with matching syncId (not deleted locally) 961 await adapter.insertItem({ 962 id: 'local-del-1', 963 type: 'url', 964 content: 'https://deleted-on-server.com', 965 metadata: null, 966 syncId: 'server-del-1', 967 syncSource: 'server', 968 syncedAt: 1000, 969 createdAt: 1000, 970 updatedAt: 2000, 971 deletedAt: 0, 972 }); 973 974 const result = await sync.pullFromServer(); 975 assert.strictEqual(result.pulled, 1); 976 977 // Item should now be soft-deleted locally 978 const item = await data.getItem('local-del-1'); 979 assert.strictEqual(item, null, 'soft-deleted item should not appear in getItem'); 980 981 // But should still exist when querying with includeDeleted 982 const allItems = await data.queryItems({ includeDeleted: true }); 983 const deletedItem = allItems.find(i => i.id === 'local-del-1'); 984 assert.ok(deletedItem, 'deleted item should exist with includeDeleted'); 985 assert.ok(deletedItem.deletedAt > 0, 'deletedAt should be set'); 986 }); 987 988 it('should skip pulling a tombstone when no local item exists', async () => { 989 const serverItems = [ 990 { 991 id: 'server-del-orphan', 992 type: 'url', 993 content: 'https://never-existed-locally.com', 994 tags: [], 995 metadata: null, 996 createdAt: new Date(1000).toISOString(), 997 updatedAt: new Date(2000).toISOString(), 998 deleted_at: 3000, 999 }, 1000 ]; 1001 const { adapter, data, sync } = createSyncTestEngine(serverItems); 1002 await adapter.open(); 1003 1004 const result = await sync.pullFromServer(); 1005 // Should skip (no local item to delete) 1006 assert.strictEqual(result.pulled, 0); 1007 1008 const items = await data.queryItems({ includeDeleted: true }); 1009 assert.strictEqual(items.length, 0); 1010 }); 1011 1012 it('should undelete local item when server item is active and newer', async () => { 1013 const serverItems = [ 1014 { 1015 id: 'server-undelete-1', 1016 type: 'url', 1017 content: 'https://undeleted.com', 1018 tags: ['restored'], 1019 metadata: null, 1020 createdAt: new Date(1000).toISOString(), 1021 updatedAt: new Date(Date.now() + 10000).toISOString(), // future = newer 1022 }, 1023 ]; 1024 const { adapter, data, sync } = createSyncTestEngine(serverItems); 1025 await adapter.open(); 1026 1027 // Insert locally deleted item with matching syncId 1028 await adapter.insertItem({ 1029 id: 'local-undelete-1', 1030 type: 'url', 1031 content: 'https://undeleted.com', 1032 metadata: null, 1033 syncId: 'server-undelete-1', 1034 syncSource: 'server', 1035 syncedAt: 1000, 1036 createdAt: 1000, 1037 updatedAt: 2000, 1038 deletedAt: 3000, 1039 }); 1040 1041 const result = await sync.pullFromServer(); 1042 assert.strictEqual(result.pulled, 1); 1043 1044 // Item should be undeleted 1045 const item = await data.getItem('local-undelete-1'); 1046 assert.ok(item, 'item should be undeleted'); 1047 assert.strictEqual(item.deletedAt, 0); 1048 assert.strictEqual(item.content, 'https://undeleted.com'); 1049 }); 1050 1051 it('should report conflict when local delete is newer than server active item', async () => { 1052 const serverItems = [ 1053 { 1054 id: 'server-conflict-1', 1055 type: 'url', 1056 content: 'https://conflict.com', 1057 tags: [], 1058 metadata: null, 1059 createdAt: new Date(1000).toISOString(), 1060 updatedAt: new Date(1000).toISOString(), // very old 1061 }, 1062 ]; 1063 const { adapter, data, sync } = createSyncTestEngine(serverItems); 1064 await adapter.open(); 1065 1066 // Local item deleted recently (much newer) 1067 await adapter.insertItem({ 1068 id: 'local-conflict-1', 1069 type: 'url', 1070 content: 'https://conflict.com', 1071 metadata: null, 1072 syncId: 'server-conflict-1', 1073 syncSource: 'server', 1074 syncedAt: 500, 1075 createdAt: 500, 1076 updatedAt: Date.now() + 5000, // much newer 1077 deletedAt: Date.now() + 5000, 1078 }); 1079 1080 const result = await sync.pullFromServer(); 1081 assert.strictEqual(result.conflicts, 1); 1082 assert.strictEqual(result.pulled, 0); 1083 1084 // Local item should still be deleted (local is newer) 1085 const items = await data.queryItems({ includeDeleted: true }); 1086 const item = items.find(i => i.id === 'local-conflict-1'); 1087 assert.ok(item.deletedAt > 0, 'local delete should be preserved'); 1088 }); 1089 1090 it('should push deleted items with syncId as tombstones', async () => { 1091 let pushedBodies = []; 1092 const adapter = createMemoryAdapter(); 1093 let syncConfig = { 1094 serverUrl: 'http://test-server.local', 1095 apiKey: 'test-api-key', 1096 serverProfileId: 'test-profile', 1097 lastSyncTime: 0, 1098 }; 1099 1100 const mockFetch = async (url, options) => { 1101 const method = options?.method || 'GET'; 1102 if (method === 'GET') { 1103 return { 1104 ok: true, status: 200, 1105 headers: new Map([ 1106 ['X-Peek-Datastore-Version', String(DATASTORE_VERSION)], 1107 ['X-Peek-Protocol-Version', String(PROTOCOL_VERSION)], 1108 ]), 1109 text: async () => '', 1110 json: async () => ({ items: [] }), 1111 }; 1112 } 1113 if (method === 'POST') { 1114 const body = JSON.parse(options.body); 1115 pushedBodies.push(body); 1116 return { 1117 ok: true, status: 200, 1118 headers: new Map([ 1119 ['X-Peek-Datastore-Version', String(DATASTORE_VERSION)], 1120 ['X-Peek-Protocol-Version', String(PROTOCOL_VERSION)], 1121 ]), 1122 text: async () => '', 1123 json: async () => ({ id: `server-${body.sync_id}`, created: true }), 1124 }; 1125 } 1126 return { ok: false, status: 404, text: async () => 'Not found' }; 1127 }; 1128 mockFetch._patchHeaders = true; 1129 1130 const { data, sync } = createEngine(adapter, { 1131 getConfig: () => syncConfig, 1132 setConfig: (updates) => { syncConfig = { ...syncConfig, ...updates }; }, 1133 fetch: mockFetch, 1134 }); 1135 1136 await adapter.open(); 1137 1138 // Insert a previously-synced item that is now deleted 1139 await adapter.insertItem({ 1140 id: 'deleted-local-1', 1141 type: 'url', 1142 content: 'https://was-deleted.com', 1143 metadata: null, 1144 syncId: 'server-id-123', 1145 syncSource: 'server', 1146 syncedAt: 1000, 1147 createdAt: 1000, 1148 updatedAt: 2000, 1149 deletedAt: 2000, 1150 }); 1151 1152 const result = await sync.pushToServer(); 1153 assert.strictEqual(result.pushed, 1); 1154 assert.strictEqual(pushedBodies.length, 1); 1155 assert.ok(pushedBodies[0].deleted_at > 0, 'should include deleted_at in push body'); 1156 }); 1157 1158 it('should not push deleted items without syncId (never synced)', async () => { 1159 const { adapter, data, sync } = createSyncTestEngine(); 1160 await adapter.open(); 1161 1162 // Insert a deleted item that was never synced (no syncId) 1163 await adapter.insertItem({ 1164 id: 'never-synced-del', 1165 type: 'url', 1166 content: 'https://never-synced.com', 1167 metadata: null, 1168 syncId: '', 1169 syncSource: '', 1170 syncedAt: 0, 1171 createdAt: 1000, 1172 updatedAt: 2000, 1173 deletedAt: 2000, 1174 }); 1175 1176 const result = await sync.pushToServer(); 1177 assert.strictEqual(result.pushed, 0); 1178 }); 1179 1180 it('should include deleted tombstones in pending count', async () => { 1181 const { adapter, data, sync } = createSyncTestEngine(); 1182 await adapter.open(); 1183 1184 // Add a regular unsynced item 1185 await data.saveItem('url', 'https://local.com'); 1186 1187 // Add a deleted item with syncId (pending tombstone) 1188 await adapter.insertItem({ 1189 id: 'pending-tombstone', 1190 type: 'url', 1191 content: 'https://pending-delete.com', 1192 metadata: null, 1193 syncId: 'server-xyz', 1194 syncSource: 'server', 1195 syncedAt: 1000, 1196 createdAt: 1000, 1197 updatedAt: 2000, 1198 deletedAt: 2000, 1199 }); 1200 1201 const status = await sync.getSyncStatus(); 1202 assert.strictEqual(status.pendingCount, 2, 'should count both regular and tombstone items'); 1203 }); 1204}); 1205 1206// ==================== Sync Engine: Server Change Detection ==================== 1207 1208describe('SyncEngine: Server Change Detection', () => { 1209 it('should reset sync state when server URL changes', async () => { 1210 const { adapter, data, sync } = createSyncTestEngine(); 1211 await adapter.open(); 1212 1213 // Save server config from previous sync 1214 await data.setSetting('sync_lastSyncServerUrl', JSON.stringify('http://old-server.local')); 1215 await data.setSetting('sync_lastSyncProfileId', JSON.stringify('test-profile')); 1216 1217 // Add a server-sourced item 1218 await adapter.insertItem({ 1219 id: 'synced-item', 1220 type: 'url', 1221 content: 'https://synced.com', 1222 metadata: null, 1223 syncId: 'remote-id', 1224 syncSource: 'server', 1225 syncedAt: 1000, 1226 createdAt: 1000, 1227 updatedAt: 1000, 1228 deletedAt: 0, 1229 }); 1230 1231 // Server URL changed (current config says test-server.local, stored says old-server.local) 1232 const reset = await sync.resetSyncStateIfServerChanged('http://test-server.local'); 1233 assert.strictEqual(reset, true); 1234 1235 // Item sync markers should be cleared 1236 const item = await data.getItem('synced-item'); 1237 assert.strictEqual(item.syncSource, ''); 1238 assert.strictEqual(item.syncedAt, 0); 1239 assert.strictEqual(item.syncId, ''); 1240 }); 1241 1242 it('should not reset when server URL is the same', async () => { 1243 const { adapter, data, sync } = createSyncTestEngine(); 1244 await adapter.open(); 1245 1246 await data.setSetting('sync_lastSyncServerUrl', JSON.stringify('http://test-server.local')); 1247 await data.setSetting('sync_lastSyncProfileId', JSON.stringify('test-profile')); 1248 1249 const reset = await sync.resetSyncStateIfServerChanged('http://test-server.local'); 1250 assert.strictEqual(reset, false); 1251 }); 1252 1253 it('should not reset on first run with no stored config', async () => { 1254 const { adapter, data, sync } = createSyncTestEngine(); 1255 await adapter.open(); 1256 1257 // No stored server config, but items exist with syncSource='server' 1258 await adapter.insertItem({ 1259 id: 'orphan', 1260 type: 'url', 1261 content: 'https://orphan.com', 1262 metadata: null, 1263 syncId: 'old-server-id', 1264 syncSource: 'server', 1265 syncedAt: 1000, 1266 createdAt: 1000, 1267 updatedAt: 1000, 1268 deletedAt: 0, 1269 }); 1270 1271 // First run — no stored config means we haven't tracked the server yet. 1272 // Don't reset items that may have been pulled in a prior pull-only sync. 1273 const reset = await sync.resetSyncStateIfServerChanged('http://test-server.local'); 1274 assert.strictEqual(reset, false); 1275 1276 const item = await data.getItem('orphan'); 1277 assert.strictEqual(item.syncSource, 'server'); 1278 }); 1279}); 1280 1281// ==================== Memory Adapter: Edge Cases ==================== 1282 1283describe('Memory Adapter', () => { 1284 it('should support open/close cycle', async () => { 1285 const adapter = createMemoryAdapter(); 1286 await adapter.open(); 1287 1288 await adapter.insertItem({ 1289 id: 'test', type: 'url', content: 'https://test.com', 1290 metadata: null, syncId: '', syncSource: '', syncedAt: 0, 1291 createdAt: 1000, updatedAt: 1000, deletedAt: 0, 1292 }); 1293 1294 assert.ok(await adapter.getItem('test')); 1295 1296 await adapter.close(); 1297 await adapter.open(); 1298 1299 // Data should be cleared after close+open 1300 assert.strictEqual(await adapter.getItem('test'), null); 1301 }); 1302 1303 it('should not duplicate item-tag links', async () => { 1304 const adapter = createMemoryAdapter(); 1305 await adapter.open(); 1306 1307 await adapter.tagItem('item-1', 'tag-1'); 1308 await adapter.tagItem('item-1', 'tag-1'); // duplicate 1309 1310 // Should have inserted a tag to check 1311 await adapter.insertTag({ 1312 id: 'tag-1', name: 'test', frequency: 1, lastUsed: 1000, 1313 frecencyScore: 10, createdAt: 1000, updatedAt: 1000, 1314 }); 1315 1316 const tags = await adapter.getItemTags('item-1'); 1317 assert.strictEqual(tags.length, 1); 1318 }); 1319 1320 it('should find item by sync_id field', async () => { 1321 const adapter = createMemoryAdapter(); 1322 await adapter.open(); 1323 1324 await adapter.insertItem({ 1325 id: 'local-id', type: 'url', content: 'https://test.com', 1326 metadata: null, syncId: 'remote-id', syncSource: 'server', syncedAt: 1000, 1327 createdAt: 1000, updatedAt: 1000, deletedAt: 0, 1328 }); 1329 1330 // Should find by syncId field 1331 const bySync = await adapter.findItemBySyncId('remote-id'); 1332 assert.ok(bySync); 1333 assert.strictEqual(bySync.id, 'local-id'); 1334 1335 // Should find by direct ID 1336 const byId = await adapter.findItemBySyncId('local-id'); 1337 assert.ok(byId); 1338 assert.strictEqual(byId.id, 'local-id'); 1339 }); 1340 1341 it('should find deleted items by sync_id (needed for tombstone matching)', async () => { 1342 const adapter = createMemoryAdapter(); 1343 await adapter.open(); 1344 1345 await adapter.insertItem({ 1346 id: 'del', type: 'url', content: 'https://deleted.com', 1347 metadata: null, syncId: 'del-sync', syncSource: '', syncedAt: 0, 1348 createdAt: 1000, updatedAt: 1000, deletedAt: 2000, 1349 }); 1350 1351 const result = await adapter.findItemBySyncId('del-sync'); 1352 assert.ok(result, 'should find deleted items for tombstone matching'); 1353 assert.strictEqual(result.id, 'del'); 1354 assert.strictEqual(result.deletedAt, 2000); 1355 }); 1356}); 1357 1358// ==================== Integration: Full Workflow ==================== 1359 1360describe('Integration: Full Workflow', () => { 1361 it('should handle save → tag → query → sync lifecycle', async () => { 1362 const serverItems = []; 1363 const { adapter, data, sync } = createSyncTestEngine(serverItems); 1364 await adapter.open(); 1365 1366 // Save items 1367 const { id: url1 } = await data.saveItem('url', 'https://example.com', ['web']); 1368 const { id: url2 } = await data.saveItem('url', 'https://other.com', ['web', 'dev']); 1369 const { id: ts1 } = await data.saveItem('tagset', null, ['pushups', '10']); 1370 1371 // Verify queries 1372 const allItems = await data.queryItems(); 1373 assert.strictEqual(allItems.length, 3); 1374 1375 const urls = await data.queryItems({ type: 'url' }); 1376 assert.strictEqual(urls.length, 2); 1377 1378 // Verify tags 1379 const tags = await data.getTagsByFrecency(); 1380 assert.ok(tags.length >= 2); 1381 // 'web' used twice should be highest 1382 assert.strictEqual(tags[0].name, 'web'); 1383 1384 // Stats 1385 const stats = await data.getStats(); 1386 assert.strictEqual(stats.totalItems, 3); 1387 assert.strictEqual(stats.itemsByType.url, 2); 1388 assert.strictEqual(stats.itemsByType.tagset, 1); 1389 1390 // Sync push 1391 const pushResult = await sync.pushToServer(); 1392 assert.strictEqual(pushResult.pushed, 3); 1393 1394 // Verify all items are now synced 1395 const status = await sync.getSyncStatus(); 1396 assert.strictEqual(status.pendingCount, 0); 1397 }); 1398}); 1399 1400// ==================== better-sqlite3 Adapter ==================== 1401 1402// Only run if better-sqlite3 is available (skip gracefully in environments without it) 1403let Database; 1404let betterSqliteWorks = false; 1405try { 1406 Database = (await import('better-sqlite3')).default; 1407 // Test that the native module actually loads (may fail if compiled for Electron) 1408 const testDb = new Database(':memory:'); 1409 testDb.close(); 1410 betterSqliteWorks = true; 1411} catch { 1412 Database = null; 1413} 1414 1415if (betterSqliteWorks) { 1416 const { createBetterSqliteAdapter } = await import('./adapters/better-sqlite3.js'); 1417 1418 describe('BetterSqlite3 Adapter', () => { 1419 let db, adapter; 1420 1421 beforeEach(() => { 1422 db = new Database(':memory:'); 1423 adapter = createBetterSqliteAdapter(db); 1424 }); 1425 1426 it('should open and create schema', async () => { 1427 await adapter.open(); 1428 const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all(); 1429 const tableNames = tables.map(t => t.name); 1430 assert.ok(tableNames.includes('items')); 1431 assert.ok(tableNames.includes('tags')); 1432 assert.ok(tableNames.includes('item_tags')); 1433 assert.ok(tableNames.includes('settings')); 1434 }); 1435 1436 it('should insert and get an item', async () => { 1437 await adapter.open(); 1438 const item = { 1439 id: 'test-1', type: 'url', content: 'https://example.com', 1440 metadata: null, syncId: '', syncSource: '', syncedAt: 0, 1441 createdAt: 1000, updatedAt: 1000, deletedAt: 0, 1442 }; 1443 await adapter.insertItem(item); 1444 const retrieved = await adapter.getItem('test-1'); 1445 assert.ok(retrieved); 1446 assert.strictEqual(retrieved.content, 'https://example.com'); 1447 }); 1448 1449 it('should not return soft-deleted items', async () => { 1450 await adapter.open(); 1451 await adapter.insertItem({ 1452 id: 'del-1', type: 'url', content: 'https://deleted.com', 1453 metadata: null, syncId: '', syncSource: '', syncedAt: 0, 1454 createdAt: 1000, updatedAt: 1000, deletedAt: 2000, 1455 }); 1456 const item = await adapter.getItem('del-1'); 1457 assert.strictEqual(item, null); 1458 }); 1459 1460 it('should update item fields', async () => { 1461 await adapter.open(); 1462 await adapter.insertItem({ 1463 id: 'upd-1', type: 'text', content: 'original', 1464 metadata: null, syncId: '', syncSource: '', syncedAt: 0, 1465 createdAt: 1000, updatedAt: 1000, deletedAt: 0, 1466 }); 1467 await adapter.updateItem('upd-1', { content: 'updated', updatedAt: 2000 }); 1468 const item = await adapter.getItem('upd-1'); 1469 assert.strictEqual(item.content, 'updated'); 1470 assert.strictEqual(item.updatedAt, 2000); 1471 }); 1472 1473 it('should soft-delete an item', async () => { 1474 await adapter.open(); 1475 await adapter.insertItem({ 1476 id: 'sd-1', type: 'url', content: 'https://test.com', 1477 metadata: null, syncId: '', syncSource: '', syncedAt: 0, 1478 createdAt: 1000, updatedAt: 1000, deletedAt: 0, 1479 }); 1480 await adapter.deleteItem('sd-1'); 1481 assert.strictEqual(await adapter.getItem('sd-1'), null); 1482 }); 1483 1484 it('should hard-delete an item and its tags', async () => { 1485 await adapter.open(); 1486 await adapter.insertItem({ 1487 id: 'hd-1', type: 'url', content: 'https://test.com', 1488 metadata: null, syncId: '', syncSource: '', syncedAt: 0, 1489 createdAt: 1000, updatedAt: 1000, deletedAt: 0, 1490 }); 1491 await adapter.insertTag({ 1492 id: 'tag-1', name: 'test', frequency: 1, lastUsed: 1000, 1493 frecencyScore: 10, createdAt: 1000, updatedAt: 1000, 1494 }); 1495 await adapter.tagItem('hd-1', 'tag-1'); 1496 await adapter.hardDeleteItem('hd-1'); 1497 1498 // Item gone 1499 const items = await adapter.getItems({ includeDeleted: true }); 1500 assert.strictEqual(items.length, 0); 1501 // Tag links gone 1502 const tags = await adapter.getItemTags('hd-1'); 1503 assert.strictEqual(tags.length, 0); 1504 }); 1505 1506 it('should manage tags', async () => { 1507 await adapter.open(); 1508 await adapter.insertTag({ 1509 id: 'tag-a', name: 'Alpha', frequency: 1, lastUsed: 1000, 1510 frecencyScore: 10, createdAt: 1000, updatedAt: 1000, 1511 }); 1512 const byName = await adapter.getTagByName('alpha'); 1513 assert.ok(byName); 1514 assert.strictEqual(byName.name, 'Alpha'); 1515 1516 await adapter.updateTag('tag-a', { frequency: 5, updatedAt: 2000 }); 1517 const updated = await adapter.getTag('tag-a'); 1518 assert.strictEqual(updated.frequency, 5); 1519 }); 1520 1521 it('should manage item-tag associations', async () => { 1522 await adapter.open(); 1523 await adapter.insertItem({ 1524 id: 'it-1', type: 'url', content: 'https://test.com', 1525 metadata: null, syncId: '', syncSource: '', syncedAt: 0, 1526 createdAt: 1000, updatedAt: 1000, deletedAt: 0, 1527 }); 1528 await adapter.insertTag({ 1529 id: 'tag-b', name: 'Beta', frequency: 1, lastUsed: 1000, 1530 frecencyScore: 10, createdAt: 1000, updatedAt: 1000, 1531 }); 1532 1533 await adapter.tagItem('it-1', 'tag-b'); 1534 let tags = await adapter.getItemTags('it-1'); 1535 assert.strictEqual(tags.length, 1); 1536 assert.strictEqual(tags[0].name, 'Beta'); 1537 1538 // Duplicate tagItem should be ignored 1539 await adapter.tagItem('it-1', 'tag-b'); 1540 tags = await adapter.getItemTags('it-1'); 1541 assert.strictEqual(tags.length, 1); 1542 1543 await adapter.untagItem('it-1', 'tag-b'); 1544 tags = await adapter.getItemTags('it-1'); 1545 assert.strictEqual(tags.length, 0); 1546 }); 1547 1548 it('should clear all tags for an item', async () => { 1549 await adapter.open(); 1550 await adapter.insertItem({ 1551 id: 'ct-1', type: 'url', content: 'https://test.com', 1552 metadata: null, syncId: '', syncSource: '', syncedAt: 0, 1553 createdAt: 1000, updatedAt: 1000, deletedAt: 0, 1554 }); 1555 await adapter.insertTag({ 1556 id: 'tag-c1', name: 'C1', frequency: 1, lastUsed: 1000, 1557 frecencyScore: 10, createdAt: 1000, updatedAt: 1000, 1558 }); 1559 await adapter.insertTag({ 1560 id: 'tag-c2', name: 'C2', frequency: 1, lastUsed: 1000, 1561 frecencyScore: 10, createdAt: 1000, updatedAt: 1000, 1562 }); 1563 await adapter.tagItem('ct-1', 'tag-c1'); 1564 await adapter.tagItem('ct-1', 'tag-c2'); 1565 await adapter.clearItemTags('ct-1'); 1566 const tags = await adapter.getItemTags('ct-1'); 1567 assert.strictEqual(tags.length, 0); 1568 }); 1569 1570 it('should manage settings', async () => { 1571 await adapter.open(); 1572 assert.strictEqual(await adapter.getSetting('missing'), null); 1573 await adapter.setSetting('key1', 'value1'); 1574 assert.strictEqual(await adapter.getSetting('key1'), 'value1'); 1575 await adapter.setSetting('key1', 'value2'); 1576 assert.strictEqual(await adapter.getSetting('key1'), 'value2'); 1577 }); 1578 1579 it('should find items by syncId', async () => { 1580 await adapter.open(); 1581 await adapter.insertItem({ 1582 id: 'local-1', type: 'url', content: 'https://test.com', 1583 metadata: null, syncId: 'remote-1', syncSource: 'server', syncedAt: 1000, 1584 createdAt: 1000, updatedAt: 1000, deletedAt: 0, 1585 }); 1586 1587 // By syncId field 1588 const bySync = await adapter.findItemBySyncId('remote-1'); 1589 assert.ok(bySync); 1590 assert.strictEqual(bySync.id, 'local-1'); 1591 1592 // By direct ID 1593 const byId = await adapter.findItemBySyncId('local-1'); 1594 assert.ok(byId); 1595 assert.strictEqual(byId.id, 'local-1'); 1596 1597 // Not found 1598 const missing = await adapter.findItemBySyncId('nonexistent'); 1599 assert.strictEqual(missing, null); 1600 }); 1601 1602 it('should find deleted items by syncId (needed for tombstone matching)', async () => { 1603 await adapter.open(); 1604 await adapter.insertItem({ 1605 id: 'del-sync', type: 'url', content: 'https://deleted.com', 1606 metadata: null, syncId: 'del-remote', syncSource: '', syncedAt: 0, 1607 createdAt: 1000, updatedAt: 1000, deletedAt: 2000, 1608 }); 1609 const bySyncId = await adapter.findItemBySyncId('del-remote'); 1610 assert.ok(bySyncId, 'should find deleted items by syncId field'); 1611 assert.strictEqual(bySyncId.id, 'del-sync'); 1612 assert.strictEqual(bySyncId.deletedAt, 2000); 1613 1614 const byId = await adapter.findItemBySyncId('del-sync'); 1615 assert.ok(byId, 'should find deleted items by direct ID'); 1616 assert.strictEqual(byId.id, 'del-sync'); 1617 }); 1618 1619 it('should filter items by type and since', async () => { 1620 await adapter.open(); 1621 await adapter.insertItem({ 1622 id: 'f-1', type: 'url', content: 'https://a.com', 1623 metadata: null, syncId: '', syncSource: '', syncedAt: 0, 1624 createdAt: 1000, updatedAt: 1000, deletedAt: 0, 1625 }); 1626 await adapter.insertItem({ 1627 id: 'f-2', type: 'text', content: 'note', 1628 metadata: null, syncId: '', syncSource: '', syncedAt: 0, 1629 createdAt: 2000, updatedAt: 2000, deletedAt: 0, 1630 }); 1631 await adapter.insertItem({ 1632 id: 'f-3', type: 'url', content: 'https://b.com', 1633 metadata: null, syncId: '', syncSource: '', syncedAt: 0, 1634 createdAt: 3000, updatedAt: 3000, deletedAt: 0, 1635 }); 1636 1637 const urls = await adapter.getItems({ type: 'url' }); 1638 assert.strictEqual(urls.length, 2); 1639 1640 const since = await adapter.getItems({ since: 1500 }); 1641 assert.strictEqual(since.length, 2); 1642 }); 1643 1644 it('should work with DataEngine for full workflow', async () => { 1645 await adapter.open(); 1646 const { createEngine } = await import('./index.js'); 1647 const { data } = createEngine(adapter); 1648 1649 const { id } = await data.saveItem('url', 'https://example.com', ['test']); 1650 assert.ok(id); 1651 1652 const item = await data.getItem(id); 1653 assert.strictEqual(item.content, 'https://example.com'); 1654 1655 const tags = await data.getItemTags(id); 1656 assert.strictEqual(tags.length, 1); 1657 assert.strictEqual(tags[0].name, 'test'); 1658 }); 1659 }); 1660}