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,245 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>LinkdingSync Automated Tests</title>
<style>
body { font-family: monospace; padding: 20px; background: #1e1e1e; color: #d4d4d4; }
h1 { color: #4ec9b0; }
.table { margin: 10px 0; }
.input { padding: 5px; background: #2d2d2d; border: 1px solid #3d3d3d; color: #d4d4d4; }
.btn { padding: 8px 16px; margin: 5px 5px 5px 0; background: #0e639c; border: 1px solid #1971c2; color: #fff; cursor: pointer; }
.btn:hover { background: #1177bb; }
.btn:disabled { background: #3d3d3d; border-color: #555; cursor: not-allowed; }
.log { background: #0d0d0d; padding: 10px; border-radius: 4px; min-height: 300px; max-height: 600px; overflow-y: auto; white-space: pre-wrap; font-size: 12px; margin: 10px 0; border: 1px solid #333; }
.pass { color: #50fa7b; }
.fail { color: #ff5555; }
.warn { color: #f1fa8c; }
.info { color: #8be9fd; }
.progress { height: 20px; background: #2d2d2d; border-radius: 3px; margin: 10px 0; overflow: hidden; }
.progress-bar { height: 100%; background: #50fa7b; width: 0%; transition: width 0.3s; }
.status { font-weight: bold; padding: 5px; border-radius: 3px; margin: 5px 0; }
.status.running { background: #2aa198; }
.status.done { background: #0e639c; }
.status.error { background: #ff5555; }
</style>
</head>
<body>
<h1>🔗 LinkdingSync Automated Tests</h1>
<div class="table">
<label>Server URL:</label> <input type="text" id="server" value="https://links.blabber1565.com" class="input">
</div>
<div class="table">
<label>Work API Key:</label> <input type="password" id="wkey" value="4108e3aff26fb82bf074f5d4dfa4757763520b06" class="input">
</div>
<div class="table">
<label>Personal API Key:</label> <input type="password" id="pkey" value="9b80accd3b9b4b91c2a7adc3dcf41621b025329a" class="input">
</div>
<div style="margin: 10px 0;">
<button id="run" class="btn">▶ Run All Tests</button>
<button id="cleanup" class="btn">🧹 Cleanup</button>
<button id="list" class="btn">📋 List Bookmarks</button>
<button id="reset" class="btn">🔄 Reset & Run</button>
</div>
<div class="progress">
<div id="progress-bar" class="progress-bar"></div>
</div>
<div id="status" class="status"></div>
<div id="log" class="log"></div>
<script>
(function() {
'use strict';
// CONFIG
var server = document.getElementById('server').value;
var wkey = document.getElementById('wkey').value;
var pkey = document.getElementById('pkey').value;
// UI
var log = document.getElementById('log');
var status = document.getElementById('status');
var bar = document.getElementById('progress-bar');
// Log messages
function logMsg(msg, cls) {
var div = document.createElement('div');
div.textContent = msg;
div.className = cls || '';
log.insertBefore(div, log.firstChild);
}
function updateProgress(percent) {
bar.style.width = percent + '%';
}
function setStatus(msg, cls) {
status.textContent = msg;
status.className = 'status ' + cls;
}
// API wrapper
function api(method, endpoint, data) {
var url = server + endpoint;
return fetch(url, {
method: method,
headers: { Authorization: 'Token ' + wkey, 'Content-Type': 'application/json' },
body: data ? JSON.stringify(data) : null
}).then(function(res) {
if (res.ok) return res.json();
if (res.status === 404) return { error: '404', status: res.status };
throw new Error(res.status + ': ' + res.statusText);
});
}
// TESTS
var results = [];
var testNum = 0;
var totalTests = 6;
function runTest(name, fn) {
return fn().then(function(r) {
var cls = r.pass === true ? 'pass' : (r.pass === false ? 'fail' : 'warn');
results.push(r);
logMsg('TEST ' + (testNum++ + 1) + ': ' + name + ' ' + (r.pass ? (cls === 'pass' ? '✓' : '⚠') : '✗') + ' ' + r.reason, cls);
return r;
}).catch(function(e) {
logMsg('TEST ' + (testNum++ + 1) + ': ' + name + ' ERROR: ' + e.message, 'fail');
results.push({ pass: false, reason: 'Error: ' + e.message });
return { pass: false, reason: 'Error: ' + e.message };
});
}
// RUN ALL TESTS
function runAll() {
log.innerHTML = '';
updateProgress(0);
logMsg('Starting tests...', 'info');
runTest('API Key Isolation', function() {
return api('POST', '/api/bookmarks/', { url: 'https://t1.example.com', title: 'W1', notes: JSON.stringify({ testId: true }) })
.then(function(b1) { return api('POST', '/api/bookmarks/', { url: 'https://t1.example.com', title: 'P1', notes: JSON.stringify({ testId: true }) }); })
.then(function(b2) {
return { pass: b1.id !== b2.id, reason: b1.id === b2.id ? 'API keys do NOT provide isolation' : 'API keys provide isolation' };
});
}).then(function() {
updateProgress(16.7);
return runTest('Cross-User Visibility', function() {
return api('GET', '/api/bookmarks/?limit=100').then(function(d) {
var myTests = (d.results || []).filter(function(b) { return b.testId || b.notes?.testId; });
return { pass: myTests.length <= 1, reason: myTests.length === 1 ? 'User isolation works' : 'Sees ' + myTests.length + ' test bookmarks' };
});
});
}).then(function() {
updateProgress(33.3);
return runTest('Conflict Resolution', function() {
return api('POST', '/api/bookmarks/', { url: 'https://t3.example.com', title: 'W', path: 'W', notes: JSON.stringify({ testId: true }) })
.then(function(b1) { return api('POST', '/api/bookmarks/', { url: 'https://t3.example.com', title: 'P', path: 'P', notes: JSON.stringify({ testId: true }) }); })
.then(function(b2) {
return { pass: b1.id !== b2.id, reason: b1.id === b2.id ? 'Server merges by URL' : 'Server creates separate bookmarks' };
});
});
}).then(function() {
updateProgress(50);
return runTest('Field Update Behavior', function() {
return api('POST', '/api/bookmarks/', { url: 'https://t4.example.com', title: 'Initial', notes: JSON.stringify({ testId: true }) })
.then(function(bm) {
return api('PUT', '/api/bookmarks/' + bm.id + '/', { title: 'Work Title', notes: JSON.stringify({ testId: true }) });
})
.then(function() {
return api('GET', '/api/bookmarks/' + bm.id + '/');
})
.then(function(f) {
return { pass: true, reason: 'Field updates work' };
});
});
}).then(function() {
updateProgress(66.7);
return runTest('Delete Behavior', function() {
return api('POST', '/api/bookmarks/', { url: 'https://t5.example.com', title: 'W', notes: JSON.stringify({ testId: true }) })
.then(function(b1) { return api('POST', '/api/bookmarks/', { url: 'https://t5.example.com', title: 'P', notes: JSON.stringify({ testId: true }) }); })
.then(function(b2) { return api('DELETE', '/api/bookmarks/' + b1.id + '/'); })
.then(function() { return api('GET', '/api/bookmarks/?limit=100&url=https://t5.example.com'); })
.then(function(d) {
return { pass: (d.results || []).length === 1, reason: (d.results || []).length === 1 ? 'Delete isolated' : 'Delete propagated' };
});
});
}).then(function() {
updateProgress(83.3);
return runTest('Bundle Filtering', function() {
return Promise.all([
api('POST', '/api/bookmarks/', { url: 'https://b6-1.example.com', title: 'B1', notes: JSON.stringify({ testId: true }) }),
api('POST', '/api/bookmarks/', { url: 'https://b6-2.example.com', title: 'B2', notes: JSON.stringify({ testId: true }) })
]).then(function() {
return api('GET', '/api/bookmarks/?all=work&limit=100').then(function(wd) {
return api('GET', '/api/bookmarks/?all=personal&limit=100').then(function(pd) {
return { pass: true, reason: 'Bundle filtering works', work: wd.count, personal: pd.count };
});
});
});
});
}).then(function() {
updateProgress(100);
return new Promise(function(resolve) {
var summary = '';
var passed = results.filter(function(r) { return r.pass === true; }).length;
var failed = results.filter(function(r) { return r.pass === false; }).length;
var warned = results.filter(function(r) { return r.pass === null; }).length;
summary += 'Total: ' + results.length + '\n';
summary += 'Passed: ' + passed + '\n';
summary += 'Failed: ' + failed + '\n';
summary += 'Warning: ' + warned;
logMsg(summary, 'info');
resolve();
});
}).then(function() {
setStatus('Tests complete', 'done');
logMsg('Results available in log above', 'info');
}).catch(function(e) {
logMsg('Error: ' + e.message, 'error');
});
}
// BUTTON HANDLERS
document.getElementById('run').addEventListener('click', runAll);
document.getElementById('reset').addEventListener('click', function() {
log.innerHTML = '';
runAll();
});
document.getElementById('cleanup').addEventListener('click', function() {
api('GET', '/api/bookmarks/?limit=100').then(function(d) {
var tests = (d.results || []).filter(function(b) { return b.testId || b.notes?.testId; });
logMsg('Cleaning up ' + tests.length + ' test bookmarks...', 'info');
if (tests.length) {
Promise.all(tests.map(function(t) { return api('DELETE', '/api/bookmarks/' + t.id + '/'); })).then(function() {
logMsg('Cleanup complete', 'info');
});
}
});
});
document.getElementById('list').addEventListener('click', function() {
api('GET', '/api/bookmarks/?limit=100').then(function(d) {
logMsg('All bookmarks: ' + (d.count || 0), 'info');
if (d.results) {
d.results.forEach(function(b) { logMsg(' ' + b.url + ' [' + b.title + ']'); });
}
});
});
// AUTO-RUN ON LOAD
window.addEventListener('load', function() {
logMsg('Test runner loaded. Click "Run All Tests" to start.', 'info');
logMsg('Default credentials will be used if you click Run without changing them.', 'info');
});
})();
</script>
</body>
</html>