Initial commit: LinkSyncServer and LinkSyncExtension projects with complete documentation, models, API endpoints, tests, and extension implementation

This commit is contained in:
DavidSaylor
2026-05-11 17:37:10 -05:00
parent ad0b12b452
commit aed69afdfd
691 changed files with 181874 additions and 28 deletions

View File

@@ -0,0 +1,313 @@
/*
* LinkdingSync Simple Test Runner
* Copy entire file into Firefox DevTools Console
* Then run: runAllTests()
*/
'use strict';
(function(exports) {
'use strict';
const serverUrl = 'https://links.blabber1565.com';
const workApiKey = '4108e3aff26fb82bf074f5d4dfa4757763520b06';
const personalApiKey = '9b80accd3b9b4b91c2a7adc3dcf41621b025329a';
const workUser = 'linkdingsync_tester';
const personalUser = 'linkdingsync_tester_2';
const workBundle = 'work';
const personalBundle = 'personal';
const currentContext = { url: '', apiKey: '', userId: null };
function setContext(key, url, apiKey, userId) {
currentContext.url = url.endsWith('/') ? url : url + '/';
currentContext.apiKey = apiKey;
currentContext.userId = userId;
}
function callApi(method, endpoint, params = {}) {
const url = new URL(endpoint, currentContext.url);
Object.entries(params).forEach(([k, v]) => url.searchParams.append(k, v));
const headers = { 'Authorization': `Token ${currentContext.apiKey}` };
return fetch(url, { method, headers }).then(r => {
if (!r.ok) throw new Error(r.status + ': ' + r.statusText);
return r.json();
});
}
function createBookmark(url, options = {}) {
const testId = 'test-' + Date.now().toString().slice(-4) + '-' + Math.random().toString(36).slice(2,4);
const baseUrl = new URL(url);
baseUrl.hostname = testId + '.' + baseUrl.hostname;
const data = {
url: baseUrl.href,
title: options.title || 'Test: ' + testId,
description: 'Test bookmark',
notes: JSON.stringify({ path: 'Test/' + testId, testId, userNotes: 'Test' })
};
return callApi('POST', '/api/bookmarks/', data).then(bm => {
console.log(' Created: ID=' + bm.id);
return bm;
});
}
function deleteBookmark(id) {
return callApi('DELETE', '/api/bookmarks/' + id + '/').then(() => {
console.log(' Deleted: ID=' + id);
});
}
function getAllBookmarks() {
let bookmarks = [];
let offset = 0;
return callApi('GET', '/api/bookmarks/?limit=100&offset=' + offset).then(data => {
bookmarks = bookmarks.concat(data.results || []);
if (bookmarks.length > offset) {
return getAllBookmarks().then(r => r);
}
return bookmarks;
});
}
function resetBookmarks() {
console.log('[Reset] Clearing test bookmarks...');
return getAllBookmarks().then(all => {
const tests = all.filter(b => b.testId);
if (tests.length > 0) {
console.log('[Reset] Found ' + tests.length + ' test bookmarks');
return Promise.all(tests.map(t => deleteBookmark(t.id))).then(() => {
console.log('[Reset] Done');
});
}
console.log('[Reset] No test bookmarks found');
});
}
// ==================== TEST 1 ====================
function test1_SameUrlDifferentKeys() {
console.log('\n=== Test 1: Same URL, Different API Keys ===');
setContext('work', serverUrl, workApiKey, workUser);
const bm1 = createBookmark('https://test1.example.com', { title: 'Test 1 - Work' });
bm1.then(function() {
setContext('personal', serverUrl, personalApiKey, personalUser);
const bm2 = createBookmark('https://test1.example.com', { title: 'Test 1 - Personal' });
return bm2;
}).then(function(bm2) {
console.log(' Work ID: ' + bm1.id);
console.log(' Personal ID: ' + bm2.id);
if (bm1.id === bm2.id) {
console.log(' [Test 1] ✗ Same ID - API keys do NOT provide isolation');
return { pass: false, same: true };
} else {
console.log(' [Test 1] ✓ Different IDs - API keys provide isolation');
return { pass: true, same: false };
}
});
}
// ==================== TEST 2 ====================
function test2_CrossUserVisibility() {
return test1_SameUrlDifferentKeys().then(function(r1) {
console.log('\n=== Test 2: Cross-User Visibility ===');
setContext('work', serverUrl, workApiKey, workUser);
const testUrl = 'https://test2.example.com';
const bm = createBookmark(testUrl, { title: 'Test 2 - Work' });
return bm.then(function(bm) {
console.log(' Work bookmark ID: ' + bm.id);
setContext('personal', serverUrl, personalApiKey, personalUser);
return callApi('GET', '/api/bookmarks/?limit=100').then(function(data) {
console.log(' Personal sees: ' + data.count + ' bookmarks');
if (data.results && data.results.length > 0) {
console.log(' [Test 2] ✗ Users can see each other\'s bookmarks');
return { pass: false, visible: true };
} else {
console.log(' [Test 2] ✓ Proper user isolation');
return { pass: true, visible: false };
}
});
});
});
}
// ==================== TEST 3 ====================
function test3_ConflictResolution() {
return test2_CrossUserVisibility().then(function(r2) {
console.log('\n=== Test 3: Conflict Resolution ===');
setContext('work', serverUrl, workApiKey, workUser);
const url = 'https://test3.example.com';
return createBookmark(url, { title: 'Test 3', path: 'Work/Path' }).then(function(bm1) {
console.log(' Work bookmark ID: ' + bm1.id);
setContext('personal', serverUrl, personalApiKey, personalUser);
return createBookmark(url, { title: 'Test 3', path: 'Personal/Path' }).then(function(bm2) {
console.log(' Personal bookmark ID: ' + bm2.id);
console.log(' Same ID? ' + (bm1.id === bm2.id));
if (bm1.id === bm2.id) {
console.log(' [Test 3] ✗ Same bookmark - server merges by URL');
return callApi('GET', '/api/bookmarks/' + bm1.id + '/').then(function(data) {
return { pass: false, merged: true, path: JSON.parse(data.notes).path };
});
} else {
console.log(' [Test 3] ✓ Different bookmarks - server does NOT merge');
return { pass: true, merged: false };
}
});
});
});
}
// ==================== TEST 4 ====================
function test4_LastWriteWins() {
return test3_ConflictResolution().then(function(r3) {
console.log('\n=== Test 4: Last-Write-Wins ===');
setContext('work', serverUrl, workApiKey, workUser);
const url = 'https://test4.example.com';
return createBookmark(url, { title: 'Initial', path: 'Initial' }).then(function(bm) {
console.log(' Initial bookmark ID: ' + bm.id);
return callApi('PUT', '/api/bookmarks/' + bm.id + '/', {
title: 'Work Title',
description: 'Work Desc',
notes: JSON.stringify({ path: 'Work/Dev', userNotes: 'Work notes' })
}).then(function() {
console.log(' Updated via Work: Work Title');
setContext('personal', serverUrl, personalApiKey, personalUser);
return callApi('PUT', '/api/bookmarks/' + bm.id + '/', {
title: 'Personal Title',
description: 'Personal Desc',
notes: JSON.stringify({ path: 'Personal/Notes', userNotes: 'Personal notes' })
}).then(function() {
console.log(' Updated via Personal: Personal Title');
return callApi('GET', '/api/bookmarks/' + bm.id + '/');
});
});
}).then(function(final) {
console.log('\n Final state:');
console.log(' Title: ' + final.title);
console.log(' Description: ' + final.description);
console.log(' Path: ' + JSON.parse(final.notes).path);
if (final.title === 'Personal Title') {
console.log(' [Test 4] ✓ Last-write-wins (Personal)');
return { pass: true, strategy: 'last-write-wins', winner: 'personal' };
} else if (final.title === 'Work Title') {
console.log(' [Test 4] ✓ Last-write-wins (Work)');
return { pass: true, strategy: 'last-write-wins', winner: 'work' };
} else {
console.log(' [Test 4] ⚠ Unexpected title: ' + final.title);
return { pass: null, strategy: 'unknown' };
}
});
});
}
// ==================== TEST 5 ====================
function test5_DeletePropagation() {
return test4_LastWriteWins().then(function(r4) {
console.log('\n=== Test 5: Delete Propagation ===');
setContext('work', serverUrl, workApiKey, workUser);
const url = 'https://test5.example.com';
return createBookmark(url, { title: 'Test 5 - Work', path: 'Work' }).then(function(bm1) {
console.log(' Work bookmark ID: ' + bm1.id);
setContext('personal', serverUrl, personalApiKey, personalUser);
return createBookmark(url, { title: 'Test 5 - Personal', path: 'Personal' }).then(function(bm2) {
console.log(' Personal bookmark ID: ' + bm2.id);
console.log(' Same ID? ' + (bm1.id === bm2.id));
setContext('work', serverUrl, workApiKey, workUser);
return callApi('DELETE', '/api/bookmarks/' + bm1.id + '/').then(function() {
console.log(' Deleted via Work');
setContext('personal', serverUrl, personalApiKey, personalUser);
return callApi('GET', '/api/bookmarks/?limit=100&url=' + url).then(function(data) {
if (data.count === 0) {
console.log(' [Test 5] ✗ Delete propagated - same bookmark');
return { pass: false, propagated: true };
} else {
console.log(' [Test 5] ✓ Delete did not propagate - separate bookmarks');
return { pass: true, propagated: false };
}
});
});
});
});
});
}
// ==================== TEST 6 ====================
function test6_BundleFiltering() {
return test5_DeletePropagation().then(function(r5) {
console.log('\n=== Test 6: Bundle Filtering ===');
setContext('work', serverUrl, workApiKey, workUser);
const url1 = 'https://bundle-work1.example.com';
const url2 = 'https://bundle-work2.example.com';
return Promise.all([
createBookmark(url1, { title: 'Bundle Work 1', path: 'Work/B1' }),
createBookmark(url2, { title: 'Bundle Work 2', path: 'Work/B2' })
]).then(function(bms) {
console.log(' Created 2 work bookmarks');
setContext('work', serverUrl, workApiKey, workUser);
return callApi('GET', '/api/bookmarks/?all=work&limit=100').then(function(data) {
const workCount = data.count || data.results?.length || 0;
setContext('personal', serverUrl, personalApiKey, personalUser);
return callApi('GET', '/api/bookmarks/?all=personal&limit=100').then(function(pd) {
const personalCount = pd.count || pd.results?.length || 0;
console.log(' Work bundle: ' + workCount + ' bookmarks');
console.log(' Personal bundle: ' + personalCount + ' bookmarks');
console.log(' [Test 6] ✓ Bundle filtering works');
return { pass: true, work: workCount, personal: personalCount };
});
});
});
});
}
// ==================== MAIN ====================
async function runAllTests() {
console.log(''.padEnd(60, '='));
console.log(' LINKDINGSYNC - Test Suite');
console.log('='.repeat(60));
const results = [];
try {
results.push(await test1_SameUrlDifferentKeys());
results.push(await test2_CrossUserVisibility());
results.push(await test3_ConflictResolution());
results.push(await test4_LastWriteWins());
results.push(await test5_DeletePropagation());
results.push(await test6_BundleFiltering());
} catch (error) {
console.error('Error:', error.message);
}
const passed = results.filter(r => r.pass === true).length;
const failed = results.filter(r => r.pass === false).length;
const warnings = results.filter(r => r.pass === null).length;
console.log('\n'.padEnd(60, '='));
console.log(' Summary: Total=' + results.length + ', Passed=' + passed + ', Failed=' + failed + ', Warning=' + warnings);
console.log('='.repeat(60));
return results;
}
async function runAllTestsWithReset() {
console.log(''.padEnd(60, '='));
console.log(' LinkdingSync - Test Suite with Reset');
console.log('='.repeat(60));
console.log('[Reset] Cleaning up...');
await resetBookmarks();
console.log('[Reset] Done');
return await runAllTests();
}
exports.runAllTests = runAllTests;
exports.runAllTestsWithReset = runAllTestsWithReset;
exports.reset = resetBookmarks;
exports.Helpers = {
createBookmark: createBookmark,
deleteBookmark: deleteBookmark,
getAllBookmarks: getAllBookmarks
};
})();
console.log('');
console.log('LinkdingSync Simple Test Runner loaded');
console.log('');
console.log('Run: runAllTestsWithReset()');