Initial commit: LinkSyncServer and LinkSyncExtension projects with complete documentation, models, API endpoints, tests, and extension implementation
This commit is contained in:
313
Linkding Browser Extension/LinkdingSync/tests/simple-test.js
Normal file
313
Linkding Browser Extension/LinkdingSync/tests/simple-test.js
Normal 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()');
|
||||
Reference in New Issue
Block a user