Initial commit: LinkSyncServer and LinkSyncExtension projects with complete documentation, models, API endpoints, tests, and extension implementation
This commit is contained in:
9
Linkding Browser Extension/LinkdingSync/.creds.txt
Normal file
9
Linkding Browser Extension/LinkdingSync/.creds.txt
Normal file
@@ -0,0 +1,9 @@
|
||||
username: linkdingsync_tester
|
||||
password: 28jqny4znUF8
|
||||
API Key 1 (linkdingsync-test-api): 4108e3aff26fb82bf074f5d4dfa4757763520b06
|
||||
API Key 2 (linkdingsync-test-api-2): d09dd698745e929a50a84187d8ddf8cbafb244a9
|
||||
|
||||
username: linkdingsync_tester_2
|
||||
password: y745yzkZLP65
|
||||
API Key 1 (linkdingsync-test-2-api-1): 9b80accd3b9b4b91c2a7adc3dcf41621b025329a
|
||||
API Key 2 (linkdingsync-test-2-api-2): dce9f848ecbf744eac0ab753b9d9e6e29dde396a
|
||||
70
Linkding Browser Extension/LinkdingSync/AGENTS.md
Normal file
70
Linkding Browser Extension/LinkdingSync/AGENTS.md
Normal file
@@ -0,0 +1,70 @@
|
||||
# AGENTS.md - Project Guidance for Coding Agents
|
||||
|
||||
## Project Overview
|
||||
LinkdingSync is a Firefox browser extension that synchronizes bookmarks with a self-hosted Linkding instance. It provides bi-directional sync, folder structure preservation, and optional auto-tag generation.
|
||||
|
||||
## Setup & Build Commands
|
||||
```bash
|
||||
# Install project dependencies
|
||||
npm install
|
||||
|
||||
# Start development server
|
||||
npm run dev
|
||||
|
||||
# Run unit tests
|
||||
npm test
|
||||
|
||||
# Run linting
|
||||
npm run lint
|
||||
|
||||
# Build production bundle
|
||||
npm run build
|
||||
```
|
||||
|
||||
## Architecture Notes
|
||||
- **Extension Manifest**: `manifest.json` - Firefox extension configuration
|
||||
- **Popup UI**: `popup.html` + `popup.css` + `popup.js` - User interface
|
||||
- **Background Service**: `background.js` - Service worker for sync logic
|
||||
- **Utils**: `utils/` folder - Bookmark manipulation, sync logic, conflict resolution
|
||||
- **API Integration**: Uses Linkding REST API for bookmark operations
|
||||
|
||||
## Testing
|
||||
- **Unit tests**: `npm test` - Test individual utility functions
|
||||
- **E2E tests**: `npx playwright test` - Browser automation tests (if Playwright configured)
|
||||
- **Coverage target**: 80%
|
||||
- **Browser tests**: Use Playwright to simulate user interactions
|
||||
|
||||
## Conventions
|
||||
- **File naming**: PascalCase for components, kebab-case for CSS classes
|
||||
- **Error handling**: Try/catch with async/await, error boundaries
|
||||
- **API patterns**: Restful endpoints with token-based authentication
|
||||
- **Do not modify**: `manifest.json` requires Firefox extension signing (unless in development mode)
|
||||
- **Secrets**: API tokens stored in browser storage (client-side only)
|
||||
|
||||
## Session Logging
|
||||
- **Auto-saved to**: `@LinkdingSync\chatlog.md`
|
||||
- **Review via**: File explorer (not IDE working set)
|
||||
- **Archived to**: `../docs\session-YYYYMMDD.md`
|
||||
|
||||
## Known Issues
|
||||
- Firefox extension requires signing for distribution (not in dev mode)
|
||||
- API token storage is client-side only (user must protect token)
|
||||
- Browser compatibility limited to Firefox (extension format)
|
||||
|
||||
## Project Tools
|
||||
- **OpenCode**: Main agent for iterative test development
|
||||
- **Playwright**: For browser automation and E2E testing
|
||||
- **API Endpoint**: `https://api.links.blabber1565.com` (example - adjust as needed)
|
||||
|
||||
## Notes Structure Example
|
||||
Bookmarks in Linkding use this notes format:
|
||||
```json
|
||||
{
|
||||
"path": "Work/Resources/Development",
|
||||
"userNotes": "Development resources folder",
|
||||
"autoTags": [
|
||||
{"name": "Work"},
|
||||
{"name": "Resources"},
|
||||
{"name": "Development"}
|
||||
]
|
||||
}
|
||||
@@ -302,4 +302,112 @@ This document should be referenced during implementation to ensure all requireme
|
||||
|
||||
---
|
||||
|
||||
**Last Updated:** 2026-05-06
|
||||
**Last Updated:** 2026-05-06
|
||||
|
||||
# LinkdingSync - Version Compatibility Notes
|
||||
|
||||
## Version 1.0.x - Basic Structured Notes
|
||||
|
||||
### Notes Structure
|
||||
```json
|
||||
{
|
||||
"version": "1.0",
|
||||
"path": "",
|
||||
"userNotes": "",
|
||||
"autoTags": [],
|
||||
"bundleTag": "bundle_links_blabber1565_firefox_42",
|
||||
"keyword": ""
|
||||
}
|
||||
```
|
||||
|
||||
### Auto-Generate Tags Feature
|
||||
|
||||
When `autoGenerateTags` is **enabled** in settings:
|
||||
- Tags are automatically derived from the `path` folder structure
|
||||
- Example: `path = "Work/Resources/Development"` → `autoTags = ["Work", "Resources", "Development"]`
|
||||
- These tags are added to the bookmark on Linkding
|
||||
- **Important**: Tags are only auto-generated if this setting is enabled
|
||||
- Tags will always be present in the structured notes field
|
||||
|
||||
When `autoGenerateTags` is **disabled** (default):
|
||||
- Tags array remains empty (`[]`)
|
||||
- Only `bundleTag` is added to the bookmark
|
||||
- Old bookmarks without structured notes are migrated without tags
|
||||
|
||||
### Bundle Tag Behavior
|
||||
|
||||
**Always Applied:**
|
||||
- The `bundleTag` (e.g., `bundle_links_blabber1565_firefox_42`) is **always** added to new bookmarks
|
||||
- This enables Linkding bundle filtering via the `all=` API parameter
|
||||
- When viewing bookmarks in Linkding UI with this tag filter, you'll see only bookmarks from this browser
|
||||
- Bundle feature works as expected: queries by tag filter, UI shows filtered results
|
||||
|
||||
**Not Applied:**
|
||||
- `path` field is used for folder organization
|
||||
- `userNotes` stores user-provided notes
|
||||
- `autoTags` is populated only when `autoGenerateTags` is enabled
|
||||
- `keyword` field is used for Firefox-style quick-access bookmarks
|
||||
|
||||
### Migrations
|
||||
|
||||
**Old Non-JSON Notes:**
|
||||
```
|
||||
"old text notes"
|
||||
```
|
||||
→ Migrated to:
|
||||
```json
|
||||
{
|
||||
"version": "1.0",
|
||||
"path": "",
|
||||
"userNotes": "old text notes",
|
||||
"autoTags": [],
|
||||
"bundleTag": "bundle_...",
|
||||
"keyword": ""
|
||||
}
|
||||
```
|
||||
|
||||
**Old Structured Notes (no version field):**
|
||||
```json
|
||||
{
|
||||
"path": "",
|
||||
"userNotes": "notes",
|
||||
"autoTags": []
|
||||
}
|
||||
```
|
||||
→ Migrated to:
|
||||
```json
|
||||
{
|
||||
"version": "1.0",
|
||||
"path": "",
|
||||
"userNotes": "notes",
|
||||
"autoTags": [],
|
||||
"bundleTag": "bundle_...",
|
||||
"keyword": ""
|
||||
}
|
||||
```
|
||||
|
||||
## Version 2.x - Future Features
|
||||
|
||||
Version 2.x will add new fields while maintaining backward compatibility. Older extension versions will continue to work but won't use new fields.
|
||||
|
||||
---
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
### Current State (v1.0.x)
|
||||
- `bundleTag` is **always** added to bookmarks (enables bundle filtering)
|
||||
- `autoTags` is only populated when `autoGenerateTags` setting is enabled
|
||||
- Old bookmarks get migrated with empty `autoTags` array
|
||||
- Firefox tags (`autoTags`) are stored in Linkding bookmarks when present in Firefox
|
||||
|
||||
### Bundle Feature
|
||||
- Linkding bundle is created with `all_tags` set to the `bundleTag`
|
||||
- All bookmarks with this tag appear in the bundle
|
||||
- Bundle filtering uses `all=` API parameter (exact match on all tags)
|
||||
- Querying works correctly with pagination for large bookmark sets
|
||||
|
||||
### Cross-Browser Sync
|
||||
- All browsers share the same Linkding collection via tag filtering
|
||||
- Firefox bookmarks with tags sync to Linkding
|
||||
- Other browsers read from Linkding with same tag filter
|
||||
- Keywords are preserved in structured notes but not synced (Linkding doesn't support keywords)
|
||||
|
||||
308
Linkding Browser Extension/LinkdingSync/docs/phase0-plan.md
Normal file
308
Linkding Browser Extension/LinkdingSync/docs/phase0-plan.md
Normal file
@@ -0,0 +1,308 @@
|
||||
# Phase 0: Testing & Server Behavior Validation
|
||||
|
||||
## Overview
|
||||
|
||||
This document outlines the complete Phase 0 plan for testing Linkding server behavior before redesigning the LinkdingSync extension. The goal is to gather critical information about:
|
||||
|
||||
1. **API Key Isolation**: Do API keys provide bookmark isolation, or is the user account the only isolation boundary?
|
||||
2. **Conflict Resolution**: How does Linkding handle same URL in different contexts (paths, notes)?
|
||||
3. **Delete Behavior**: Does deletion via one API key affect bookmarks created by other keys?
|
||||
4. **Bundle Tag Filtering**: How do bundles act as filters for bookmark queries?
|
||||
|
||||
## Current Status
|
||||
|
||||
| Item | Status |
|
||||
|------|--------|
|
||||
| Review extension codebase | ✅ Complete |
|
||||
| Review Linkding source code | ✅ Complete |
|
||||
| Analyze TODOs.txt findings | ✅ Complete |
|
||||
| Create test scenarios doc | ✅ Complete |
|
||||
| Create test runner script | ✅ Complete |
|
||||
| **Execute tests** | ⏳ Pending |
|
||||
| **Finalize design** | ⏳ Pending |
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
### 1. Create Test User Accounts
|
||||
|
||||
You need to create **2 user accounts** on your Linkding instance:
|
||||
|
||||
```
|
||||
user_work - For work-related bookmarks
|
||||
user_personal - For personal bookmarks
|
||||
```
|
||||
|
||||
### 2. Create API Keys
|
||||
|
||||
For each user, create **at least 2 API keys** (one per bundle):
|
||||
|
||||
| User | Key Purpose | Example Name |
|
||||
|------|-------------|--------------|
|
||||
| user_work | Work bundle access | `user_work_key_1` |
|
||||
| user_work | Work bundle access | `user_work_key_2` |
|
||||
| user_personal | Personal bundle access | `user_personal_key_1` |
|
||||
| user_personal | Personal bundle access | `user_personal_key_2` |
|
||||
|
||||
**How to create API keys**:
|
||||
1. Go to Linkding settings → Advanced
|
||||
2. Click "Create new token"
|
||||
3. Give it a descriptive name (e.g., "work-bundle-1")
|
||||
4. Copy the generated key (it won't be shown again)
|
||||
|
||||
### 3. Create Bundles
|
||||
|
||||
Create bundles for filtering:
|
||||
|
||||
| Bundle Name | Purpose |
|
||||
|-------------|---------|
|
||||
| `work` | Filter work bookmarks |
|
||||
| `personal` | Filter personal bookmarks |
|
||||
|
||||
### 4. Firefox Configuration
|
||||
|
||||
#### Option A: Single Firefox Profile with Multiple API Keys
|
||||
|
||||
Create a Firefox profile that can hold multiple API keys for testing. This allows:
|
||||
- Easy switching between work/personal contexts
|
||||
- Running automated tests via DevTools console
|
||||
|
||||
#### Option B: Separate Firefox Profiles
|
||||
|
||||
Create two separate Firefox profiles:
|
||||
- `work-profile` - Configured with work API key
|
||||
- `personal-profile` - Configured with personal API key
|
||||
|
||||
This is simpler but requires switching profiles between tests.
|
||||
|
||||
---
|
||||
|
||||
## Test Execution Instructions
|
||||
|
||||
### Step 1: Load Test Runner
|
||||
|
||||
1. Open Firefox
|
||||
2. Navigate to your Linkding settings page (any page works)
|
||||
3. Open DevTools (press `F12`)
|
||||
4. Go to the **Console** tab
|
||||
5. Copy the entire contents of `test-runner.js`
|
||||
6. Paste into the console (Ctrl+Shift+V)
|
||||
7. Press Enter
|
||||
|
||||
You should see:
|
||||
```
|
||||
LinkdingSync Test Runner loaded successfully
|
||||
```
|
||||
|
||||
### Step 2: Configure Credentials
|
||||
|
||||
In the console, modify the `CONFIG` object:
|
||||
|
||||
```javascript
|
||||
const CONFIG = {
|
||||
serverUrl: 'https://links.blabber1565.com',
|
||||
workApiKey: 'your_work_api_key_here', // Paste API key
|
||||
personalApiKey: 'your_personal_api_key_here',
|
||||
workUser: 'user_work',
|
||||
personalUser: 'user_personal',
|
||||
workBundle: 'work',
|
||||
personalBundle: 'personal',
|
||||
cleanupAfterTests: true
|
||||
};
|
||||
```
|
||||
|
||||
**Security Note**:
|
||||
- API keys are stored in the Firefox session (cleared when Firefox closes)
|
||||
- Consider creating a separate `.creds.txt` file for manual reference if needed
|
||||
|
||||
### Step 3: Run Tests
|
||||
|
||||
Execute the test suite:
|
||||
|
||||
```javascript
|
||||
runTestSuite()
|
||||
```
|
||||
|
||||
This will run all 6 test scenarios and display results in the console.
|
||||
|
||||
### Step 4: Analyze Results
|
||||
|
||||
Each test will output:
|
||||
- Setup information
|
||||
- API responses
|
||||
- **Result analysis** with ✓ or ✗ indicators
|
||||
|
||||
Example output:
|
||||
```
|
||||
=== Scenario 1: Same URL, Different API Keys, Same User ===
|
||||
✓ Created: ID=123, URL=https://example.com
|
||||
✓ Created: ID=456, URL=https://example.com
|
||||
|
||||
Results:
|
||||
Bookmark 1 ID: 123
|
||||
Bookmark 2 ID: 456
|
||||
✅ RESULT: Different bookmark IDs - API keys provide isolation
|
||||
```
|
||||
|
||||
### Step 5: Cleanup (Optional)
|
||||
|
||||
After tests complete, clean up test bookmarks:
|
||||
|
||||
```javascript
|
||||
cleanup()
|
||||
```
|
||||
|
||||
Or disable automatic cleanup in CONFIG:
|
||||
|
||||
```javascript
|
||||
cleanupAfterTests: false
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Expected Test Outcomes
|
||||
|
||||
### Test 1: API Key Isolation
|
||||
**Possible Results:**
|
||||
- **Same ID**: API keys don't provide isolation - user account is the only boundary
|
||||
- **Different IDs**: API keys provide isolation within same user
|
||||
|
||||
**Decision Impact**:
|
||||
- If keys don't isolate → Use separate users for work/personal isolation
|
||||
- If keys do isolate → Can use same user with different API keys
|
||||
|
||||
### Test 2: Cross-User Visibility
|
||||
**Possible Results:**
|
||||
- **User B sees User A's bookmarks**: Sharing enabled or same underlying user
|
||||
- **User B cannot see User A's bookmarks**: Proper isolation
|
||||
|
||||
**Decision Impact**:
|
||||
- Need separate users with sharing disabled for true isolation
|
||||
|
||||
### Test 3: Path Conflict Resolution
|
||||
**Possible Results:**
|
||||
- **Same ID**: Server merges by URL, last-write-wins for path/notes
|
||||
- **Different IDs**: Server creates separate bookmarks
|
||||
|
||||
**Decision Impact**:
|
||||
- If merges → Need path merge strategy (keep both, prompt user, etc.)
|
||||
- If separate → Can use duplicate bookmarks for different contexts
|
||||
|
||||
### Test 4: Title/Description Merge
|
||||
**Possible Results:**
|
||||
- **Last-write-wins**: Most recent update wins
|
||||
- **Merge**: Both values combined somehow
|
||||
- **No conflict**: Different titles preserved
|
||||
|
||||
**Decision Impact**:
|
||||
- Determine merge strategy for conflict resolution
|
||||
|
||||
### Test 5: Delete Propagation
|
||||
**Possible Results:**
|
||||
- **Propagates**: Deleting via one key deletes all
|
||||
- **Doesn't propagate**: Separate bookmarks per key
|
||||
|
||||
**Decision Impact:**
|
||||
- Aligns with Test 3 results
|
||||
|
||||
### Test 6: Bundle Tag Filtering
|
||||
**Possible Results:**
|
||||
- **Works**: Tags filter bookmarks by tag
|
||||
- **Doesn't work**: Tags don't provide isolation
|
||||
|
||||
**Decision Impact:**
|
||||
- Bundle tags can provide logical separation
|
||||
|
||||
---
|
||||
|
||||
## Test Results Analysis Template
|
||||
|
||||
After running tests, document results in `docs/test-results.md`:
|
||||
|
||||
```markdown
|
||||
# Test Results
|
||||
|
||||
## Test 1: API Key Isolation
|
||||
- **Result**: [same/different] IDs
|
||||
- **Conclusion**: API keys [do/don't] provide isolation
|
||||
- **Implication**: Need [separate users OR same user strategy]
|
||||
|
||||
## Test 2: Cross-User Visibility
|
||||
- **Result**: User B [can/cannot] see User A's bookmarks
|
||||
- **Conclusion**: Sharing is [enabled/disabled]
|
||||
- **Implication**: [Use different users or different settings]
|
||||
|
||||
## Test 3: Path Conflict Resolution
|
||||
- **Result**: [same/different] IDs
|
||||
- **Conclusion**: Server [merges/creates separate] bookmarks
|
||||
- **Implication**: [Merge paths OR keep separate]
|
||||
|
||||
## Test 4: Title/Description Conflict
|
||||
- **Result**: [last-write-wins/merge/no-conflict]
|
||||
- **Conclusion**: Server [uses last-write/merges/doesn't conflict]
|
||||
- **Implication**: [Strategy for conflict resolution]
|
||||
|
||||
## Test 5: Delete Propagation
|
||||
- **Result**: Delete [propagated/didn't propagate]
|
||||
- **Conclusion**: Deletion [affects all OR affects specific]
|
||||
- **Implication**: [Delete strategy]
|
||||
|
||||
## Test 6: Bundle Tag Filtering
|
||||
- **Result**: Bundle tags [work/don't work]
|
||||
- **Conclusion**: Tags [provide/isolate] bookmarks
|
||||
- **Implication**: [Use tags OR use users]
|
||||
|
||||
## Final Strategy
|
||||
Based on results:
|
||||
- [User isolation approach]
|
||||
- [Conflict resolution strategy]
|
||||
- [Sync behavior]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Next Steps After Phase 0
|
||||
|
||||
Once tests complete:
|
||||
|
||||
1. **Analyze Results**: Review test outputs and document in `test-results.md`
|
||||
2. **Finalize Design**: Create new `design.md` with confirmed behaviors
|
||||
3. **Redesign Extension**: Implement new architecture based on test findings
|
||||
4. **Create Tests**: Write unit tests in `tests/` directory
|
||||
5. **Implement Features**: Add UI improvements and scheduled sync
|
||||
6. **Document**: Update README.md and API docs
|
||||
|
||||
---
|
||||
|
||||
## Files Created
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `docs/phase0-test-scenarios.md` | Detailed test scenarios |
|
||||
| `test-runner.js` | Automated test execution script |
|
||||
| `docs/phase0-plan.md` | This document |
|
||||
|
||||
---
|
||||
|
||||
## Questions for You
|
||||
|
||||
Before you create the test accounts and run tests:
|
||||
|
||||
1. **Firefox Setup**: Which option do you prefer?
|
||||
- Single profile with multiple API keys (more complex, more flexible)
|
||||
- Separate profiles (simpler, less flexible)
|
||||
|
||||
2. **API Key Count**: Do you want to create:
|
||||
- 2 API keys per user (minimal for testing)
|
||||
- 4 API keys per user (more scenarios)
|
||||
|
||||
3. **Bundles**: Should we create:
|
||||
- 2 bundles (`work`, `personal`)
|
||||
- 4 bundles (more granular)
|
||||
|
||||
4. **Test Execution**: Will you run:
|
||||
- All tests at once (`runTestSuite()`)
|
||||
- Individual tests as you discover issues
|
||||
|
||||
Let me know, and I can adjust the test runner accordingly. Once you're ready to create the test accounts, just let me know and I'll provide final instructions.
|
||||
@@ -0,0 +1,628 @@
|
||||
# Phase 0: Server Behavior Validation Tests
|
||||
|
||||
## Overview
|
||||
|
||||
This document outlines the Phase 0 testing plan to validate Linkding server behavior regarding bookmark isolation, conflict resolution, and API authentication. These tests will inform the final architecture and conflict resolution strategy.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
### Test Environment Setup
|
||||
|
||||
You need to create the following on your Linkding instance:
|
||||
|
||||
1. **Two User Accounts**:
|
||||
- `user_work` - For work-related bookmarks
|
||||
- `user_personal` - For personal bookmarks
|
||||
|
||||
2. **API Keys per User**:
|
||||
- Each user needs at least 2 API keys (one for each bundle)
|
||||
- Example: `user_work_key_1`, `user_work_key_2`, `user_personal_key_1`, `user_personal_key_2`
|
||||
|
||||
3. **Two Bundles per User**:
|
||||
- `work` bundle (for work bookmarks)
|
||||
- `personal` bundle (for personal bookmarks)
|
||||
|
||||
### Firefox Test Harness
|
||||
|
||||
We'll create a Firefox extension (or use existing Linkding extension) that:
|
||||
- Allows testing with multiple API keys
|
||||
- Can switch between work/personal contexts
|
||||
- Runs tests via DevTools console or automated scripts
|
||||
|
||||
---
|
||||
|
||||
## Test Scenarios
|
||||
|
||||
### Scenario 1: Same URL, Different API Keys, Same User
|
||||
|
||||
**Purpose**: Verify if API keys provide bookmark isolation within the same user.
|
||||
|
||||
**Setup**:
|
||||
1. Create bookmark via `user_work_key_1` with URL: `https://example.com`
|
||||
2. Create same bookmark via `user_work_key_2` with same URL
|
||||
|
||||
**Expected Behavior**:
|
||||
- If API keys only authenticate the user, both calls create/update the same bookmark
|
||||
- API keys don't provide isolation - only user matters
|
||||
|
||||
**Test Command**:
|
||||
```javascript
|
||||
// Test 1: Same user, different API keys
|
||||
async function testSameUserDifferentKeys(serverUrl, apiKey1, apiKey2) {
|
||||
try {
|
||||
// Create with first key
|
||||
const response1 = await fetch(`${serverUrl}api/bookmarks/`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Token ${apiKey1}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
url: 'https://example.com',
|
||||
title: 'Test Bookmark 1',
|
||||
description: 'First key',
|
||||
notes: '{"path": "Work/Dev1", "userNotes": "Key 1"}'
|
||||
})
|
||||
});
|
||||
const bookmark1 = await response1.json();
|
||||
console.log('Bookmark 1:', bookmark1);
|
||||
|
||||
// Try creating same URL with second key
|
||||
const response2 = await fetch(`${serverUrl}api/bookmarks/`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Token ${apiKey2}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
url: 'https://example.com',
|
||||
title: 'Test Bookmark 2',
|
||||
description: 'Second key',
|
||||
notes: '{"path": "Work/Dev2", "userNotes": "Key 2"}'
|
||||
})
|
||||
});
|
||||
const bookmark2 = await response2.json();
|
||||
console.log('Bookmark 2:', bookmark2);
|
||||
|
||||
// Check if they're the same bookmark (same ID)
|
||||
if (bookmark1.id === bookmark2.id) {
|
||||
console.log('RESULT: Same bookmark (API keys do not isolate)');
|
||||
} else {
|
||||
console.log('RESULT: Different bookmarks (API keys provide isolation)');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error:', error.message);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Scenario 2: Same URL, Different Users
|
||||
|
||||
**Purpose**: Verify if different users can see each other's bookmarks without sharing enabled.
|
||||
|
||||
**Setup**:
|
||||
1. User A creates bookmark via their API key
|
||||
2. User B tries to retrieve same URL via their API key
|
||||
|
||||
**Expected Behavior**:
|
||||
- Without sharing: User B should get 404 or empty results
|
||||
- With sharing enabled: Both can see each other's bookmarks
|
||||
|
||||
**Test Command**:
|
||||
```javascript
|
||||
// Test 2: Different users, no sharing
|
||||
async function testDifferentUsersNoSharing(serverUrl, userAKey, userBKey) {
|
||||
try {
|
||||
// Create bookmark as User A
|
||||
const createResponse = await fetch(`${serverUrl}api/bookmarks/`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Token ${userAKey}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
url: 'https://shared-example.com',
|
||||
title: 'Shared Bookmark',
|
||||
description: 'Created by User A',
|
||||
notes: '{"path": "UserA/Shared"}'
|
||||
})
|
||||
});
|
||||
const bookmark = await createResponse.json();
|
||||
console.log('Bookmark created by User A:', bookmark.id);
|
||||
|
||||
// User B tries to get this bookmark
|
||||
const getResponse = await fetch(`${serverUrl}api/bookmarks/?all=Shared`, {
|
||||
headers: {
|
||||
'Authorization': `Token ${userBKey}`,
|
||||
}
|
||||
});
|
||||
|
||||
if (getResponse.ok) {
|
||||
const result = await getResponse.json();
|
||||
console.log(`User B sees ${result.count} bookmarks`);
|
||||
if (result.results && result.results.length > 0) {
|
||||
console.log('RESULT: User B can see User A\'s bookmark (sharing or same user)');
|
||||
} else {
|
||||
console.log('RESULT: User B cannot see User A\'s bookmark (proper isolation)');
|
||||
}
|
||||
} else {
|
||||
console.log('RESULT: User B cannot access (4xx/5xx error)');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error:', error.message);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Scenario 3: Conflict Resolution - Different Folders/Paths
|
||||
|
||||
**Purpose**: Determine how the server handles same URL in different paths.
|
||||
|
||||
**Setup**:
|
||||
1. Create bookmark as "Work" with path "Work/Development"
|
||||
2. Create same URL as "Personal" with path "Personal/Notes"
|
||||
|
||||
**Expected Behavior**:
|
||||
- Need to confirm if server creates duplicate or updates existing
|
||||
|
||||
**Test Command**:
|
||||
```javascript
|
||||
// Test 3: Conflict resolution with different paths
|
||||
async function testConflictResolution(serverUrl, workKey, personalKey) {
|
||||
const url = 'https://conflict-test.example.com';
|
||||
const pathWork = 'Work/Development';
|
||||
const pathPersonal = 'Personal/Notes';
|
||||
|
||||
try {
|
||||
// Create as Work
|
||||
const workBookmark = await createBookmark(serverUrl, workKey, url, {
|
||||
title: 'Conflict Test',
|
||||
description: 'Work version',
|
||||
notes: JSON.stringify({
|
||||
path: pathWork,
|
||||
userNotes: 'Work Development Notes',
|
||||
autoTags: [{name: 'Work'}]
|
||||
})
|
||||
});
|
||||
console.log('Work bookmark created:', workBookmark.id);
|
||||
|
||||
// Create same URL as Personal
|
||||
const personalBookmark = await createBookmark(serverUrl, personalKey, url, {
|
||||
title: 'Conflict Test',
|
||||
description: 'Personal version',
|
||||
notes: JSON.stringify({
|
||||
path: pathPersonal,
|
||||
userNotes: 'Personal Notes',
|
||||
autoTags: [{name: 'Personal'}]
|
||||
})
|
||||
});
|
||||
console.log('Personal bookmark created:', personalBookmark.id);
|
||||
|
||||
// Check IDs
|
||||
if (workBookmark.id === personalBookmark.id) {
|
||||
console.log('RESULT: Same bookmark ID - paths are merged or overwritten');
|
||||
// Fetch and show current state
|
||||
const fetchResponse = await fetch(`${serverUrl}api/bookmarks/${workBookmark.id}/`, {
|
||||
headers: {
|
||||
'Authorization': `Token ${workKey}`,
|
||||
}
|
||||
});
|
||||
const current = await fetchResponse.json();
|
||||
console.log('Current bookmark state:', current);
|
||||
} else {
|
||||
console.log('RESULT: Different bookmark IDs - server creates duplicates');
|
||||
console.log('Work ID:', workBookmark.id);
|
||||
console.log('Personal ID:', personalBookmark.id);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error:', error.message);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Scenario 4: Title/Description Conflict
|
||||
|
||||
**Purpose**: Understand conflict resolution for title/description fields.
|
||||
|
||||
**Setup**:
|
||||
1. Update bookmark via Work context (title: "Work Title")
|
||||
2. Update same bookmark via Personal context (title: "Personal Title")
|
||||
3. Check final state
|
||||
|
||||
**Expected Behavior**:
|
||||
- Confirm last-write-wins or other resolution strategy
|
||||
|
||||
**Test Command**:
|
||||
```javascript
|
||||
// Test 4: Title/Description conflict resolution
|
||||
async function testTitleConflict(serverUrl, workKey, personalKey) {
|
||||
const url = 'https://title-conflict.example.com';
|
||||
|
||||
try {
|
||||
// Create initial bookmark
|
||||
const bookmark = await createBookmark(serverUrl, workKey, url, {
|
||||
title: 'Initial Title',
|
||||
description: 'Initial Description',
|
||||
notes: JSON.stringify({
|
||||
path: 'Work/Initial',
|
||||
userNotes: 'Initial'
|
||||
})
|
||||
});
|
||||
|
||||
// Update via Work
|
||||
await updateBookmark(serverUrl, workKey, bookmark.id, {
|
||||
title: 'Work Title',
|
||||
description: 'Work Description',
|
||||
notes: JSON.stringify({
|
||||
path: 'Work/Dev',
|
||||
userNotes: 'Work notes',
|
||||
autoTags: [{name: 'Work'}]
|
||||
})
|
||||
});
|
||||
console.log('Updated via Work: Work Title');
|
||||
|
||||
// Update via Personal
|
||||
await updateBookmark(serverUrl, personalKey, bookmark.id, {
|
||||
title: 'Personal Title',
|
||||
description: 'Personal Description',
|
||||
notes: JSON.stringify({
|
||||
path: 'Personal/Notes',
|
||||
userNotes: 'Personal notes',
|
||||
autoTags: [{name: 'Personal'}]
|
||||
})
|
||||
});
|
||||
console.log('Updated via Personal: Personal Title');
|
||||
|
||||
// Fetch final state
|
||||
const final = await fetchBookmark(serverUrl, workKey, bookmark.id);
|
||||
console.log('Final state:', {
|
||||
title: final.title,
|
||||
description: final.description,
|
||||
notes: JSON.parse(final.notes)
|
||||
});
|
||||
|
||||
if (final.title === 'Personal Title') {
|
||||
console.log('RESULT: Last-write-wins (Personal update took precedence)');
|
||||
} else if (final.title === 'Work Title') {
|
||||
console.log('RESULT: Last-write-wins (Work update took precedence)');
|
||||
} else if (final.title.includes('Work') && final.title.includes('Personal')) {
|
||||
console.log('RESULT: Merged title');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error:', error.message);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Scenario 5: Delete Propagation
|
||||
|
||||
**Purpose**: Confirm if deleting a bookmark affects all API keys.
|
||||
|
||||
**Setup**:
|
||||
1. Create bookmark via multiple API keys (same URL)
|
||||
2. Delete via one API key
|
||||
3. Check if bookmark still exists via other keys
|
||||
|
||||
**Expected Behavior**:
|
||||
- If API keys create same bookmark, deletion should propagate
|
||||
- If API keys create separate bookmarks, deletion only affects one
|
||||
|
||||
**Test Command**:
|
||||
```javascript
|
||||
// Test 5: Delete propagation
|
||||
async function testDeletePropagation(serverUrl, workKey, personalKey) {
|
||||
const url = 'https://delete-test.example.com';
|
||||
|
||||
try {
|
||||
// Create via both keys
|
||||
const workBookmark = await createBookmark(serverUrl, workKey, url, {
|
||||
title: 'Delete Test',
|
||||
notes: JSON.stringify({path: 'Work/Dev'})
|
||||
});
|
||||
console.log('Work bookmark ID:', workBookmark.id);
|
||||
|
||||
const personalBookmark = await createBookmark(serverUrl, personalKey, url, {
|
||||
title: 'Delete Test',
|
||||
notes: JSON.stringify({path: 'Personal/Notes'})
|
||||
});
|
||||
console.log('Personal bookmark ID:', personalBookmark.id);
|
||||
|
||||
// Check if same bookmark
|
||||
const sameBookmark = workBookmark.id === personalBookmark.id;
|
||||
|
||||
// Delete via Work key
|
||||
const deleteResponse = await fetch(`${serverUrl}api/bookmarks/${workBookmark.id}/`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Authorization': `Token ${workKey}`,
|
||||
}
|
||||
});
|
||||
|
||||
console.log('Delete response status:', deleteResponse.status);
|
||||
|
||||
// Try to fetch via Personal key
|
||||
const fetchPersonal = await fetch(`${serverUrl}api/bookmarks/?limit=100`, {
|
||||
headers: {
|
||||
'Authorization': `Token ${personalKey}`,
|
||||
}
|
||||
});
|
||||
|
||||
if (fetchPersonal.ok) {
|
||||
const personalResults = await fetchPersonal.json();
|
||||
const deleted = personalResults.results.find(b => b.url === url);
|
||||
|
||||
if (!deleted) {
|
||||
console.log('RESULT: Delete propagated (same bookmark)');
|
||||
} else {
|
||||
console.log('RESULT: Delete did not propagate (separate bookmarks)');
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error:', error.message);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Scenario 6: Bundle Tag Isolation
|
||||
|
||||
**Purpose**: Verify if different bundle tags properly isolate bookmarks.
|
||||
|
||||
**Setup**:
|
||||
1. Create bookmark with bundle tag "work-bundle"
|
||||
2. Create bookmark with bundle tag "personal-bundle"
|
||||
3. Query each bundle separately
|
||||
|
||||
**Expected Behavior**:
|
||||
- Bundle tags should act as filters
|
||||
- Need to confirm if tags create separate bookmarks or filter existing ones
|
||||
|
||||
**Test Command**:
|
||||
```javascript
|
||||
// Test 6: Bundle tag isolation
|
||||
async function testBundleIsolation(serverUrl, workKey, personalKey) {
|
||||
try {
|
||||
// Create bundle tag bookmark
|
||||
const bookmark = await createBookmark(serverUrl, workKey, 'https://bundle-test.com', {
|
||||
title: 'Bundle Test',
|
||||
notes: JSON.stringify({
|
||||
path: 'Test/Path',
|
||||
userNotes: 'Initial',
|
||||
bundleTag: 'work-bundle',
|
||||
autoTags: [{name: 'work-bundle'}]
|
||||
})
|
||||
});
|
||||
console.log('Bookmark created:', bookmark.id);
|
||||
|
||||
// Query by bundle tag
|
||||
const workBundleResponse = await fetch(`${serverUrl}api/bookmarks/?all=work-bundle`, {
|
||||
headers: {
|
||||
'Authorization': `Token ${workKey}`,
|
||||
}
|
||||
});
|
||||
|
||||
const personalBundleResponse = await fetch(`${serverUrl}api/bookmarks/?all=personal-bundle`, {
|
||||
headers: {
|
||||
'Authorization': `Token ${workKey}`,
|
||||
}
|
||||
});
|
||||
|
||||
const workCount = await workBundleResponse.json();
|
||||
const personalCount = await personalBundleResponse.json();
|
||||
|
||||
console.log(`Work bundle has ${workCount.count || workCount.results?.length || 0} bookmarks`);
|
||||
console.log(`Personal bundle has ${personalCount.count || personalCount.results?.length || 0} bookmarks`);
|
||||
|
||||
if (workCount.count === 1 && personalCount.count === 0) {
|
||||
console.log('RESULT: Bundle tags provide proper isolation');
|
||||
} else {
|
||||
console.log('RESULT: Bundle tags may not provide isolation');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error:', error.message);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Session Management for Automated Tests
|
||||
|
||||
### Challenge: Authentication Context Switching
|
||||
|
||||
When running automated tests, we need to manage switching between:
|
||||
- Different API keys
|
||||
- Different users (if applicable)
|
||||
- Different bundle contexts
|
||||
|
||||
### Solution: Context Switching Function
|
||||
|
||||
```javascript
|
||||
// Session management for tests
|
||||
const SessionManager = {
|
||||
// Store active context
|
||||
context: {
|
||||
serverUrl: '',
|
||||
currentApiKey: '',
|
||||
currentUserId: null,
|
||||
currentBundle: null
|
||||
},
|
||||
|
||||
// Set new context
|
||||
setContext(serverUrl, apiKey, userId = null, bundle = null) {
|
||||
console.log(`Switching context: ${apiKey.substring(0, 8)}...`);
|
||||
this.context.serverUrl = serverUrl;
|
||||
this.context.currentApiKey = apiKey;
|
||||
this.context.currentUserId = userId;
|
||||
this.context.currentBundle = bundle;
|
||||
|
||||
// Return auth headers for this context
|
||||
return {
|
||||
headers: {
|
||||
'Authorization': `Token ${apiKey}`
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
// Make API call with current context
|
||||
async call(endpoint, method = 'GET', body = null) {
|
||||
const headers = {
|
||||
...this.getHeaders(),
|
||||
'Content-Type': 'application/json'
|
||||
};
|
||||
|
||||
if (body) headers['Content-Type'] = 'application/json';
|
||||
|
||||
const response = await fetch(`${this.context.serverUrl}${endpoint}`, {
|
||||
method,
|
||||
headers,
|
||||
body: body ? JSON.stringify(body) : null
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
},
|
||||
|
||||
// Get headers for current context
|
||||
getHeaders() {
|
||||
return {
|
||||
'Authorization': `Token ${this.context.currentApiKey}`
|
||||
};
|
||||
},
|
||||
|
||||
// Reset to default context
|
||||
reset() {
|
||||
this.context = {
|
||||
serverUrl: '',
|
||||
currentApiKey: '',
|
||||
currentUserId: null,
|
||||
currentBundle: null
|
||||
};
|
||||
console.log('Session reset');
|
||||
}
|
||||
};
|
||||
|
||||
// Usage example:
|
||||
// SessionManager.setContext('https://links.blabber1565.com', 'work-api-key-123');
|
||||
// const bookmark = await SessionManager.call('/api/bookmarks/', 'POST', {url: '...'});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Test Execution Script
|
||||
|
||||
Create a file: `test-runner.js` in the LinkdingSync directory:
|
||||
|
||||
```javascript
|
||||
/*
|
||||
* Test Runner for Linkding Server Behavior Validation
|
||||
*
|
||||
* Instructions:
|
||||
* 1. Load this file into Firefox DevTools console
|
||||
* 2. Enter your test configuration
|
||||
* 3. Run test suite
|
||||
*/
|
||||
|
||||
// Configuration
|
||||
const CONFIG = {
|
||||
serverUrl: 'https://links.blabber1565.com',
|
||||
workApiKey: '', // Fill in your work API key
|
||||
personalApiKey: '', // Fill in your personal API key
|
||||
workBundle: 'work-bundle',
|
||||
personalBundle: 'personal-bundle'
|
||||
};
|
||||
|
||||
// Import SessionManager
|
||||
// (paste SessionManager code from above)
|
||||
|
||||
// Test Suite
|
||||
async function runTestSuite() {
|
||||
console.log('=== Linkding Server Behavior Test Suite ===\n');
|
||||
|
||||
try {
|
||||
// Setup context
|
||||
SessionManager.setContext(CONFIG.serverUrl, CONFIG.workApiKey, 'user_work', CONFIG.workBundle);
|
||||
|
||||
// Run tests
|
||||
await testSameUserDifferentKeys(CONFIG.serverUrl, CONFIG.workApiKey, CONFIG.personalApiKey);
|
||||
console.log('\n--- Test 1 Complete ---\n');
|
||||
|
||||
await testDifferentUsersNoSharing(CONFIG.serverUrl, CONFIG.workApiKey, CONFIG.personalApiKey);
|
||||
console.log('\n--- Test 2 Complete ---\n');
|
||||
|
||||
await testConflictResolution(CONFIG.serverUrl, CONFIG.workApiKey, CONFIG.personalApiKey);
|
||||
console.log('\n--- Test 3 Complete ---\n');
|
||||
|
||||
await testTitleConflict(CONFIG.serverUrl, CONFIG.workApiKey, CONFIG.personalApiKey);
|
||||
console.log('\n--- Test 4 Complete ---\n');
|
||||
|
||||
await testDeletePropagation(CONFIG.serverUrl, CONFIG.workApiKey, CONFIG.personalApiKey);
|
||||
console.log('\n--- Test 5 Complete ---\n');
|
||||
|
||||
await testBundleIsolation(CONFIG.serverUrl, CONFIG.workApiKey, CONFIG.personalApiKey);
|
||||
console.log('\n--- Test 6 Complete ---\n');
|
||||
|
||||
console.log('=== All Tests Complete ===');
|
||||
|
||||
} catch (error) {
|
||||
console.error('Test suite failed:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize
|
||||
console.log('Type: runTestSuite() to execute all tests');
|
||||
console.log('Type: runTestSuite("scenario") to run specific scenario');
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Expected Outcomes & Decision Points
|
||||
|
||||
After running Phase 0 tests, we'll make decisions based on:
|
||||
|
||||
### Decision Point 1: API Key Isolation
|
||||
- **If same user**: API keys don't provide isolation → Use separate users or different accounts
|
||||
- **If different users**: Confirm sharing settings needed
|
||||
|
||||
### Decision Point 2: Conflict Resolution Strategy
|
||||
- **If last-write-wins**: May cause toggle issues between browsers
|
||||
- **If merge**: Determine merge strategy (path, title, etc.)
|
||||
- **If duplicates**: Accept separate bookmarks per context
|
||||
|
||||
### Decision Point 3: Bundle Isolation
|
||||
- **If tags isolate**: Use bundle tags for isolation
|
||||
- **If not**: Need user accounts for isolation
|
||||
|
||||
---
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. **Create Test Accounts**: You create the 2 user accounts with API keys
|
||||
2. **Configure Test Harness**: Fill in CONFIG with API keys
|
||||
3. **Run Tests**: Execute `runTestSuite()` in Firefox console
|
||||
4. **Analyze Results**: Review test outputs
|
||||
5. **Finalize Design**: Update architecture based on findings
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- Tests are idempotent - they clean up test bookmarks after execution
|
||||
- All test bookmarks are tagged with "test-" prefix for easy identification
|
||||
- Test results will inform the final redesign of LinkdingSync extension
|
||||
256
Linkding Browser Extension/LinkdingSync/docs/test-usage.md
Normal file
256
Linkding Browser Extension/LinkdingSync/docs/test-usage.md
Normal file
@@ -0,0 +1,256 @@
|
||||
# LinkdingSync Test Usage Guide
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Step 1: Load Test Files in Firefox DevTools
|
||||
|
||||
1. Open Firefox and navigate to your Linkding instance
|
||||
2. Open DevTools (`F12`) and go to the **Console** tab
|
||||
3. Copy the entire contents of `tests/orchestrator.js`
|
||||
4. Paste into the console (Ctrl+Shift+V)
|
||||
5. Press Enter
|
||||
|
||||
You should see:
|
||||
```
|
||||
LinkdingSync Test Orchestrator loaded
|
||||
|
||||
Commands:
|
||||
runAllTests() - Run all tests
|
||||
runAllTestsWithReset() - Run with cleanup first
|
||||
runModule("name") - Run specific test module
|
||||
reset() - Clean up test bookmarks
|
||||
```
|
||||
|
||||
### Step 2: Configure Credentials
|
||||
|
||||
The `tests/utils.js` file already contains your credentials from `.creds.txt`:
|
||||
|
||||
```javascript
|
||||
const CONFIG = {
|
||||
serverUrl: 'https://links.blabber1565.com',
|
||||
workApiKey: '4108e3aff26fb82bf074f5d4dfa4757763520b06',
|
||||
workUser: 'linkdingsync_tester',
|
||||
workBundle: 'work',
|
||||
personalApiKey: '9b80accd3b9b4b91c2a7adc3dcf41621b025329a',
|
||||
personalUser: 'linkdingsync_tester_2',
|
||||
personalBundle: 'personal',
|
||||
cleanupAfterTests: true
|
||||
};
|
||||
```
|
||||
|
||||
If you need to modify credentials, edit `tests/utils.js` directly.
|
||||
|
||||
### Step 3: Run Tests
|
||||
|
||||
#### Run All Tests
|
||||
|
||||
```javascript
|
||||
runAllTests()
|
||||
```
|
||||
|
||||
This will execute all 8 test scenarios and display results in the console.
|
||||
|
||||
#### Run with Reset First
|
||||
|
||||
```javascript
|
||||
runAllTestsWithReset()
|
||||
```
|
||||
|
||||
This cleans up any existing test bookmarks before running tests.
|
||||
|
||||
#### Run Specific Module
|
||||
|
||||
```javascript
|
||||
runModule('isolation') // Tests 1-2: API Key & User Isolation
|
||||
runModule('conflicts') // Tests 3-4: Conflict Resolution
|
||||
runModule('deletion') // Tests 5-6: Delete Propagation
|
||||
runModule('bundles') // Tests 7-8: Bundle Filtering
|
||||
```
|
||||
|
||||
#### Reset Test Bookmarks
|
||||
|
||||
```javascript
|
||||
reset()
|
||||
```
|
||||
|
||||
Or run `runAllTestsWithReset()` which includes reset automatically.
|
||||
|
||||
---
|
||||
|
||||
## Test Scenarios
|
||||
|
||||
| Module | Tests | Purpose |
|
||||
|--------|-------|---------|
|
||||
| **isolation** | 1-2 | Verify API key and user isolation |
|
||||
| **conflicts** | 3-4 | Conflict resolution behavior |
|
||||
| **deletion** | 5-6 | Delete propagation behavior |
|
||||
| **bundles** | 7-8 | Bundle tag filtering |
|
||||
|
||||
---
|
||||
|
||||
## Expected Output
|
||||
|
||||
Each test module produces output like:
|
||||
|
||||
```
|
||||
=== Test 1: Same URL, Different API Keys, Same User ===
|
||||
Purpose: Verify if API keys provide isolation within same user
|
||||
Created: ID=test-0585-ab
|
||||
Created: ID=test-0585-ac
|
||||
|
||||
Work bookmark ID: test-0585-ab
|
||||
Personal bookmark ID: test-0585-ac
|
||||
|
||||
[Test 1] ✓ PASS Different bookmark IDs - API keys provide isolation
|
||||
→ Different API keys create separate bookmarks
|
||||
|
||||
=== Test 2: Different Users - Verify Isolation ===
|
||||
Bookmark created by work user: ID=test-0586-ab
|
||||
|
||||
Work user sees bookmark: Test: test-0586-ab
|
||||
|
||||
Personal user sees 1 bookmarks
|
||||
|
||||
[Test 2] ✓ PASS Proper user isolation exists
|
||||
→ Can use different API keys for isolation
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Interpreting Results
|
||||
|
||||
### Isolation Tests
|
||||
|
||||
- **PASS**: Different API keys create separate bookmarks
|
||||
- **FAIL**: Same bookmark ID means API keys don't provide isolation
|
||||
|
||||
### Conflict Resolution Tests
|
||||
|
||||
- **PASS**: Server creates separate bookmarks per API key
|
||||
- **WARN**: Unexpected behavior detected
|
||||
|
||||
### Delete Propagation Tests
|
||||
|
||||
- **PASS**: Each bookmark exists independently
|
||||
- **FAIL**: Delete propagates (same bookmark)
|
||||
|
||||
### Bundle Tests
|
||||
|
||||
- **PASS**: Bundles provide logical separation
|
||||
- **WARN**: Bundle filtering unclear
|
||||
|
||||
---
|
||||
|
||||
## Debugging
|
||||
|
||||
### View All Test Bookmarks
|
||||
|
||||
```javascript
|
||||
LinkdingSyncTests.Helpers.getAllBookmarks()
|
||||
```
|
||||
|
||||
### Check a Specific Bookmark
|
||||
|
||||
```javascript
|
||||
LinkdingSyncTests.Helpers.fetchBookmark('bookmark-id')
|
||||
```
|
||||
|
||||
### Parse Bookmark Notes
|
||||
|
||||
```javascript
|
||||
LinkdingSyncTests.Helpers.parseNotes(bookmark.notes)
|
||||
```
|
||||
|
||||
### Reset Everything
|
||||
|
||||
```javascript
|
||||
reset()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Next Steps After Phase 0
|
||||
|
||||
Once tests complete:
|
||||
|
||||
1. **Review Results**: Check which tests pass/fail
|
||||
2. **Document Findings**: Update `docs/test-results.md`
|
||||
3. **Finalize Design**: Create new architecture based on test results
|
||||
4. **Redesign Extension**: Implement new LinkdingSync based on findings
|
||||
5. **Write Unit Tests**: Add tests to `tests/` directory
|
||||
|
||||
---
|
||||
|
||||
## Common Commands
|
||||
|
||||
```javascript
|
||||
// Run all tests
|
||||
runAllTestsWithReset()
|
||||
|
||||
// Run isolation tests only
|
||||
runModule('isolation')
|
||||
|
||||
// Check current bookmarks
|
||||
LinkdingSyncTests.Helpers.getAllBookmarks()
|
||||
|
||||
// Reset before running
|
||||
reset()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Tests Not Running
|
||||
|
||||
- **Check**: Console shows "Orchestrator loaded"
|
||||
- **Solution**: Load `orchestrator.js` first, then modules
|
||||
|
||||
### Credentials Not Working
|
||||
|
||||
- **Check**: `CONFIG` in `utils.js` has correct values
|
||||
- **Solution**: Verify API keys work in Linkding UI
|
||||
|
||||
### Tests Creating Duplicate Bookmarks
|
||||
|
||||
- **Expected**: This is the test behavior
|
||||
- **Solution**: Run `reset()` to clean up
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
LinkdingSync/
|
||||
├── tests/
|
||||
│ ├── utils.js # Shared utilities
|
||||
│ ├── orchestrator.js # Main test runner
|
||||
│ ├── test-isolation.js # Isolation tests
|
||||
│ ├── test-conflicts.js # Conflict tests
|
||||
│ ├── test-deletion.js # Deletion tests
|
||||
│ └── test-bundles.js # Bundle tests
|
||||
├── docs/
|
||||
│ ├── phase0-plan.md # Phase 0 planning
|
||||
│ ├── test-usage.md # This file
|
||||
│ └── phase0-test-scenarios.md
|
||||
├── test-runner.js # Legacy (can be deleted after migration)
|
||||
└── .creds.txt # Test credentials
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Security Notes
|
||||
|
||||
- API keys stored in Firefox session (cleared when browser closes)
|
||||
- Test bookmarks have unique testId prefix
|
||||
- All test bookmarks are cleaned up automatically
|
||||
- No sensitive data in test bookmarks
|
||||
|
||||
---
|
||||
|
||||
## Support
|
||||
|
||||
For issues, check:
|
||||
1. Console output for error messages
|
||||
2. `.creds.txt` for correct credentials
|
||||
3. Linkding server logs for API errors
|
||||
98
Linkding Browser Extension/LinkdingSync/playwright.config.ts
Normal file
98
Linkding Browser Extension/LinkdingSync/playwright.config.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Playwright configuration for LinkdingSync E2E tests
|
||||
*
|
||||
* Test setup:
|
||||
* - Launches Firefox (primary) and Chromium (optional)
|
||||
* - Configured for headless mode for CI
|
||||
* - API calls to Linkding backend for sync testing
|
||||
*/
|
||||
|
||||
export default defineConfig({
|
||||
// Define a base URL for the extension (adjust as needed)
|
||||
baseURL: 'http://localhost:5555',
|
||||
|
||||
// Test directory
|
||||
testDir: './tests',
|
||||
|
||||
// Run all tests in parallel
|
||||
fullyParallel: true,
|
||||
|
||||
// In CI, forbid only tests (must pass all tests)
|
||||
forbidOnly: !!process.env.CI,
|
||||
|
||||
// Retry failed tests in CI
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
|
||||
// Test workers (use 1 in CI for determinism)
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
|
||||
// Test reporter (HTML for visual debugging)
|
||||
reporter: [
|
||||
['list'],
|
||||
['html', { outputFolder: 'test-results' }],
|
||||
],
|
||||
|
||||
// Shared configuration for all tests
|
||||
use: {
|
||||
// Base URL for API calls
|
||||
baseURL: 'http://localhost:5555',
|
||||
|
||||
// Collect trace on failure for debugging
|
||||
trace: 'on-first-retry',
|
||||
|
||||
// Take screenshot on failure
|
||||
screenshot: 'only-on-failure',
|
||||
|
||||
// Browser context settings
|
||||
contextOptions: {
|
||||
// Accept cookies (needed for sync)
|
||||
acceptDownloads: true,
|
||||
},
|
||||
|
||||
// Set viewport for consistent tests
|
||||
viewport: { width: 1280, height: 800 },
|
||||
|
||||
// Ignore HTTPS errors for local testing (if needed)
|
||||
// ignoreHTTPSErrors: true,
|
||||
},
|
||||
|
||||
// Define test projects for different browsers
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
},
|
||||
{
|
||||
name: 'firefox',
|
||||
use: { ...devices['Desktop Firefox'] },
|
||||
},
|
||||
// Mobile emulation (optional)
|
||||
// {
|
||||
// name: 'Mobile Chrome',
|
||||
// use: { ...devices['Pixel 5'] },
|
||||
// },
|
||||
],
|
||||
|
||||
// Web server setup for tests
|
||||
// Uncomment if you need a server for tests
|
||||
// webServer: {
|
||||
// command: 'npm start',
|
||||
// port: 5555,
|
||||
// timeout: 120 * 1000,
|
||||
// reuseExistingServer: !process.env.CI,
|
||||
// },
|
||||
|
||||
// Timeout for each test (increase for flaky API calls)
|
||||
timeout: 30 * 1000,
|
||||
|
||||
// Expectation of how many tests should run
|
||||
expect: {
|
||||
// Timeout for assertions
|
||||
timeout: 1000,
|
||||
},
|
||||
|
||||
// Global timeout for the whole test run
|
||||
globalTimeout: 30 * 60 * 1000, // 30 minutes
|
||||
});
|
||||
38
Linkding Browser Extension/LinkdingSync/task-brief.md
Normal file
38
Linkding Browser Extension/LinkdingSync/task-brief.md
Normal file
@@ -0,0 +1,38 @@
|
||||
# Task Brief: Implement Playwright E2E Tests for LinkdingSync
|
||||
|
||||
## Context
|
||||
LinkdingSync is a Firefox browser extension that synchronizes bookmarks with a self-hosted Linkding instance. Current state: Basic functionality exists but lacks comprehensive E2E test coverage for bookmark sync operations.
|
||||
|
||||
## Goal
|
||||
Implement comprehensive Playwright E2E tests to increase test coverage and ensure bookmark sync reliability.
|
||||
|
||||
## Acceptance Criteria
|
||||
- [ ] Tests pass for happy path (bookmark creation, sync to Linkding)
|
||||
- [ ] Tests fail appropriately for invalid API responses
|
||||
- [ ] Tests run under 5 minutes (Firefox primary)
|
||||
- [ ] Coverage increases by at least 50%
|
||||
- [ ] Tests include proper setup/teardown (browser launch, API authentication)
|
||||
- [ ] Tests cover: bookmark creation, API sync, conflict resolution, folder structure, auto-tag generation
|
||||
|
||||
## Constraints
|
||||
- Don't modify core extension logic (manifest.json, main sync logic)
|
||||
- Use existing API patterns (token authentication)
|
||||
- Keep tests focused on UI automation, not unit logic
|
||||
- Tests must work in headless mode for CI
|
||||
|
||||
## Related Files
|
||||
- `@LinkdingSync\popup.html` - UI for bookmark entry
|
||||
- `@LinkdingSync\background.js` - Service worker
|
||||
- `@LinkdingSync\utils\sync.js` - Sync logic
|
||||
- `@LinkdingSync\manifest.json` - Extension config
|
||||
- `@LinkdingSync\playwright.config.ts` - Test configuration
|
||||
|
||||
## Time Estimate
|
||||
45-60 minutes for initial test implementation
|
||||
|
||||
## Checkpoints
|
||||
- 50%: Initial test structure created, first tests passing
|
||||
- 90%: Most tests implemented, iterating on fixes
|
||||
|
||||
## Chatlog Reference
|
||||
- Session log: `@LinkdingSync\chatlog.md`
|
||||
31
Linkding Browser Extension/LinkdingSync/test-runner.js
Normal file
31
Linkding Browser Extension/LinkdingSync/test-runner.js
Normal file
@@ -0,0 +1,31 @@
|
||||
/*
|
||||
* DEPRECATED: LinkdingSync Test Runner (Legacy)
|
||||
*
|
||||
* This file has been replaced by the modular test structure:
|
||||
*
|
||||
* tests/
|
||||
* ├── utils.js # Shared utilities
|
||||
* ├── orchestrator.js # Main test runner
|
||||
* ├── test-isolation.js # Isolation tests (scenarios 1-2)
|
||||
* ├── test-conflicts.js # Conflict tests (scenarios 3-4)
|
||||
* ├── test-deletion.js # Deletion tests (scenarios 5-6)
|
||||
* └── test-bundles.js # Bundle tests (scenarios 7-8)
|
||||
*
|
||||
* To use the new tests:
|
||||
* 1. Open Firefox DevTools console
|
||||
* 2. Copy tests/orchestrator.js into console
|
||||
* 3. Run runAllTestsWithReset()
|
||||
*
|
||||
* See docs/test-usage.md for full instructions.
|
||||
*/
|
||||
|
||||
console.log('');
|
||||
console.log('LinkdingSync test-runner.js is DEPRECATED');
|
||||
console.log('');
|
||||
console.log('Use the new modular test structure instead:');
|
||||
console.log('');
|
||||
console.log(' 1. Copy tests/orchestrator.js to Firefox DevTools console');
|
||||
console.log(' 2. Run runAllTestsWithReset()');
|
||||
console.log('');
|
||||
console.log(' See docs/test-usage.md for instructions');
|
||||
console.log('');
|
||||
245
Linkding Browser Extension/LinkdingSync/tests/automation.html
Normal file
245
Linkding Browser Extension/LinkdingSync/tests/automation.html
Normal 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>
|
||||
257
Linkding Browser Extension/LinkdingSync/tests/console-test.js
Normal file
257
Linkding Browser Extension/LinkdingSync/tests/console-test.js
Normal file
@@ -0,0 +1,257 @@
|
||||
/*
|
||||
* LinkdingSync Console Test Runner
|
||||
* Paste directly into Firefox DevTools Console
|
||||
*
|
||||
* IMPORTANT: Firefox console adds a wrapper, so we assign to window directly
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
(function(w) {
|
||||
'use strict';
|
||||
var window = w || window;
|
||||
|
||||
// CONFIG
|
||||
var serverUrl = 'https://links.blabber1565.com';
|
||||
var workApiKey = '4108e3aff26fb82bf074f5d4dfa4757763520b06';
|
||||
var personalApiKey = '9b80accd3b9b4b91c2a7adc3dcf41621b025329a';
|
||||
var workUser = 'linkdingsync_tester';
|
||||
var personalUser = 'linkdingsync_tester_2';
|
||||
|
||||
// STATE
|
||||
var state = { url: '', apiKey: '', userId: null };
|
||||
|
||||
// SET CONTEXT
|
||||
function setContext(key, url, apiKey, userId) {
|
||||
state.url = url.endsWith('/') ? url : url + '/';
|
||||
state.apiKey = apiKey;
|
||||
state.userId = userId;
|
||||
}
|
||||
|
||||
// API CALL
|
||||
function call(method, endpoint, data) {
|
||||
var u = new URL(endpoint, state.url);
|
||||
var r = fetch(u, {
|
||||
method: method,
|
||||
headers: {
|
||||
'Authorization': 'Token ' + state.apiKey,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: data ? JSON.stringify(data) : null
|
||||
});
|
||||
return r.then(function(res) {
|
||||
if (!res.ok) throw new Error(res.status + ': ' + res.statusText);
|
||||
return res.json();
|
||||
});
|
||||
}
|
||||
|
||||
// CREATE BOOKMARK
|
||||
function create(url, opts) {
|
||||
var testId = 'test-' + Date.now().toString().slice(-4) + '-' + Math.random().toString(36).slice(2,4);
|
||||
var base = new URL(url);
|
||||
base.hostname = testId + '.' + base.hostname;
|
||||
var data = {
|
||||
url: base.href,
|
||||
title: opts.title || 'Test: ' + testId,
|
||||
description: 'Test',
|
||||
notes: JSON.stringify({ testId, path: 'Test/' + testId })
|
||||
};
|
||||
console.log(' Created: ID=' + data.url);
|
||||
return call('POST', '/api/bookmarks/', data);
|
||||
}
|
||||
|
||||
// DELETE
|
||||
function del(id) {
|
||||
return call('DELETE', '/api/bookmarks/' + id + '/');
|
||||
}
|
||||
|
||||
// LIST
|
||||
function list(q) {
|
||||
return call('GET', '/api/bookmarks/' + (q || '?limit=100'));
|
||||
}
|
||||
|
||||
// RESET
|
||||
async function reset() {
|
||||
console.log('[Reset] Clearing test bookmarks...');
|
||||
var all = await call('GET', '/api/bookmarks/?limit=100');
|
||||
var tests = all.results.filter(function(b) { return b.testId; });
|
||||
if (tests.length) {
|
||||
console.log('[Reset] Found ' + tests.length + ' to delete');
|
||||
for (var i = 0; i < tests.length; i++) {
|
||||
await del(tests[i].id);
|
||||
}
|
||||
console.log('[Reset] Done');
|
||||
}
|
||||
}
|
||||
|
||||
// TEST 1
|
||||
(function test1() {
|
||||
console.log('\n=== Test 1: API Key Isolation ===');
|
||||
setContext('work', serverUrl, workApiKey, workUser);
|
||||
create('https://t1.example.com', { title: 'T1-Work' }).then(function(b1) {
|
||||
console.log(' Work ID: ' + b1.id);
|
||||
setContext('personal', serverUrl, personalApiKey, personalUser);
|
||||
return create('https://t1.example.com', { title: 'T1-Personal' });
|
||||
}).then(function(b2) {
|
||||
console.log(' Personal ID: ' + b2.id);
|
||||
console.log(' Same? ' + (b1.id === b2.id));
|
||||
if (b1.id === b2.id) {
|
||||
console.log(' [Test 1] ✗ FAIL - API keys do NOT provide isolation');
|
||||
} else {
|
||||
console.log(' [Test 1] ✓ PASS - API keys provide isolation');
|
||||
}
|
||||
});
|
||||
})();
|
||||
|
||||
// TEST 2
|
||||
(function test2() {
|
||||
console.log('\n=== Test 2: Cross-User Isolation ===');
|
||||
setContext('work', serverUrl, workApiKey, workUser);
|
||||
var bm = create('https://t2.example.com', { title: 'T2-Work' });
|
||||
bm.then(function(bm) {
|
||||
console.log(' Work ID: ' + bm.id);
|
||||
setContext('personal', serverUrl, personalApiKey, personalUser);
|
||||
return call('GET', '/api/bookmarks/?limit=100');
|
||||
}).then(function(d) {
|
||||
console.log(' Personal sees: ' + d.count + ' bookmarks');
|
||||
if (d.results && d.results.length) {
|
||||
console.log(' [Test 2] ✗ FAIL - Users see each other');
|
||||
} else {
|
||||
console.log(' [Test 2] ✓ PASS - Proper isolation');
|
||||
}
|
||||
});
|
||||
})();
|
||||
|
||||
// TEST 3
|
||||
(function test3() {
|
||||
console.log('\n=== Test 3: Conflict Resolution ===');
|
||||
setContext('work', serverUrl, workApiKey, workUser);
|
||||
var u = 'https://t3.example.com';
|
||||
var b1 = create(u, { title: 'T3-Work', path: 'Work' });
|
||||
b1.then(function(b1) {
|
||||
console.log(' Work ID: ' + b1.id);
|
||||
setContext('personal', serverUrl, personalApiKey, personalUser);
|
||||
var b2 = create(u, { title: 'T3-Personal', path: 'Personal' });
|
||||
return b2;
|
||||
}).then(function(b2) {
|
||||
console.log(' Personal ID: ' + b2.id);
|
||||
console.log(' Same? ' + (b1.id === b2.id));
|
||||
if (b1.id === b2.id) {
|
||||
console.log(' [Test 3] ✗ FAIL - Server merges by URL');
|
||||
} else {
|
||||
console.log(' [Test 3] ✓ PASS - Server creates separate');
|
||||
}
|
||||
});
|
||||
})();
|
||||
|
||||
// TEST 4
|
||||
(function test4() {
|
||||
console.log('\n=== Test 4: Last-Write-Wins ===');
|
||||
setContext('work', serverUrl, workApiKey, workUser);
|
||||
var u = 'https://t4.example.com';
|
||||
create(u, { title: 'Initial', path: 'Init' }).then(function(bm) {
|
||||
console.log(' Initial ID: ' + bm.id);
|
||||
return call('PUT', '/api/bookmarks/' + bm.id + '/', {
|
||||
title: 'Work Title',
|
||||
description: 'Work Desc',
|
||||
notes: JSON.stringify({ path: 'Work/Dev', userNotes: 'Work' })
|
||||
});
|
||||
}).then(function() {
|
||||
console.log(' Updated: Work Title');
|
||||
setContext('personal', serverUrl, personalApiKey, personalUser);
|
||||
return call('PUT', '/api/bookmarks/' + bm.id + '/', {
|
||||
title: 'Personal Title',
|
||||
description: 'Personal Desc',
|
||||
notes: JSON.stringify({ path: 'Personal/Notes', userNotes: 'Personal' })
|
||||
});
|
||||
}).then(function() {
|
||||
console.log(' Updated: Personal Title');
|
||||
setContext('work', serverUrl, workApiKey, workUser);
|
||||
return call('GET', '/api/bookmarks/' + bm.id + '/');
|
||||
}).then(function(f) {
|
||||
console.log('\n Final:');
|
||||
console.log(' Title: ' + f.title);
|
||||
console.log(' Path: ' + JSON.parse(f.notes).path);
|
||||
if (f.title === 'Personal Title') {
|
||||
console.log(' [Test 4] ✓ PASS - Last-write-wins (Personal)');
|
||||
} else {
|
||||
console.log(' [Test 4] ✓ PASS - Last-write-wins (Work)');
|
||||
}
|
||||
});
|
||||
})();
|
||||
|
||||
// TEST 5
|
||||
(function test5() {
|
||||
console.log('\n=== Test 5: Delete Propagation ===');
|
||||
setContext('work', serverUrl, workApiKey, workUser);
|
||||
var u = 'https://t5.example.com';
|
||||
var b1 = create(u, { title: 'T5-Work', path: 'Work' });
|
||||
b1.then(function(b1) {
|
||||
setContext('personal', serverUrl, personalApiKey, personalUser);
|
||||
var b2 = create(u, { title: 'T5-Personal', path: 'Personal' });
|
||||
return b2;
|
||||
}).then(function(b2) {
|
||||
console.log(' Work ID: ' + b1.id);
|
||||
console.log(' Personal ID: ' + b2.id);
|
||||
console.log(' Same? ' + (b1.id === b2.id));
|
||||
setContext('work', serverUrl, workApiKey, workUser);
|
||||
return call('DELETE', '/api/bookmarks/' + b1.id + '/');
|
||||
}).then(function() {
|
||||
console.log(' Deleted via Work');
|
||||
setContext('personal', serverUrl, personalApiKey, personalUser);
|
||||
return call('GET', '/api/bookmarks/?limit=100&url=' + u);
|
||||
}).then(function(d) {
|
||||
if (d.count === 0) {
|
||||
console.log(' [Test 5] ✗ FAIL - Delete propagated');
|
||||
} else {
|
||||
console.log(' [Test 5] ✓ PASS - Delete isolated');
|
||||
}
|
||||
});
|
||||
})();
|
||||
|
||||
// TEST 6
|
||||
(function test6() {
|
||||
console.log('\n=== Test 6: Bundle Filtering ===');
|
||||
setContext('work', serverUrl, workApiKey, workUser);
|
||||
var u1 = 'https://b6-1.example.com';
|
||||
var u2 = 'https://b6-2.example.com';
|
||||
create(u1, { title: 'B6-W1' }).then(function() {
|
||||
return create(u2, { title: 'B6-W2' });
|
||||
}).then(function() {
|
||||
console.log(' Created 2 bookmarks');
|
||||
setContext('work', serverUrl, workApiKey, workUser);
|
||||
return call('GET', '/api/bookmarks/?all=work&limit=100');
|
||||
}).then(function(d) {
|
||||
console.log(' Work bundle: ' + (d.count || d.results?.length || 0) + ' bookmarks');
|
||||
setContext('personal', serverUrl, personalApiKey, personalUser);
|
||||
return call('GET', '/api/bookmarks/?all=personal&limit=100');
|
||||
}).then(function(d) {
|
||||
console.log(' Personal bundle: ' + (d.count || d.results?.length || 0) + ' bookmarks');
|
||||
console.log(' [Test 6] ✓ PASS - Bundle filtering works');
|
||||
});
|
||||
})();
|
||||
|
||||
// SUMMARY
|
||||
(function summary() {
|
||||
console.log('\n' + '='.repeat(60));
|
||||
console.log(' Test Suite Complete');
|
||||
console.log('='.repeat(60));
|
||||
})();
|
||||
|
||||
// RESET FUNCTION
|
||||
window.LinkdingSyncTests = {
|
||||
reset: reset,
|
||||
call: call,
|
||||
create: create,
|
||||
del: del,
|
||||
list: list,
|
||||
setContext: setContext
|
||||
};
|
||||
|
||||
})(window);
|
||||
|
||||
console.log('');
|
||||
console.log('LinkdingSync Console Test Runner loaded');
|
||||
console.log('');
|
||||
console.log('Tests are running automatically...');
|
||||
console.log('Use LinkdingSyncTests.reset() to clean up test bookmarks');
|
||||
194
Linkding Browser Extension/LinkdingSync/tests/final-test.js
Normal file
194
Linkding Browser Extension/LinkdingSync/tests/final-test.js
Normal file
@@ -0,0 +1,194 @@
|
||||
/*
|
||||
* LinkdingSync Final Test Runner
|
||||
* Firefox Console Compatible
|
||||
*/
|
||||
|
||||
(function(w, eval) {
|
||||
'use strict';
|
||||
var window = w || window;
|
||||
var E = eval;
|
||||
|
||||
// === CONFIG ===
|
||||
var SERVER_URL = 'https://links.blabber1565.com';
|
||||
var WORK_KEY = '4108e3aff26fb82bf074f5d4dfa4757763520b06';
|
||||
var PERSONAL_KEY = '9b80accd3b9b4b91c2a7adc3dcf41621b025329a';
|
||||
var WORK_USER = 'linkdingsync_tester';
|
||||
var PERSONAL_USER = 'linkdingsync_tester_2';
|
||||
|
||||
// === HELPERS ===
|
||||
var STATE = { URL: '', KEY: '', USER: null };
|
||||
|
||||
// Safe URL parsing - handles console-modified strings
|
||||
function parseUrl(str) {
|
||||
try {
|
||||
return new URL(str);
|
||||
} catch(e) {
|
||||
console.log(' [WARN] Invalid URL: ' + str);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function API(method, endpoint, data) {
|
||||
var url = parseUrl(STATE.URL + endpoint);
|
||||
if (!url) throw new Error('Invalid base URL');
|
||||
var r = E(function(res) {
|
||||
if (!res.ok && res.status === 404) return { error: '404', status: res.status };
|
||||
if (!res.ok) throw new Error(res.status + ': ' + res.statusText);
|
||||
return res.json();
|
||||
})(url, { method: method, headers: { 'Authorization': 'Token ' + STATE.KEY, 'Content-Type': 'application/json' }, body: data ? JSON.stringify(data) : null });
|
||||
return r;
|
||||
}
|
||||
|
||||
// === TESTS ===
|
||||
var RESULTS = [];
|
||||
|
||||
function TEST(name, fn) {
|
||||
console.log(''); console.log('=== ' + name + ' ===');
|
||||
var promise = E(function() { return fn(); });
|
||||
promise.then(function(r) {
|
||||
console.log(' [PASS/FAIL/' + (r.pass === null ? 'WARN' : 'PASS') + '] ' + r.reason);
|
||||
RESULTS.push(r);
|
||||
return r;
|
||||
}).catch(function(e) {
|
||||
console.log(' [ERROR] ' + e.message);
|
||||
RESULTS.push({ pass: false, reason: 'Error: ' + e.message });
|
||||
return { pass: false, reason: 'Error: ' + e.message };
|
||||
});
|
||||
return promise;
|
||||
}
|
||||
|
||||
// === MAIN ===
|
||||
TEST('API Key Isolation', function() {
|
||||
STATE.URL = SERVER_URL; STATE.KEY = WORK_KEY; STATE.USER = WORK_USER;
|
||||
return API('POST', '/api/bookmarks/', { url: 'https://t1.example.com', title: 'T1-Work', description: 'Test', notes: JSON.stringify({ testId: true }) })
|
||||
.then(function(b1) {
|
||||
STATE.KEY = PERSONAL_KEY; STATE.USER = PERSONAL_USER;
|
||||
return API('POST', '/api/bookmarks/', { url: 'https://t1.example.com', title: 'T1-Personal', description: 'Test', notes: JSON.stringify({ testId: true }) });
|
||||
})
|
||||
.then(function(b2) {
|
||||
console.log(' IDs: ' + b1.id + ' vs ' + b2.id + ' Same? ' + (b1.id === b2.id));
|
||||
if (b1.id === b2.id) return { pass: false, reason: 'API keys do NOT provide isolation' };
|
||||
return { pass: true, reason: 'API keys provide isolation' };
|
||||
});
|
||||
}).then(function(r1) {
|
||||
console.log(''); console.log('=== Cross-User Visibility ===');
|
||||
STATE.KEY = PERSONAL_KEY; STATE.USER = PERSONAL_USER;
|
||||
return API('GET', '/api/bookmarks/?limit=100').then(function(data) {
|
||||
var myTests = data.results ? data.results.filter(function(b) { return b.testId; }) : [];
|
||||
console.log(' Personal sees ' + (data.count || data.results.length) + ' bookmarks');
|
||||
console.log(' My test bookmarks: ' + myTests.length);
|
||||
if (myTests.length === 1 && myTests[0].url === 'https://t1.example.com') {
|
||||
console.log(' [PASS] Personal only sees my test bookmark');
|
||||
return { pass: true, reason: 'Proper user isolation' };
|
||||
}
|
||||
console.log(' [WARN] Personal sees ' + myTests.length + ' test bookmarks');
|
||||
return { pass: null, reason: 'Mixed results - check if sharing enabled' };
|
||||
});
|
||||
}).then(function(r2) {
|
||||
console.log(''); console.log('=== Conflict Resolution ===');
|
||||
STATE.KEY = WORK_KEY; STATE.USER = WORK_USER;
|
||||
return API('POST', '/api/bookmarks/', { url: 'https://t3.example.com', title: 'T3-Work', description: 'Test', notes: JSON.stringify({ testId: true, path: 'Work' }) })
|
||||
.then(function(b1) {
|
||||
STATE.KEY = PERSONAL_KEY; STATE.USER = PERSONAL_USER;
|
||||
return API('POST', '/api/bookmarks/', { url: 'https://t3.example.com', title: 'T3-Personal', description: 'Test', notes: JSON.stringify({ testId: true, path: 'Personal' }) });
|
||||
})
|
||||
.then(function(b2) {
|
||||
console.log(' IDs: ' + b1.id + ' vs ' + b2.id + ' Same? ' + (b1.id === b2.id));
|
||||
if (b1.id === b2.id) return { pass: false, reason: 'Server merges by URL' };
|
||||
return { pass: true, reason: 'Server creates separate bookmarks' };
|
||||
});
|
||||
}).then(function(r3) {
|
||||
console.log(''); console.log('=== Field Update Behavior ===');
|
||||
STATE.KEY = WORK_KEY; STATE.USER = WORK_USER;
|
||||
return API('GET', '/api/bookmarks/?limit=1').then(function(data) {
|
||||
var bm = data.results ? data.results[0] : null;
|
||||
if (!bm) return API('POST', '/api/bookmarks/', { url: 'https://t4.example.com', title: 'Initial', description: 'Test', notes: JSON.stringify({ testId: true }) });
|
||||
return API('PUT', '/api/bookmarks/' + bm.id + '/', { title: 'Work Title', description: 'Work', notes: JSON.stringify({ testId: true, path: 'Work' }) });
|
||||
}).then(function(resp) {
|
||||
console.log(' Update response: ' + (resp.error ? resp.error : 'OK'));
|
||||
return API('GET', '/api/bookmarks/' + (resp.url || (resp.id ? '/api/bookmarks/' + resp.id + '/' : '')));
|
||||
}).catch(function() {
|
||||
return API('POST', '/api/bookmarks/', { url: 'https://t4-new.example.com', title: 'Initial', description: 'Test', notes: JSON.stringify({ testId: true }) });
|
||||
}).then(function(f) {
|
||||
console.log(' Final title: ' + f.title);
|
||||
if (f.title === 'Work Title') return { pass: true, reason: 'Title was updated' };
|
||||
if (f.title === 'Initial') return { pass: true, reason: 'Title NOT updated (notes only)' };
|
||||
return { pass: null, reason: 'Unknown title: ' + f.title };
|
||||
});
|
||||
}).then(function(r4) {
|
||||
console.log(''); console.log('=== Delete Behavior ===');
|
||||
STATE.KEY = WORK_KEY; STATE.USER = WORK_USER;
|
||||
return API('POST', '/api/bookmarks/', { url: 'https://t5.example.com', title: 'T5-Work', description: 'Test', notes: JSON.stringify({ testId: true }) })
|
||||
.then(function(b1) {
|
||||
STATE.KEY = PERSONAL_KEY; STATE.USER = PERSONAL_USER;
|
||||
return API('POST', '/api/bookmarks/', { url: 'https://t5.example.com', title: 'T5-Personal', description: 'Test', notes: JSON.stringify({ testId: true }) });
|
||||
})
|
||||
.then(function(b2) {
|
||||
console.log(' IDs: ' + b1.id + ' vs ' + b2.id + ' Same? ' + (b1.id === b2.id));
|
||||
STATE.KEY = WORK_KEY; STATE.USER = WORK_USER;
|
||||
return API('DELETE', '/api/bookmarks/' + b1.id + '/');
|
||||
})
|
||||
.then(function() {
|
||||
STATE.KEY = PERSONAL_KEY; STATE.USER = PERSONAL_USER;
|
||||
return API('GET', '/api/bookmarks/?limit=100&url=https://t5.example.com');
|
||||
})
|
||||
.then(function(data) {
|
||||
var count = data.count || data.results ? data.results.length : 0;
|
||||
console.log(' Personal sees ' + count + ' with that URL');
|
||||
if (count === 0) return { pass: false, reason: 'Delete propagated' };
|
||||
return { pass: true, reason: 'Delete isolated' };
|
||||
});
|
||||
}).then(function(r5) {
|
||||
console.log(''); console.log('=== Bundle Filtering ===');
|
||||
STATE.KEY = WORK_KEY; STATE.USER = WORK_USER;
|
||||
return API('POST', '/api/bookmarks/', { url: 'https://b6-1.example.com', title: 'B6-W1', description: 'Test', notes: JSON.stringify({ testId: true }) })
|
||||
.then(function() {
|
||||
return API('POST', '/api/bookmarks/', { url: 'https://b6-2.example.com', title: 'B6-W2', description: 'Test', notes: JSON.stringify({ testId: true }) });
|
||||
})
|
||||
.then(function() {
|
||||
console.log(' Created 2 work bookmarks');
|
||||
STATE.KEY = WORK_KEY; STATE.USER = WORK_USER;
|
||||
return API('GET', '/api/bookmarks/?all=work&limit=100').then(function(wd) {
|
||||
var wc = wd.count || wd.results ? wd.results.length : 0;
|
||||
console.log(' Work bundle: ' + wc + ' bookmarks');
|
||||
STATE.KEY = PERSONAL_KEY; STATE.USER = PERSONAL_USER;
|
||||
return API('GET', '/api/bookmarks/?all=personal&limit=100').then(function(pd) {
|
||||
var pc = pd.count || pd.results ? pd.results.length : 0;
|
||||
console.log(' Personal bundle: ' + pc + ' bookmarks');
|
||||
console.log(' [PASS] Bundle filtering works');
|
||||
return { pass: true, reason: 'Bundle filtering works', work: wc, personal: pc };
|
||||
});
|
||||
});
|
||||
});
|
||||
}).then(function(r6) {
|
||||
// SUMMARY
|
||||
console.log(''); console.log('='.repeat(60)); console.log(' Summary'); console.log('='.repeat(60));
|
||||
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;
|
||||
console.log(' Total: ' + RESULTS.length); console.log(' Passed: ' + passed); console.log(' Failed: ' + failed); console.log(' Warning: ' + warned);
|
||||
console.log('='.repeat(60));
|
||||
console.log(''); console.log('LinkdingSyncTests available:'); console.log(' cleanup()'); console.log(' listAll()'); console.log(''); console.log('Done!');
|
||||
return RESULTS;
|
||||
}).catch(function(e) {
|
||||
console.log(''); console.log('Error:', e.message); console.log(''); console.log('Use LinkdingSyncTests.cleanup()');
|
||||
return RESULTS;
|
||||
});
|
||||
|
||||
// === CLEANUP ===
|
||||
(function cleanup() {
|
||||
API('GET', '/api/bookmarks/?limit=100').then(function(data) {
|
||||
var tests = data.results ? data.results.filter(function(b) { return b.testId; }) : [];
|
||||
console.log(''); console.log('[Cleanup] ' + tests.length + ' test bookmarks');
|
||||
if (tests.length) {
|
||||
Promise.all(tests.map(function(t) { return API('DELETE', '/api/bookmarks/' + t.id + '/'); })).then(function() { console.log('[Cleanup] Done'); });
|
||||
}
|
||||
});
|
||||
})();
|
||||
|
||||
// === EXPORT ===
|
||||
window.LinkdingSyncTests = { cleanup: (function() { API('GET', '/api/bookmarks/?limit=100').then(function(data) { var t = data.results ? data.results.filter(function(b) { return b.testId; }) : []; console.log('[Cleanup] ' + t.length + ' bookmarks'); if (t.length) Promise.all(t.map(function(tt) { return API('DELETE', '/api/bookmarks/' + tt.id + '/'); })).then(function() { console.log('[Cleanup] Done'); }); })(); }()) };
|
||||
|
||||
})(window, window.eval);
|
||||
|
||||
console.log(''); console.log('LinkdingSync Final Test Runner loaded'); console.log(''); console.log('Running tests automatically...');
|
||||
@@ -0,0 +1,796 @@
|
||||
/*
|
||||
* LinkdingSync Test Orchestrator (Inline Version)
|
||||
* Self-contained - paste entire file directly into Firefox DevTools Console
|
||||
*
|
||||
* Instructions:
|
||||
* 1. Open Firefox DevTools → Console tab
|
||||
* 2. Copy ENTIRE file contents
|
||||
* 3. Paste into console (Ctrl+Shift+V)
|
||||
* 4. Wait for "LinkdingSync Test Suite loaded"
|
||||
* 5. Run: runAllTestsWithReset()
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
(function() {
|
||||
// ====================================================================
|
||||
// CONFIGURATION
|
||||
// ====================================================================
|
||||
|
||||
const CONFIG = {
|
||||
serverUrl: 'https://links.blabber1565.com',
|
||||
workApiKey: '4108e3aff26fb82bf074f5d4dfa4757763520b06',
|
||||
workUser: 'linkdingsync_tester',
|
||||
workBundle: 'work',
|
||||
personalApiKey: '9b80accd3b9b4b91c2a7adc3dcf41621b025329a',
|
||||
personalUser: 'linkdingsync_tester_2',
|
||||
personalBundle: 'personal',
|
||||
cleanupAfterTests: true
|
||||
};
|
||||
|
||||
// ====================================================================
|
||||
// SESSION MANAGEMENT
|
||||
// ====================================================================
|
||||
|
||||
const SessionManager = {
|
||||
currentContext: null,
|
||||
|
||||
setContext(serverUrl, apiKey, userId, bundle) {
|
||||
this.currentContext = {
|
||||
serverUrl: serverUrl.endsWith('/') ? serverUrl : serverUrl + '/',
|
||||
apiKey, userId, bundle
|
||||
};
|
||||
return this;
|
||||
},
|
||||
|
||||
getHeaders() {
|
||||
if (!this.currentContext) {
|
||||
throw new Error('No context set. Call setContext() first.');
|
||||
}
|
||||
return {
|
||||
'Authorization': `Token ${this.currentContext.apiKey}`,
|
||||
'Content-Type': 'application/json'
|
||||
};
|
||||
},
|
||||
|
||||
async call(endpoint, method = 'GET', queryParams = {}) {
|
||||
const url = new URL(endpoint, this.currentContext.serverUrl);
|
||||
Object.entries(queryParams).forEach(([key, value]) => {
|
||||
url.searchParams.append(key, value);
|
||||
});
|
||||
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers: this.getHeaders(),
|
||||
body: null
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text().slice(0, 200);
|
||||
throw new Error(`${response.status}: ${response.statusText} - ${text}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
};
|
||||
|
||||
// ====================================================================
|
||||
// HELPERS
|
||||
// ====================================================================
|
||||
|
||||
const Helpers = {
|
||||
generateTestId(prefix = 'test') {
|
||||
return `${prefix}-${Date.now().toString().slice(-4)}-${Math.random().toString(36).substring(2, 4)}`;
|
||||
},
|
||||
|
||||
async createBookmark(url, options = {}) {
|
||||
const testId = this.generateTestId();
|
||||
const baseUrl = new URL(url);
|
||||
baseUrl.hostname = `${testId}.${baseUrl.hostname}`;
|
||||
|
||||
const bookmarkData = {
|
||||
url: baseUrl.href,
|
||||
title: options.title || `Test: ${testId}`,
|
||||
description: options.description || 'Test bookmark',
|
||||
notes: JSON.stringify({
|
||||
path: options.path || `Test/${testId}`,
|
||||
userNotes: options.notes || 'Test bookmark',
|
||||
testId
|
||||
})
|
||||
};
|
||||
|
||||
const response = await SessionManager.call('/api/bookmarks/', 'POST', null, {});
|
||||
console.log(` Created: ID=${response.id}`);
|
||||
return response;
|
||||
},
|
||||
|
||||
async updateBookmark(bookmarkId, data) {
|
||||
const response = await SessionManager.call(`/api/bookmarks/${bookmarkId}/`, 'PUT', null, {});
|
||||
console.log(` Updated: ID=${bookmarkId}`);
|
||||
return response;
|
||||
},
|
||||
|
||||
async deleteBookmark(bookmarkId) {
|
||||
await SessionManager.call(`/api/bookmarks/${bookmarkId}/`, 'DELETE', {});
|
||||
console.log(` Deleted: ID=${bookmarkId}`);
|
||||
return true;
|
||||
},
|
||||
|
||||
async fetchBookmark(id) {
|
||||
return SessionManager.call(`/api/bookmarks/${id}/`);
|
||||
},
|
||||
|
||||
parseNotes(noteString) {
|
||||
if (!noteString) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(noteString);
|
||||
return parsed;
|
||||
} catch {
|
||||
return { userNotes: noteString, version: '1.0', path: '', autoTags: [], bundleTag: null };
|
||||
}
|
||||
},
|
||||
|
||||
async getAllBookmarks() {
|
||||
let bookmarks = [];
|
||||
let offset = 0;
|
||||
const batchSize = 100;
|
||||
|
||||
do {
|
||||
const response = await SessionManager.call('/api/bookmarks/', 'GET', { limit: batchSize, offset });
|
||||
bookmarks.push(...(response.results || []));
|
||||
offset += batchSize;
|
||||
} while (bookmarks.length > offset);
|
||||
|
||||
return bookmarks;
|
||||
},
|
||||
|
||||
async resetBookmarks() {
|
||||
console.log('[Utils] Resetting all bookmarks...');
|
||||
try {
|
||||
const allBookmarks = await this.getAllBookmarks();
|
||||
const testBookmarks = allBookmarks.filter(b => b.testId);
|
||||
|
||||
if (testBookmarks.length > 0) {
|
||||
console.log(`[Utils] Found ${testBookmarks.length} test bookmarks to delete`);
|
||||
|
||||
for (const bm of testBookmarks) {
|
||||
await this.deleteBookmark(bm.id);
|
||||
}
|
||||
|
||||
console.log('[Utils] Reset complete');
|
||||
} else {
|
||||
console.log('[Utils] No test bookmarks found');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Utils] Reset failed:', error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// ====================================================================
|
||||
// FORMATTERS
|
||||
// ====================================================================
|
||||
|
||||
const Formatters = {
|
||||
formatTimestamp(timestamp) {
|
||||
if (!timestamp) return 'Never';
|
||||
return new Date(timestamp).toLocaleString('en-US', {
|
||||
year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit'
|
||||
});
|
||||
},
|
||||
|
||||
consoleHeader(text) {
|
||||
console.log(''.padEnd(60, '='));
|
||||
console.log(` ${text}`.padEnd(60, '='.charCodeAt(0) === text.charCodeAt(0) ? '=' : '-').padEnd(60, '='));
|
||||
console.log(''.padEnd(60, '='));
|
||||
},
|
||||
|
||||
consoleResult(scenario, status, details = '') {
|
||||
const icon = status === 'PASS' ? '✓' : status === 'FAIL' ? '✗' : '⚠';
|
||||
const emoji = status === 'PASS' ? '✅' : status === 'FAIL' ? '❌' : '⚠️';
|
||||
console.log(` [${scenario}] ${icon} ${emoji} ${status}`);
|
||||
if (details) console.log(` ${details}`);
|
||||
}
|
||||
};
|
||||
|
||||
// ====================================================================
|
||||
// TEST MODULE: ISOLATION
|
||||
// ====================================================================
|
||||
|
||||
async function test1_SameUserDifferentKeys() {
|
||||
console.log('\n=== Test 1: Same URL, Different API Keys, Same User ===');
|
||||
console.log('Purpose: Verify if API keys provide isolation within same user');
|
||||
|
||||
try {
|
||||
SessionManager.setContext(CONFIG.serverUrl, CONFIG.workApiKey, CONFIG.workUser, CONFIG.workBundle);
|
||||
|
||||
const bm1 = await Helpers.createBookmark('https://isolation-test.example.com', {
|
||||
title: 'Isolation Test - Work Key'
|
||||
});
|
||||
|
||||
SessionManager.setContext(CONFIG.serverUrl, CONFIG.personalApiKey, CONFIG.personalUser, CONFIG.personalBundle);
|
||||
const bm2 = await Helpers.createBookmark('https://isolation-test.example.com', {
|
||||
title: 'Isolation Test - Personal Key'
|
||||
});
|
||||
|
||||
console.log(` Work bookmark ID: ${bm1.id}`);
|
||||
console.log(` Personal bookmark ID: ${bm2.id}`);
|
||||
|
||||
if (bm1.id === bm2.id) {
|
||||
Formatters.consoleResult('Test 1', 'FAIL', 'Same bookmark ID - API keys do NOT provide isolation');
|
||||
console.log(' → Same user means same bookmarks regardless of API key');
|
||||
return { pass: false, reason: 'API keys do not provide isolation within same user' };
|
||||
} else {
|
||||
Formatters.consoleResult('Test 1', 'PASS', 'Different bookmark IDs - API keys provide isolation');
|
||||
console.log(' → Different API keys create separate bookmarks');
|
||||
return { pass: true, ids: { work: bm1.id, personal: bm2.id } };
|
||||
}
|
||||
} catch (error) {
|
||||
Formatters.consoleResult('Test 1', 'FAIL', error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function test2_DifferentUsers() {
|
||||
console.log('\n=== Test 2: Different Users - Verify Isolation ===');
|
||||
console.log('Purpose: Verify isolation between different users');
|
||||
|
||||
try {
|
||||
const workUrl = 'https://cross-user-isolation.example.com';
|
||||
SessionManager.setContext(CONFIG.serverUrl, CONFIG.workApiKey, CONFIG.workUser, CONFIG.workBundle);
|
||||
|
||||
const workBookmark = await Helpers.createBookmark(workUrl, {
|
||||
title: 'Cross-User Test - Work'
|
||||
});
|
||||
|
||||
console.log(` Bookmark created by work user: ID=${workBookmark.id}`);
|
||||
|
||||
const workFetch = await SessionManager.call(`/api/bookmarks/${workBookmark.id}/`);
|
||||
console.log(` Work user sees bookmark: ${workFetch.title}`);
|
||||
|
||||
SessionManager.setContext(CONFIG.serverUrl, CONFIG.personalApiKey, CONFIG.personalUser, CONFIG.personalBundle);
|
||||
const personalFetch = await SessionManager.call('/api/bookmarks/', 'GET', { limit: 100 });
|
||||
|
||||
console.log(` Personal user sees ${personalFetch.count || personalFetch.results?.length || 0} bookmarks`);
|
||||
|
||||
if (personalFetch.results && personalFetch.results.length > 0) {
|
||||
Formatters.consoleResult('Test 2', 'FAIL', 'Users can see each other\'s bookmarks');
|
||||
console.log(' → Sharing enabled or same underlying user');
|
||||
return { pass: false, reason: 'Users can see each other\'s bookmarks' };
|
||||
} else {
|
||||
Formatters.consoleResult('Test 2', 'PASS', 'Proper user isolation exists');
|
||||
console.log(' → Can use different API keys for isolation');
|
||||
return { pass: true };
|
||||
}
|
||||
} catch (error) {
|
||||
Formatters.consoleResult('Test 2', 'FAIL', error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function runIsolationTests() {
|
||||
console.log('\n' + '='.repeat(60));
|
||||
console.log(' API Key & User Isolation Tests');
|
||||
console.log('='.repeat(60));
|
||||
|
||||
const results = [];
|
||||
|
||||
try {
|
||||
results[0] = await test1_SameUserDifferentKeys();
|
||||
results[1] = await test2_DifferentUsers();
|
||||
} catch (error) {
|
||||
console.error('Test suite error:', error.message);
|
||||
await Helpers.resetBookmarks();
|
||||
}
|
||||
|
||||
console.log('\n' + '='.repeat(60));
|
||||
console.log(' Isolation Tests Complete');
|
||||
console.log('='.repeat(60));
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
// ====================================================================
|
||||
// TEST MODULE: CONFLICTS
|
||||
// ====================================================================
|
||||
|
||||
async function test3_ConflictResolution() {
|
||||
console.log('\n=== Test 3: Conflict Resolution - Different Paths ===');
|
||||
console.log('Purpose: How server handles same URL in different paths with different API keys');
|
||||
|
||||
try {
|
||||
SessionManager.setContext(CONFIG.serverUrl, CONFIG.workApiKey, CONFIG.workUser, CONFIG.workBundle);
|
||||
|
||||
const workUrl = 'https://conflict-resolution.example.com';
|
||||
const workBookmark = await Helpers.createBookmark(workUrl, {
|
||||
title: 'Conflict Resolution Test',
|
||||
path: 'Work/Development',
|
||||
notes: 'Work Development Notes'
|
||||
});
|
||||
|
||||
console.log(` Work bookmark ID: ${workBookmark.id}`);
|
||||
|
||||
SessionManager.setContext(CONFIG.serverUrl, CONFIG.personalApiKey, CONFIG.personalUser, CONFIG.personalBundle);
|
||||
const personalBookmark = await Helpers.createBookmark(workUrl, {
|
||||
title: 'Conflict Resolution Test',
|
||||
path: 'Personal/Notes',
|
||||
notes: 'Personal Notes'
|
||||
});
|
||||
|
||||
console.log(` Personal bookmark ID: ${personalBookmark.id}`);
|
||||
console.log(`\nComparing bookmark IDs: work=${workBookmark.id}, personal=${personalBookmark.id}`);
|
||||
|
||||
if (workBookmark.id === personalBookmark.id) {
|
||||
Formatters.consoleResult('Test 3', 'FAIL', 'Same bookmark ID');
|
||||
console.log(' → Server merges bookmarks by URL');
|
||||
console.log(' → Need path merge strategy');
|
||||
|
||||
const state = await SessionManager.call(`/api/bookmarks/${workBookmark.id}/`);
|
||||
const parsed = Helpers.parseNotes(state.notes);
|
||||
console.log(` → Current path: ${parsed.path}`);
|
||||
console.log(` → Current notes: ${parsed.userNotes}`);
|
||||
|
||||
return { pass: false, sameId: true, path: parsed.path };
|
||||
} else {
|
||||
Formatters.consoleResult('Test 3', 'PASS', 'Different bookmark IDs');
|
||||
console.log(' → Server creates separate bookmarks per API key');
|
||||
console.log(' → Can use different API keys for isolation');
|
||||
|
||||
return { pass: true, sameId: false, workId: workBookmark.id, personalId: personalBookmark.id };
|
||||
}
|
||||
} catch (error) {
|
||||
Formatters.consoleResult('Test 3', 'FAIL', error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function test4_TitleDescriptionConflict() {
|
||||
console.log('\n=== Test 4: Title/Description Conflict ===');
|
||||
console.log('Purpose: How server resolves conflicts for title/description fields');
|
||||
|
||||
try {
|
||||
SessionManager.setContext(CONFIG.serverUrl, CONFIG.workApiKey, CONFIG.workUser, CONFIG.workBundle);
|
||||
|
||||
const testUrl = 'https://title-conflict.example.com';
|
||||
const bookmark = await Helpers.createBookmark(testUrl, {
|
||||
title: 'Initial Title',
|
||||
description: 'Initial Description',
|
||||
path: 'Initial'
|
||||
});
|
||||
|
||||
await Helpers.updateBookmark(bookmark.id, {
|
||||
title: 'Work Title',
|
||||
description: 'Work Description',
|
||||
notes: JSON.stringify({
|
||||
path: 'Work/Dev',
|
||||
userNotes: 'Work notes',
|
||||
autoTags: [{name: 'Work'}]
|
||||
})
|
||||
});
|
||||
|
||||
console.log(' Updated via Work: Work Title');
|
||||
|
||||
SessionManager.setContext(CONFIG.serverUrl, CONFIG.personalApiKey, CONFIG.personalUser, CONFIG.personalBundle);
|
||||
await Helpers.updateBookmark(bookmark.id, {
|
||||
title: 'Personal Title',
|
||||
description: 'Personal Description',
|
||||
notes: JSON.stringify({
|
||||
path: 'Personal/Notes',
|
||||
userNotes: 'Personal notes',
|
||||
autoTags: [{name: 'Personal'}]
|
||||
})
|
||||
});
|
||||
|
||||
console.log(' Updated via Personal: Personal Title');
|
||||
|
||||
const final = await SessionManager.call(`/api/bookmarks/${bookmark.id}/`);
|
||||
const parsed = Helpers.parseNotes(final.notes);
|
||||
|
||||
console.log('\nFinal state:');
|
||||
console.log(` Title: ${final.title}`);
|
||||
console.log(` Description: ${final.description}`);
|
||||
console.log(` Path: ${parsed.path}`);
|
||||
console.log(` User notes: ${parsed.userNotes}`);
|
||||
|
||||
if (final.title === 'Personal Title') {
|
||||
Formatters.consoleResult('Test 4', 'PASS', 'Last-write-wins (Personal took precedence)');
|
||||
return { pass: true, strategy: 'last-write-wins', winner: 'personal' };
|
||||
} else if (final.title === 'Work Title') {
|
||||
Formatters.consoleResult('Test 4', 'PASS', 'Last-write-wins (Work took precedence)');
|
||||
return { pass: true, strategy: 'last-write-wins', winner: 'work' };
|
||||
} else if (final.title.includes('Work') && final.title.includes('Personal')) {
|
||||
Formatters.consoleResult('Test 4', 'PASS', 'Merged title');
|
||||
return { pass: true, strategy: 'merge', winner: 'merged' };
|
||||
} else {
|
||||
Formatters.consoleResult('Test 4', 'WARN', 'Unexpected title value');
|
||||
return { pass: null, strategy: 'unknown', winner: final.title };
|
||||
}
|
||||
} catch (error) {
|
||||
Formatters.consoleResult('Test 4', 'FAIL', error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function runConflictTests() {
|
||||
console.log('\n' + '='.repeat(60));
|
||||
console.log(' Conflict Resolution Tests');
|
||||
console.log('='.repeat(60));
|
||||
|
||||
const results = [];
|
||||
|
||||
try {
|
||||
results[0] = await test3_ConflictResolution();
|
||||
results[1] = await test4_TitleDescriptionConflict();
|
||||
} catch (error) {
|
||||
console.error('Test suite error:', error.message);
|
||||
await Helpers.resetBookmarks();
|
||||
}
|
||||
|
||||
console.log('\n' + '='.repeat(60));
|
||||
console.log(' Conflict Tests Complete');
|
||||
console.log('='.repeat(60));
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
// ====================================================================
|
||||
// TEST MODULE: DELETION
|
||||
// ====================================================================
|
||||
|
||||
async function test5_DeletePropagation() {
|
||||
console.log('\n=== Test 5: Delete Propagation ===');
|
||||
console.log('Purpose: Confirm if deleting affects all API keys');
|
||||
|
||||
try {
|
||||
SessionManager.setContext(CONFIG.serverUrl, CONFIG.workApiKey, CONFIG.workUser, CONFIG.workBundle);
|
||||
|
||||
const testUrl = 'https://delete-propagation.example.com';
|
||||
const workBookmark = await Helpers.createBookmark(testUrl, {
|
||||
title: 'Delete Prop Test',
|
||||
path: 'Work/Dev'
|
||||
});
|
||||
|
||||
console.log(` Work bookmark ID: ${workBookmark.id}`);
|
||||
|
||||
SessionManager.setContext(CONFIG.serverUrl, CONFIG.personalApiKey, CONFIG.personalUser, CONFIG.personalBundle);
|
||||
const personalBookmark = await Helpers.createBookmark(testUrl, {
|
||||
title: 'Delete Prop Test',
|
||||
path: 'Personal/Notes'
|
||||
});
|
||||
|
||||
console.log(` Personal bookmark ID: ${personalBookmark.id}`);
|
||||
|
||||
console.log(` Same bookmark? ${workBookmark.id === personalBookmark.id}`);
|
||||
|
||||
await SessionManager.call(`/api/bookmarks/${workBookmark.id}/`, 'DELETE', {});
|
||||
console.log(' Deleted via Work key');
|
||||
|
||||
SessionManager.setContext(CONFIG.serverUrl, CONFIG.personalApiKey, CONFIG.personalUser, CONFIG.personalBundle);
|
||||
const personalList = await SessionManager.call('/api/bookmarks/', 'GET', { limit: 100 });
|
||||
const deleted = personalList.results?.find(b => b.url === testUrl);
|
||||
|
||||
if (!deleted) {
|
||||
Formatters.consoleResult('Test 5', 'FAIL', 'Delete propagated (same bookmark)');
|
||||
console.log(' → Deleting via one key deletes all');
|
||||
|
||||
return { pass: false, propagated: true, sameBookmark: true };
|
||||
} else {
|
||||
Formatters.consoleResult('Test 5', 'PASS', 'Delete did not propagate (separate bookmarks)');
|
||||
console.log(' → Each bookmark exists independently');
|
||||
console.log(' → Can delete via specific API key');
|
||||
|
||||
return { pass: true, propagated: false, sameBookmark: false };
|
||||
}
|
||||
} catch (error) {
|
||||
Formatters.consoleResult('Test 5', 'FAIL', error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function test6_DeleteSameUserDifferentKeys() {
|
||||
console.log('\n=== Test 6: Delete - Same User, Different Keys ===');
|
||||
console.log('Purpose: Verify delete behavior when same user, different API keys');
|
||||
|
||||
try {
|
||||
SessionManager.setContext(CONFIG.serverUrl, CONFIG.workApiKey, CONFIG.workUser, CONFIG.workBundle);
|
||||
|
||||
const testUrl = 'https://delete-same-user.example.com';
|
||||
const bm1 = await Helpers.createBookmark(testUrl, {
|
||||
title: 'Same User Delete Test - Key 1'
|
||||
});
|
||||
|
||||
console.log(` Created with Key 1: ID=${bm1.id}`);
|
||||
|
||||
SessionManager.setContext(CONFIG.serverUrl, CONFIG.personalApiKey, CONFIG.personalUser, CONFIG.personalBundle);
|
||||
const bm2 = await Helpers.createBookmark(testUrl, {
|
||||
title: 'Same User Delete Test - Key 2'
|
||||
});
|
||||
|
||||
console.log(` Created with Key 2: ID=${bm2.id}`);
|
||||
|
||||
if (bm1.id === bm2.id) {
|
||||
Formatters.consoleResult('Test 6', 'WARN', 'Same bookmark - delete propagates');
|
||||
console.log(' → Same user means same bookmark');
|
||||
console.log(' → Deleting via either key removes it');
|
||||
|
||||
await Helpers.deleteBookmark(bm1.id);
|
||||
|
||||
const workList = await SessionManager.call('/api/bookmarks/', 'GET', { limit: 100 });
|
||||
const exists = workList.results?.find(b => b.url === testUrl);
|
||||
|
||||
if (!exists) {
|
||||
console.log(' → Verified: bookmark deleted via both keys');
|
||||
}
|
||||
|
||||
return { pass: true, same: true, propagates: true };
|
||||
} else {
|
||||
Formatters.consoleResult('Test 6', 'PASS', 'Different bookmarks - delete is isolated');
|
||||
console.log(' → Different API keys create different bookmarks');
|
||||
console.log(' → Can delete independently');
|
||||
|
||||
await Helpers.deleteBookmark(bm1.id);
|
||||
|
||||
const workList = await SessionManager.call('/api/bookmarks/', 'GET', { limit: 100 });
|
||||
const workGone = !workList.results?.find(b => b.url === testUrl);
|
||||
|
||||
SessionManager.setContext(CONFIG.serverUrl, CONFIG.personalApiKey, CONFIG.personalUser, CONFIG.personalBundle);
|
||||
const personalList = await SessionManager.call('/api/bookmarks/', 'GET', { limit: 100 });
|
||||
const personalExists = personalList.results?.find(b => b.url === testUrl);
|
||||
|
||||
if (workGone && personalExists) {
|
||||
console.log(' → Verified: work deleted, personal still exists');
|
||||
}
|
||||
|
||||
return { pass: true, same: false, propagates: false };
|
||||
}
|
||||
} catch (error) {
|
||||
Formatters.consoleResult('Test 6', 'FAIL', error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function runDeletionTests() {
|
||||
console.log('\n' + '='.repeat(60));
|
||||
console.log(' Deletion Tests');
|
||||
console.log('='.repeat(60));
|
||||
|
||||
const results = [];
|
||||
|
||||
try {
|
||||
results[0] = await test5_DeletePropagation();
|
||||
results[1] = await test6_DeleteSameUserDifferentKeys();
|
||||
} catch (error) {
|
||||
console.error('Test suite error:', error.message);
|
||||
await Helpers.resetBookmarks();
|
||||
}
|
||||
|
||||
console.log('\n' + '='.repeat(60));
|
||||
console.log(' Deletion Tests Complete');
|
||||
console.log('='.repeat(60));
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
// ====================================================================
|
||||
// TEST MODULE: BUNDLES
|
||||
// ====================================================================
|
||||
|
||||
async function test7_BundleTagFiltering() {
|
||||
console.log('\n=== Test 7: Bundle Tag Filtering ===');
|
||||
console.log('Purpose: Verify if bundle tags filter bookmarks properly');
|
||||
|
||||
try {
|
||||
SessionManager.setContext(CONFIG.serverUrl, CONFIG.workApiKey, CONFIG.workUser, CONFIG.workBundle);
|
||||
|
||||
const testUrl = 'https://bundle-filter.example.com';
|
||||
const bookmark = await Helpers.createBookmark(testUrl, {
|
||||
title: 'Bundle Filter Test',
|
||||
path: 'Test/Path',
|
||||
notes: 'Work bundle tag'
|
||||
});
|
||||
|
||||
console.log(` Bookmark created: ID=${bookmark.id}`);
|
||||
|
||||
const workBundleResponse = await SessionManager.call('/api/bookmarks/', 'GET', { limit: 100 });
|
||||
|
||||
console.log(` Work bundle has ${workBundleResponse.results?.filter(b => b.testId).length || 0} test bookmarks`);
|
||||
|
||||
if (workBundleResponse.results?.filter(b => b.testId).length > 0) {
|
||||
Formatters.consoleResult('Test 7', 'PASS', 'Bundle tags filter bookmarks');
|
||||
console.log(' → Work bundle has work-tagged bookmarks');
|
||||
|
||||
return { pass: true, filtered: true };
|
||||
} else {
|
||||
Formatters.consoleResult('Test 7', 'WARN', 'Bundle filtering unclear');
|
||||
console.log(' → May need to use tags for filtering');
|
||||
|
||||
return { pass: null, filtered: null };
|
||||
}
|
||||
} catch (error) {
|
||||
Formatters.consoleResult('Test 7', 'FAIL', error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function test8_BundleSpecificSync() {
|
||||
console.log('\n=== Test 8: Bundle-Specific Sync ===');
|
||||
console.log('Purpose: Verify sync behavior with different bundles');
|
||||
|
||||
try {
|
||||
SessionManager.setContext(CONFIG.serverUrl, CONFIG.workApiKey, CONFIG.workUser, CONFIG.workBundle);
|
||||
|
||||
const workUrl = 'https://bundle-specific-work.example.com';
|
||||
const workBookmark = await Helpers.createBookmark(workUrl, {
|
||||
title: 'Bundle Specific - Work',
|
||||
path: 'Work/Bundle',
|
||||
notes: 'Work bundle content'
|
||||
});
|
||||
|
||||
console.log(` Work bookmark: ID=${workBookmark.id}`);
|
||||
|
||||
SessionManager.setContext(CONFIG.serverUrl, CONFIG.personalApiKey, CONFIG.personalUser, CONFIG.personalBundle);
|
||||
|
||||
const personalUrl = 'https://bundle-specific-personal.example.com';
|
||||
const personalBookmark = await Helpers.createBookmark(personalUrl, {
|
||||
title: 'Bundle Specific - Personal',
|
||||
path: 'Personal/Bundle',
|
||||
notes: 'Personal bundle content'
|
||||
});
|
||||
|
||||
console.log(` Personal bookmark: ID=${personalBookmark.id}`);
|
||||
|
||||
const workList = await SessionManager.call('/api/bookmarks/', 'GET', { limit: 100 });
|
||||
const personalList = await SessionManager.call('/api/bookmarks/', 'GET', { limit: 100 });
|
||||
|
||||
const workCount = workList.results?.filter(b => b.testId).length || 0;
|
||||
const personalCount = personalList.results?.filter(b => b.testId).length || 0;
|
||||
|
||||
console.log(` Work has ${workCount} test bookmarks`);
|
||||
console.log(` Personal has ${personalCount} test bookmarks`);
|
||||
|
||||
if (workCount === 1 && personalCount === 1) {
|
||||
Formatters.consoleResult('Test 8', 'PASS', 'Bundles provide logical separation');
|
||||
console.log(' → Can maintain separate sync for each bundle');
|
||||
|
||||
return { pass: true, workCount, personalCount };
|
||||
} else {
|
||||
Formatters.consoleResult('Test 8', 'WARN', 'Bundle counts differ from expected');
|
||||
console.log(' → May need to use tags for proper isolation');
|
||||
|
||||
return { pass: null, workCount, personalCount };
|
||||
}
|
||||
} catch (error) {
|
||||
Formatters.consoleResult('Test 8', 'FAIL', error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function runBundleTests() {
|
||||
console.log('\n' + '='.repeat(60));
|
||||
console.log(' Bundle Tests');
|
||||
console.log('='.repeat(60));
|
||||
|
||||
const results = [];
|
||||
|
||||
try {
|
||||
results[0] = await test7_BundleTagFiltering();
|
||||
results[1] = await test8_BundleSpecificSync();
|
||||
} catch (error) {
|
||||
console.error('Test suite error:', error.message);
|
||||
await Helpers.resetBookmarks();
|
||||
}
|
||||
|
||||
console.log('\n' + '='.repeat(60));
|
||||
console.log(' Bundle Tests Complete');
|
||||
console.log('='.repeat(60));
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
// ====================================================================
|
||||
// MAIN ORCHESTRATOR
|
||||
// ====================================================================
|
||||
|
||||
async function runAllTests() {
|
||||
console.log(''.padEnd(60, '='));
|
||||
console.log(' LINKDINGSYNC - Complete Test Suite');
|
||||
console.log('='.repeat(60));
|
||||
console.log('');
|
||||
|
||||
const results = [];
|
||||
|
||||
try {
|
||||
results = await runIsolationTests();
|
||||
results = results.concat(await runConflictTests());
|
||||
results = results.concat(await runDeletionTests());
|
||||
results = results.concat(await runBundleTests());
|
||||
} catch (error) {
|
||||
console.error('Test suite error:', error.message);
|
||||
console.log('');
|
||||
console.log('[Orchestrator] Attempting cleanup...');
|
||||
try {
|
||||
await Helpers.resetBookmarks();
|
||||
} catch (cleanupError) {
|
||||
console.error('Cleanup failed:', cleanupError.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Summary
|
||||
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 || r.pass === undefined).length;
|
||||
|
||||
Formatters.consoleHeader('Test Summary');
|
||||
console.log(` Total: ${results.length}`);
|
||||
console.log(` Passed: ${passed}`);
|
||||
console.log(` Failed: ${failed}`);
|
||||
console.log(` Warning: ${warnings}`);
|
||||
console.log(''.padEnd(60, '='));
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
async function runAllTestsWithReset() {
|
||||
console.log(''.padEnd(60, '='));
|
||||
console.log(' LINKDINGSYNC - Test Suite with Reset');
|
||||
console.log('='.repeat(60));
|
||||
|
||||
try {
|
||||
await Helpers.resetBookmarks();
|
||||
console.log('[Reset] Test bookmarks cleaned');
|
||||
} catch (error) {
|
||||
console.error('[Reset] Failed:', error.message);
|
||||
}
|
||||
|
||||
return await runAllTests();
|
||||
}
|
||||
|
||||
async function reset() {
|
||||
console.log('[Orchestrator] Resetting test bookmarks...');
|
||||
await Helpers.resetBookmarks();
|
||||
console.log('[Orchestrator] Reset complete');
|
||||
}
|
||||
|
||||
// ====================================================================
|
||||
// EXPORT TO WINDOW
|
||||
// ====================================================================
|
||||
|
||||
window.LinkdingSyncTests = {
|
||||
CONFIG,
|
||||
SessionManager,
|
||||
Helpers,
|
||||
Formatters,
|
||||
runAllTests,
|
||||
runAllTestsWithReset,
|
||||
reset,
|
||||
runIsolationTests,
|
||||
runConflictTests,
|
||||
runDeletionTests,
|
||||
runBundleTests,
|
||||
test1_SameUserDifferentKeys,
|
||||
test2_DifferentUsers,
|
||||
test3_ConflictResolution,
|
||||
test4_TitleDescriptionConflict,
|
||||
test5_DeletePropagation,
|
||||
test6_DeleteSameUserDifferentKeys,
|
||||
test7_BundleTagFiltering,
|
||||
test8_BundleSpecificSync
|
||||
};
|
||||
|
||||
console.log('');
|
||||
console.log('LinkdingSync Test Suite loaded successfully');
|
||||
console.log('');
|
||||
console.log('Commands:');
|
||||
console.log(' runAllTests() - Run all tests');
|
||||
console.log(' runAllTestsWithReset() - Run with cleanup first');
|
||||
console.log(' reset() - Clean up test bookmarks');
|
||||
console.log(' runModule("name") - Run specific module');
|
||||
console.log('');
|
||||
console.log('Test modules:');
|
||||
console.log(' isolation - API key & user isolation (Tests 1-2)');
|
||||
console.log(' conflicts - Conflict resolution (Tests 3-4)');
|
||||
console.log(' deletion - Delete propagation (Tests 5-6)');
|
||||
console.log(' bundles - Bundle filtering (Tests 7-8)');
|
||||
console.log('');
|
||||
})();
|
||||
183
Linkding Browser Extension/LinkdingSync/tests/orchestrator.js
Normal file
183
Linkding Browser Extension/LinkdingSync/tests/orchestrator.js
Normal file
@@ -0,0 +1,183 @@
|
||||
/*
|
||||
* LinkdingSync Test Orchestrator
|
||||
* Main entry point for running test modules
|
||||
*
|
||||
* Usage in Firefox DevTools Console:
|
||||
* 1. Load this file and utils/test modules
|
||||
* 2. Fill in CONFIG in tests/utils.js
|
||||
* 3. Run runAllTests() or specific test modules
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
// ====================================================================
|
||||
// ORCHESTRATOR - Main Test Runner
|
||||
// ====================================================================
|
||||
|
||||
const LinkdingSyncTests = window.LinkdingSyncTests || {};
|
||||
|
||||
// Test modules registry
|
||||
const TestModules = {
|
||||
isolation: null,
|
||||
conflicts: null,
|
||||
deletion: null,
|
||||
bundles: null
|
||||
};
|
||||
|
||||
// Load test modules
|
||||
function loadModules() {
|
||||
// Load isolation tests
|
||||
if (typeof TestIsolation !== 'undefined') {
|
||||
TestModules.isolation = TestIsolation;
|
||||
}
|
||||
|
||||
// Load conflicts tests
|
||||
if (typeof TestConflicts !== 'undefined') {
|
||||
TestModules.conflicts = TestConflicts;
|
||||
}
|
||||
|
||||
// Load deletion tests
|
||||
if (typeof TestDeletion !== 'undefined') {
|
||||
TestModules.deletion = TestDeletion;
|
||||
}
|
||||
|
||||
// Load bundles tests
|
||||
if (typeof TestBundles !== 'undefined') {
|
||||
TestModules.bundles = TestBundles;
|
||||
}
|
||||
|
||||
console.log('[Orchestrator] Modules loaded:', Object.keys(TestModules).filter(k => TestModules[k]).join(', '));
|
||||
}
|
||||
|
||||
// Run all tests
|
||||
async function runAllTests() {
|
||||
console.log(''.padEnd(60, '='));
|
||||
console.log(' LINKDINGSYNC - Complete Test Suite');
|
||||
console.log('='.repeat(60));
|
||||
console.log('');
|
||||
|
||||
LinkdingSyncTests.Formatters.consoleHeader('Test Suite Execution');
|
||||
|
||||
const results = [];
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
try {
|
||||
// Run isolation tests
|
||||
if (TestModules.isolation) {
|
||||
LinkdingSyncTests.Formatters.consoleHeader('Isolation Tests');
|
||||
const result = await TestModules.isolation.run();
|
||||
results.push(...result);
|
||||
updateCounts(result);
|
||||
}
|
||||
|
||||
// Run conflicts tests
|
||||
if (TestModules.conflicts) {
|
||||
LinkdingSyncTests.Formatters.consoleHeader('Conflict Resolution Tests');
|
||||
const result = await TestModules.conflicts.run();
|
||||
results.push(...result);
|
||||
updateCounts(result);
|
||||
}
|
||||
|
||||
// Run deletion tests
|
||||
if (TestModules.deletion) {
|
||||
LinkdingSyncTests.Formatters.consoleHeader('Deletion Tests');
|
||||
const result = await TestModules.deletion.run();
|
||||
results.push(...result);
|
||||
updateCounts(result);
|
||||
}
|
||||
|
||||
// Run bundles tests
|
||||
if (TestModules.bundles) {
|
||||
LinkdingSyncTests.Formatters.consoleHeader('Bundle Tests');
|
||||
const result = await TestModules.bundles.run();
|
||||
results.push(...result);
|
||||
updateCounts(result);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Test suite error:', error.message);
|
||||
console.log('');
|
||||
console.log('[Orchestrator] Attempting cleanup...');
|
||||
try {
|
||||
await LinkdingSyncTests.Helpers.resetBookmarks();
|
||||
} catch (cleanupError) {
|
||||
console.error('Cleanup failed:', cleanupError.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Summary
|
||||
LinkdingSyncTests.Formatters.consoleHeader('Test Summary');
|
||||
console.log(` Total: ${results.length}`);
|
||||
console.log(` Passed: ${passed}`);
|
||||
console.log(` Failed: ${failed}`);
|
||||
console.log(` Warning: ${results.length - passed - failed}`);
|
||||
console.log(''.padEnd(60, '='));
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
// Update pass/fail counts
|
||||
function updateCounts(results) {
|
||||
results.forEach(r => {
|
||||
if (r.pass === true) passed++;
|
||||
else if (r.pass === false) failed++;
|
||||
});
|
||||
}
|
||||
|
||||
// Run specific test module
|
||||
async function runModule(moduleName) {
|
||||
if (!TestModules[moduleName]) {
|
||||
console.error(`[Orchestrator] Unknown module: ${moduleName}`);
|
||||
return [];
|
||||
}
|
||||
|
||||
console.log(`\nRunning ${moduleName} tests...`);
|
||||
return await TestModules[moduleName].run();
|
||||
}
|
||||
|
||||
// Reset and run all tests
|
||||
async function runAllTestsWithReset() {
|
||||
console.log(''.padEnd(60, '='));
|
||||
console.log(' LINKDINGSYNC - Test Suite with Reset');
|
||||
console.log('='.repeat(60));
|
||||
|
||||
try {
|
||||
await LinkdingSyncTests.Helpers.resetBookmarks();
|
||||
console.log('[Reset] Test bookmarks cleaned');
|
||||
} catch (error) {
|
||||
console.error('[Reset] Failed:', error.message);
|
||||
}
|
||||
|
||||
return await runAllTests();
|
||||
}
|
||||
|
||||
// ====================================================================
|
||||
// EXPOSE FUNCTIONS
|
||||
// ====================================================================
|
||||
|
||||
window.LinkdingSyncTests = {
|
||||
runAllTests,
|
||||
runAllTestsWithReset,
|
||||
runModule,
|
||||
TestModules
|
||||
};
|
||||
|
||||
console.log('');
|
||||
console.log('LinkdingSync Test Orchestrator loaded');
|
||||
console.log('');
|
||||
console.log('Commands:');
|
||||
console.log(' runAllTests() - Run all tests');
|
||||
console.log(' runAllTestsWithReset() - Run with cleanup first');
|
||||
console.log(' runModule("name") - Run specific test module');
|
||||
console.log(' reset() - Clean up test bookmarks');
|
||||
console.log('');
|
||||
|
||||
// Export reset function
|
||||
async function reset() {
|
||||
console.log('[Orchestrator] Resetting test bookmarks...');
|
||||
await LinkdingSyncTests.Helpers.resetBookmarks();
|
||||
console.log('[Orchestrator] Reset complete');
|
||||
}
|
||||
|
||||
window.LinkdingSyncTests.reset = reset;
|
||||
139
Linkding Browser Extension/LinkdingSync/tests/quick-test.js
Normal file
139
Linkding Browser Extension/LinkdingSync/tests/quick-test.js
Normal file
@@ -0,0 +1,139 @@
|
||||
/*
|
||||
* LinkdingSync Quick Test Runner
|
||||
* Simplest version - just paste and run!
|
||||
*/
|
||||
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
// CONFIG
|
||||
var BASE = 'https://links.blabber1565.com';
|
||||
var WKEY = '4108e3aff26fb82bf074f5d4dfa4757763520b06';
|
||||
var PKEY = '9b80accd3b9b4b91c2a7adc3dcf41621b025329a';
|
||||
var RESULTS = [];
|
||||
|
||||
// STATE
|
||||
var ctx = { url: BASE, key: WKEY };
|
||||
|
||||
// API
|
||||
var $ = function(m, e, d) {
|
||||
var url = BASE + e;
|
||||
var r = new Promise(function(ok, err) {
|
||||
fetch(url, { method: m, headers: { Authorization: 'Token ' + ctx.key, 'Content-Type': 'json' }, body: d ? JSON.stringify(d) : null })
|
||||
.then(function(res) {
|
||||
if (res.ok || res.status === 404) res.json().then(ok);
|
||||
else err(new Error(res.status + ': ' + res.statusText));
|
||||
})
|
||||
.catch(err);
|
||||
});
|
||||
return r;
|
||||
};
|
||||
|
||||
// TEST 1
|
||||
($('POST', '/api/bookmarks/', { url: 'https://t1.w.example.com', title: 'W1', notes: '{"test":1}' }))
|
||||
.then(function(b1) {
|
||||
ctx.key = PKEY;
|
||||
return $('POST', '/api/bookmarks/', { url: 'https://t1.w.example.com', title: 'P1', notes: '{"test":1}' });
|
||||
})
|
||||
.then(function(b2) {
|
||||
console.log('T1 IDs: ' + b1.id + ' ' + b2.id);
|
||||
console.log('Same? ' + (b1.id === b2.id));
|
||||
if (b1.id === b2.id) { RESULTS.push({p:false,r:'API keys do NOT isolate'}); }
|
||||
else { RESULTS.push({p:true,r:'API keys provide isolation'}); }
|
||||
})
|
||||
.then(function() {
|
||||
// TEST 2
|
||||
ctx.key = PKEY;
|
||||
return $('GET', '/api/bookmarks/?limit=100').then(function(d) {
|
||||
console.log('P sees: ' + d.count + ' bookmarks');
|
||||
RESULTS.push({p:true,r:'User isolation works'});
|
||||
return d;
|
||||
});
|
||||
})
|
||||
.then(function(d) {
|
||||
// TEST 3
|
||||
ctx.key = WKEY;
|
||||
return $('POST', '/api/bookmarks/', { url: 'https://t3.c.example.com', title: 'W', path: 'W', notes: '{"test":3}' });
|
||||
})
|
||||
.then(function(b1) {
|
||||
ctx.key = PKEY;
|
||||
return $('POST', '/api/bookmarks/', { url: 'https://t3.c.example.com', title: 'P', path: 'P', notes: '{"test":3}' });
|
||||
})
|
||||
.then(function(b2) {
|
||||
console.log('T3 IDs: ' + b1.id + ' ' + b2.id);
|
||||
if (b1.id === b2.id) { RESULTS.push({p:false,r:'Server merges by URL'}); }
|
||||
else { RESULTS.push({p:true,r:'Server creates separate'}); }
|
||||
})
|
||||
.then(function() {
|
||||
// TEST 4
|
||||
ctx.key = WKEY;
|
||||
return $('POST', '/api/bookmarks/', { url: 'https://t4.up.example.com', title: 'Initial', notes: '{"test":4}' });
|
||||
})
|
||||
.then(function(bm) {
|
||||
console.log('T4 Initial: ' + bm.id);
|
||||
return $('PUT', '/api/bookmarks/' + bm.id + '/', { title: 'Work Title', notes: '{"test":4}' });
|
||||
})
|
||||
.then(function() {
|
||||
return $('GET', '/api/bookmarks/' + bm.id + '/').then(function(f) {
|
||||
console.log('T4 Final title: ' + f.title);
|
||||
if (f.title === 'Work Title') { RESULTS.push({p:true,r:'Title update works'}); }
|
||||
else if (f.title === 'Initial') { RESULTS.push({p:true,r:'Title NOT updated'}); }
|
||||
else { RESULTS.push({p:null,r:'Unknown title'}); }
|
||||
});
|
||||
})
|
||||
.then(function() {
|
||||
// TEST 5
|
||||
ctx.key = WKEY;
|
||||
return $('POST', '/api/bookmarks/', { url: 'https://t5.d.example.com', title: 'W', path: 'W', notes: '{"test":5}' });
|
||||
})
|
||||
.then(function(b1) {
|
||||
ctx.key = PKEY;
|
||||
return $('POST', '/api/bookmarks/', { url: 'https://t5.d.example.com', title: 'P', path: 'P', notes: '{"test":5}' });
|
||||
})
|
||||
.then(function(b2) {
|
||||
console.log('T5 IDs: ' + b1.id + ' ' + b2.id);
|
||||
ctx.key = WKEY;
|
||||
return $('DELETE', '/api/bookmarks/' + b1.id + '/');
|
||||
})
|
||||
.then(function() {
|
||||
ctx.key = PKEY;
|
||||
return $('GET', '/api/bookmarks/?limit=100&url=https://t5.d.example.com').then(function(d) {
|
||||
console.log('P sees with URL: ' + (d.count || 0));
|
||||
if ((d.count || 0) === 0) { RESULTS.push({p:false,r:'Delete propagated'}); }
|
||||
else { RESULTS.push({p:true,r:'Delete isolated'}); }
|
||||
});
|
||||
})
|
||||
.then(function() {
|
||||
// TEST 6
|
||||
ctx.key = WKEY;
|
||||
return Promise.all([
|
||||
$('POST', '/api/bookmarks/', { url: 'https://b6.1.example.com', title: 'B6-1', path: 'W1', notes: '{"test":6}' }),
|
||||
$('POST', '/api/bookmarks/', { url: 'https://b6.2.example.com', title: 'B6-2', path: 'W2', notes: '{"test":6}' })
|
||||
]).then(function() {
|
||||
console.log('Created 2 W bookmarks');
|
||||
ctx.key = WKEY;
|
||||
return $('GET', '/api/bookmarks/?all=work&limit=100').then(function(wd) {
|
||||
ctx.key = PKEY;
|
||||
return $('GET', '/api/bookmarks/?all=personal&limit=100').then(function(pd) {
|
||||
console.log('W bundle: ' + wd.count + ' Personal: ' + pd.count);
|
||||
RESULTS.push({p:true,r:'Bundle filtering works'});
|
||||
});
|
||||
});
|
||||
});
|
||||
})
|
||||
.then(function() {
|
||||
// SUMMARY
|
||||
console.log(''); console.log('='.repeat(60)); console.log(' Summary'); console.log('='.repeat(60));
|
||||
var passed = RESULTS.filter(function(r) { return r.p === true; }).length;
|
||||
var failed = RESULTS.filter(function(r) { return r.p === false; }).length;
|
||||
var warned = RESULTS.filter(function(r) { return r.p === null; }).length;
|
||||
console.log(' Total: ' + RESULTS.length + ' Passed: ' + passed + ' Failed: ' + failed + ' Warn: ' + warned);
|
||||
console.log('='.repeat(60));
|
||||
console.log(''); console.log('LinkdingSyncTests.cleanup() - clean up');
|
||||
console.log(''); console.log('Done!');
|
||||
})
|
||||
.catch(function(e) { console.error('Error:', e.message); });
|
||||
|
||||
})();
|
||||
|
||||
console.log(''); console.log('LinkdingSync Quick Test Runner loaded'); console.log('Running tests automatically...');
|
||||
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()');
|
||||
161
Linkding Browser Extension/LinkdingSync/tests/test-bundles.js
Normal file
161
Linkding Browser Extension/LinkdingSync/tests/test-bundles.js
Normal file
@@ -0,0 +1,161 @@
|
||||
/*
|
||||
* Test Module: Bundle Tag Filtering
|
||||
* Tests scenario 6
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const utils = require('../utils.js').LinkdingSyncTests;
|
||||
|
||||
const SCENARIO_NAME = 'Bundle Tag Filtering Tests';
|
||||
|
||||
// Test 7: Bundle Tag Filtering
|
||||
async function test7_BundleTagFiltering() {
|
||||
console.log('\n=== Test 7: Bundle Tag Filtering ===');
|
||||
console.log('Purpose: Verify if bundle tags filter bookmarks properly');
|
||||
|
||||
try {
|
||||
utils.SessionManager.setContext(
|
||||
CONFIG.serverUrl,
|
||||
CONFIG.workApiKey,
|
||||
CONFIG.workUser,
|
||||
CONFIG.workBundle
|
||||
);
|
||||
|
||||
const testUrl = 'https://bundle-filter.example.com';
|
||||
|
||||
// Create with work bundle tag
|
||||
const bookmark = await utils.Helpers.createBookmark(testUrl, {
|
||||
title: 'Bundle Filter Test',
|
||||
path: 'Test/Path',
|
||||
notes: 'Work bundle tag'
|
||||
});
|
||||
|
||||
console.log(` Bookmark created: ID=${bookmark.id}`);
|
||||
|
||||
// Query by work bundle tag
|
||||
const workBundleResponse = await utils.Helpers.getAllBookmarks();
|
||||
|
||||
console.log(` Work bundle has ${workBundleResponse.filter(b => b.testId).length} test bookmarks`);
|
||||
|
||||
// Check if bookmark is in work bundle
|
||||
const workFiltered = workBundleResponse.filter(b => b.testId && b.notes?.testId);
|
||||
|
||||
if (workFiltered.length > 0) {
|
||||
utils.Formatters.consoleResult('Test 7', 'PASS', 'Bundle tags filter bookmarks');
|
||||
console.log(' → Work bundle has work-tagged bookmarks');
|
||||
|
||||
return { pass: true, filtered: true };
|
||||
|
||||
} else {
|
||||
utils.Formatters.consoleResult('Test 7', 'WARN', 'Bundle filtering unclear');
|
||||
console.log(' → May need to use tags for filtering');
|
||||
|
||||
return { pass: null, filtered: null };
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
utils.Formatters.consoleResult('Test 7', 'FAIL', error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Test 8: Bundle-Specific Sync
|
||||
async function test8_BundleSpecificSync() {
|
||||
console.log('\n=== Test 8: Bundle-Specific Sync ===');
|
||||
console.log('Purpose: Verify sync behavior with different bundles');
|
||||
|
||||
try {
|
||||
utils.SessionManager.setContext(
|
||||
CONFIG.serverUrl,
|
||||
CONFIG.workApiKey,
|
||||
CONFIG.workUser,
|
||||
CONFIG.workBundle
|
||||
);
|
||||
|
||||
const workUrl = 'https://bundle-specific-work.example.com';
|
||||
const personalUrl = 'https://bundle-specific-personal.example.com';
|
||||
|
||||
// Create work bookmark
|
||||
const workBookmark = await utils.Helpers.createBookmark(workUrl, {
|
||||
title: 'Bundle Specific - Work',
|
||||
path: 'Work/Bundle',
|
||||
notes: 'Work bundle content'
|
||||
});
|
||||
|
||||
console.log(` Work bookmark: ID=${workBookmark.id}`);
|
||||
|
||||
// Create personal bookmark
|
||||
utils.SessionManager.setContext(
|
||||
CONFIG.serverUrl,
|
||||
CONFIG.personalApiKey,
|
||||
CONFIG.personalUser,
|
||||
CONFIG.personalBundle
|
||||
);
|
||||
|
||||
const personalBookmark = await utils.Helpers.createBookmark(personalUrl, {
|
||||
title: 'Bundle Specific - Personal',
|
||||
path: 'Personal/Bundle',
|
||||
notes: 'Personal bundle content'
|
||||
});
|
||||
|
||||
console.log(` Personal bookmark: ID=${personalBookmark.id}`);
|
||||
|
||||
// Check via work
|
||||
const workList = await utils.Helpers.getAllBookmarks();
|
||||
const personalList = await utils.Helpers.getAllBookmarks();
|
||||
|
||||
const workCount = workList.filter(b => b.testId).length;
|
||||
const personalCount = personalList.filter(b => b.testId).length;
|
||||
|
||||
console.log(` Work has ${workCount} test bookmarks`);
|
||||
console.log(` Personal has ${personalCount} test bookmarks`);
|
||||
|
||||
if (workCount === 1 && personalCount === 1) {
|
||||
utils.Formatters.consoleResult('Test 8', 'PASS', 'Bundles provide logical separation');
|
||||
console.log(' → Can maintain separate sync for each bundle');
|
||||
|
||||
return { pass: true, workCount, personalCount };
|
||||
|
||||
} else {
|
||||
utils.Formatters.consoleResult('Test 8', 'WARN', 'Bundle counts differ from expected');
|
||||
console.log(' → May need to use tags for proper isolation');
|
||||
|
||||
return { pass: null, workCount, personalCount };
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
utils.Formatters.consoleResult('Test 8', 'FAIL', error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Run all tests
|
||||
async function runBundleTests() {
|
||||
console.log('\n' + '='.repeat(60));
|
||||
console.log(' ' + SCENARIO_NAME);
|
||||
console.log('='.repeat(60));
|
||||
|
||||
const results = [];
|
||||
|
||||
try {
|
||||
results[0] = await test7_BundleTagFiltering();
|
||||
results[1] = await test8_BundleSpecificSync();
|
||||
} catch (error) {
|
||||
console.error('Test suite error:', error.message);
|
||||
utils.Helpers.resetBookmarks();
|
||||
}
|
||||
|
||||
console.log('\n' + '='.repeat(60));
|
||||
console.log(' Bundle Tests Complete');
|
||||
console.log('='.repeat(60));
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
// Export
|
||||
window.LinkdingSyncTests.TestBundles = {
|
||||
run: runBundleTests,
|
||||
test7: test7_BundleTagFiltering,
|
||||
test8: test8_BundleSpecificSync
|
||||
};
|
||||
196
Linkding Browser Extension/LinkdingSync/tests/test-conflicts.js
Normal file
196
Linkding Browser Extension/LinkdingSync/tests/test-conflicts.js
Normal file
@@ -0,0 +1,196 @@
|
||||
/*
|
||||
* Test Module: Conflict Resolution
|
||||
* Tests scenarios 3 and 4
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const utils = require('../utils.js').LinkdingSyncTests;
|
||||
|
||||
const SCENARIO_NAME = 'Conflict Resolution Tests';
|
||||
|
||||
// Test 3: Conflict Resolution - Different Paths
|
||||
async function test3_ConflictResolution() {
|
||||
console.log('\n=== Test 3: Conflict Resolution - Different Paths ===');
|
||||
console.log('Purpose: How server handles same URL in different paths with different API keys');
|
||||
|
||||
try {
|
||||
// Create with work API key
|
||||
utils.SessionManager.setContext(
|
||||
CONFIG.serverUrl,
|
||||
CONFIG.workApiKey,
|
||||
CONFIG.workUser,
|
||||
CONFIG.workBundle
|
||||
);
|
||||
|
||||
const workUrl = 'https://conflict-resolution.example.com';
|
||||
const workBookmark = await utils.Helpers.createBookmark(workUrl, {
|
||||
title: 'Conflict Resolution Test',
|
||||
path: 'Work/Development',
|
||||
notes: 'Work Development Notes'
|
||||
});
|
||||
|
||||
console.log(` Work bookmark ID: ${workBookmark.id}`);
|
||||
|
||||
// Create same URL with personal API key
|
||||
utils.SessionManager.setContext(
|
||||
CONFIG.serverUrl,
|
||||
CONFIG.personalApiKey,
|
||||
CONFIG.personalUser,
|
||||
CONFIG.personalBundle
|
||||
);
|
||||
|
||||
const personalBookmark = await utils.Helpers.createBookmark(workUrl, {
|
||||
title: 'Conflict Resolution Test',
|
||||
path: 'Personal/Notes',
|
||||
notes: 'Personal Notes'
|
||||
});
|
||||
|
||||
console.log(` Personal bookmark ID: ${personalBookmark.id}`);
|
||||
|
||||
// Compare
|
||||
console.log(`\nComparing bookmark IDs: work=${workBookmark.id}, personal=${personalBookmark.id}`);
|
||||
|
||||
if (workBookmark.id === personalBookmark.id) {
|
||||
utils.Formatters.consoleResult('Test 3', 'FAIL', 'Same bookmark ID');
|
||||
console.log(' → Server merges bookmarks by URL');
|
||||
console.log(' → Need path merge strategy');
|
||||
|
||||
const state = await utils.Helpers.fetchBookmark(workBookmark.id);
|
||||
const parsed = utils.Helpers.parseNotes(state.notes);
|
||||
console.log(` → Current path: ${parsed.path}`);
|
||||
console.log(` → Current notes: ${parsed.userNotes}`);
|
||||
|
||||
return { pass: false, sameId: true, path: parsed.path };
|
||||
|
||||
} else {
|
||||
utils.Formatters.consoleResult('Test 3', 'PASS', 'Different bookmark IDs');
|
||||
console.log(' → Server creates separate bookmarks per API key');
|
||||
console.log(' → Can use different API keys for isolation');
|
||||
|
||||
return { pass: true, sameId: false, workId: workBookmark.id, personalId: personalBookmark.id };
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
utils.Formatters.consoleResult('Test 3', 'FAIL', error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Test 4: Title/Description Conflict
|
||||
async function test4_TitleDescriptionConflict() {
|
||||
console.log('\n=== Test 4: Title/Description Conflict ===');
|
||||
console.log('Purpose: How server resolves conflicts for title/description fields');
|
||||
|
||||
try {
|
||||
utils.SessionManager.setContext(
|
||||
CONFIG.serverUrl,
|
||||
CONFIG.workApiKey,
|
||||
CONFIG.workUser,
|
||||
CONFIG.workBundle
|
||||
);
|
||||
|
||||
const testUrl = 'https://title-conflict.example.com';
|
||||
|
||||
// Create initial
|
||||
const bookmark = await utils.Helpers.createBookmark(testUrl, {
|
||||
title: 'Initial Title',
|
||||
description: 'Initial Description',
|
||||
path: 'Initial'
|
||||
});
|
||||
|
||||
// Update via work
|
||||
await utils.Helpers.updateBookmark(bookmark.id, {
|
||||
title: 'Work Title',
|
||||
description: 'Work Description',
|
||||
notes: JSON.stringify({
|
||||
path: 'Work/Dev',
|
||||
userNotes: 'Work notes',
|
||||
autoTags: [{name: 'Work'}]
|
||||
})
|
||||
});
|
||||
|
||||
console.log(' Updated via Work: Work Title');
|
||||
|
||||
// Update via personal
|
||||
utils.SessionManager.setContext(
|
||||
CONFIG.serverUrl,
|
||||
CONFIG.personalApiKey,
|
||||
CONFIG.personalUser,
|
||||
CONFIG.personalBundle
|
||||
);
|
||||
|
||||
await utils.Helpers.updateBookmark(bookmark.id, {
|
||||
title: 'Personal Title',
|
||||
description: 'Personal Description',
|
||||
notes: JSON.stringify({
|
||||
path: 'Personal/Notes',
|
||||
userNotes: 'Personal notes',
|
||||
autoTags: [{name: 'Personal'}]
|
||||
})
|
||||
});
|
||||
|
||||
console.log(' Updated via Personal: Personal Title');
|
||||
|
||||
// Fetch final state
|
||||
const final = await utils.Helpers.fetchBookmark(bookmark.id);
|
||||
const parsed = utils.Helpers.parseNotes(final.notes);
|
||||
|
||||
console.log('\nFinal state:');
|
||||
console.log(` Title: ${final.title}`);
|
||||
console.log(` Description: ${final.description}`);
|
||||
console.log(` Path: ${parsed.path}`);
|
||||
console.log(` User notes: ${parsed.userNotes}`);
|
||||
|
||||
if (final.title === 'Personal Title') {
|
||||
utils.Formatters.consoleResult('Test 4', 'PASS', 'Last-write-wins (Personal took precedence)');
|
||||
return { pass: true, strategy: 'last-write-wins', winner: 'personal' };
|
||||
|
||||
} else if (final.title === 'Work Title') {
|
||||
utils.Formatters.consoleResult('Test 4', 'PASS', 'Last-write-wins (Work took precedence)');
|
||||
return { pass: true, strategy: 'last-write-wins', winner: 'work' };
|
||||
|
||||
} else if (final.title.includes('Work') && final.title.includes('Personal')) {
|
||||
utils.Formatters.consoleResult('Test 4', 'PASS', 'Merged title');
|
||||
return { pass: true, strategy: 'merge', winner: 'merged' };
|
||||
|
||||
} else {
|
||||
utils.Formatters.consoleResult('Test 4', 'WARN', 'Unexpected title value');
|
||||
return { pass: null, strategy: 'unknown', winner: final.title };
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
utils.Formatters.consoleResult('Test 4', 'FAIL', error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Run all tests
|
||||
async function runConflictTests() {
|
||||
console.log('\n' + '='.repeat(60));
|
||||
console.log(' ' + SCENARIO_NAME);
|
||||
console.log('='.repeat(60));
|
||||
|
||||
const results = [];
|
||||
|
||||
try {
|
||||
results[0] = await test3_ConflictResolution();
|
||||
results[1] = await test4_TitleDescriptionConflict();
|
||||
} catch (error) {
|
||||
console.error('Test suite error:', error.message);
|
||||
utils.Helpers.resetBookmarks();
|
||||
}
|
||||
|
||||
console.log('\n' + '='.repeat(60));
|
||||
console.log(' Conflict Resolution Tests Complete');
|
||||
console.log('='.repeat(60));
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
// Export
|
||||
window.LinkdingSyncTests.TestConflicts = {
|
||||
run: runConflictTests,
|
||||
test3: test3_ConflictResolution,
|
||||
test4: test4_TitleDescriptionConflict
|
||||
};
|
||||
218
Linkding Browser Extension/LinkdingSync/tests/test-deletion.js
Normal file
218
Linkding Browser Extension/LinkdingSync/tests/test-deletion.js
Normal file
@@ -0,0 +1,218 @@
|
||||
/*
|
||||
* Test Module: Delete Propagation
|
||||
* Tests scenario 5
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const utils = require('../utils.js').LinkdingSyncTests;
|
||||
|
||||
const SCENARIO_NAME = 'Delete Propagation Tests';
|
||||
|
||||
// Test 5: Delete Propagation
|
||||
async function test5_DeletePropagation() {
|
||||
console.log('\n=== Test 5: Delete Propagation ===');
|
||||
console.log('Purpose: Confirm if deleting affects all API keys');
|
||||
|
||||
try {
|
||||
utils.SessionManager.setContext(
|
||||
CONFIG.serverUrl,
|
||||
CONFIG.workApiKey,
|
||||
CONFIG.workUser,
|
||||
CONFIG.workBundle
|
||||
);
|
||||
|
||||
const testUrl = 'https://delete-propagation.example.com';
|
||||
|
||||
// Create via work
|
||||
const workBookmark = await utils.Helpers.createBookmark(testUrl, {
|
||||
title: 'Delete Prop Test',
|
||||
path: 'Work/Dev'
|
||||
});
|
||||
|
||||
console.log(` Work bookmark ID: ${workBookmark.id}`);
|
||||
|
||||
// Create same URL via personal
|
||||
utils.SessionManager.setContext(
|
||||
CONFIG.serverUrl,
|
||||
CONFIG.personalApiKey,
|
||||
CONFIG.personalUser,
|
||||
CONFIG.personalBundle
|
||||
);
|
||||
|
||||
const personalBookmark = await utils.Helpers.createBookmark(testUrl, {
|
||||
title: 'Delete Prop Test',
|
||||
path: 'Personal/Notes'
|
||||
});
|
||||
|
||||
console.log(` Personal bookmark ID: ${personalBookmark.id}`);
|
||||
|
||||
const sameBookmark = workBookmark.id === personalBookmark.id;
|
||||
console.log(` Same bookmark? ${sameBookmark}`);
|
||||
|
||||
// Delete via work
|
||||
await utils.SessionManager.setContext(
|
||||
CONFIG.serverUrl,
|
||||
CONFIG.workApiKey,
|
||||
CONFIG.workUser,
|
||||
CONFIG.workBundle
|
||||
);
|
||||
await utils.Helpers.deleteBookmark(workBookmark.id);
|
||||
console.log(' Deleted via Work key');
|
||||
|
||||
// Check via personal
|
||||
utils.SessionManager.setContext(
|
||||
CONFIG.serverUrl,
|
||||
CONFIG.personalApiKey,
|
||||
CONFIG.personalUser,
|
||||
CONFIG.personalBundle
|
||||
);
|
||||
|
||||
const personalList = await utils.Helpers.getAllBookmarks();
|
||||
const deleted = personalList.find(b => b.url === testUrl);
|
||||
|
||||
if (!deleted) {
|
||||
utils.Formatters.consoleResult('Test 5', 'FAIL', 'Delete propagated (same bookmark)');
|
||||
console.log(' → Deleting via one key deletes all');
|
||||
|
||||
return { pass: false, propagated: true, sameBookmark };
|
||||
|
||||
} else {
|
||||
utils.Formatters.consoleResult('Test 5', 'PASS', 'Delete did not propagate (separate bookmarks)');
|
||||
console.log(' → Each bookmark exists independently');
|
||||
console.log(' → Can delete via specific API key');
|
||||
|
||||
return { pass: true, propagated: false, sameBookmark };
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
utils.Formatters.consoleResult('Test 5', 'FAIL', error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Test 6: Delete with Same User Different Keys
|
||||
async function test6_DeleteSameUserDifferentKeys() {
|
||||
console.log('\n=== Test 6: Delete - Same User, Different Keys ===');
|
||||
console.log('Purpose: Verify delete behavior when same user, different API keys');
|
||||
|
||||
try {
|
||||
utils.SessionManager.setContext(
|
||||
CONFIG.serverUrl,
|
||||
CONFIG.workApiKey,
|
||||
CONFIG.workUser,
|
||||
CONFIG.workBundle
|
||||
);
|
||||
|
||||
const testUrl = 'https://delete-same-user.example.com';
|
||||
|
||||
// Create with first key
|
||||
const bm1 = await utils.Helpers.createBookmark(testUrl, {
|
||||
title: 'Same User Delete Test - Key 1'
|
||||
});
|
||||
|
||||
console.log(` Created with Key 1: ID=${bm1.id}`);
|
||||
|
||||
// Create same URL with second key (personal)
|
||||
utils.SessionManager.setContext(
|
||||
CONFIG.serverUrl,
|
||||
CONFIG.personalApiKey,
|
||||
CONFIG.personalUser,
|
||||
CONFIG.personalBundle
|
||||
);
|
||||
|
||||
const bm2 = await utils.Helpers.createBookmark(testUrl, {
|
||||
title: 'Same User Delete Test - Key 2'
|
||||
});
|
||||
|
||||
console.log(` Created with Key 2: ID=${bm2.id}`);
|
||||
|
||||
// Check if same bookmark
|
||||
if (bm1.id === bm2.id) {
|
||||
utils.Formatters.consoleResult('Test 6', 'WARN', 'Same bookmark - delete propagates');
|
||||
console.log(' → Same user means same bookmark');
|
||||
console.log(' → Deleting via either key removes it');
|
||||
|
||||
// Delete via work
|
||||
await utils.Helpers.deleteBookmark(bm1.id);
|
||||
|
||||
// Verify gone
|
||||
utils.SessionManager.setContext(
|
||||
CONFIG.serverUrl,
|
||||
CONFIG.workApiKey,
|
||||
CONFIG.workUser,
|
||||
CONFIG.workBundle
|
||||
);
|
||||
const workList = await utils.Helpers.getAllBookmarks();
|
||||
const exists = workList.find(b => b.url === testUrl);
|
||||
|
||||
if (!exists) {
|
||||
console.log(' → Verified: bookmark deleted via both keys');
|
||||
}
|
||||
|
||||
return { pass: true, same: true, propagates: true };
|
||||
|
||||
} else {
|
||||
utils.Formatters.consoleResult('Test 6', 'PASS', 'Different bookmarks - delete is isolated');
|
||||
console.log(' → Different API keys create different bookmarks');
|
||||
console.log(' → Can delete independently');
|
||||
|
||||
// Delete via work
|
||||
await utils.Helpers.deleteBookmark(bm1.id);
|
||||
|
||||
// Verify work side gone
|
||||
const workList = await utils.Helpers.getAllBookmarks();
|
||||
const workGone = !workList.find(b => b.url === testUrl);
|
||||
|
||||
// Verify personal still exists
|
||||
utils.SessionManager.setContext(
|
||||
CONFIG.serverUrl,
|
||||
CONFIG.personalApiKey,
|
||||
CONFIG.personalUser,
|
||||
CONFIG.personalBundle
|
||||
);
|
||||
const personalList = await utils.Helpers.getAllBookmarks();
|
||||
const personalExists = personalList.find(b => b.url === testUrl);
|
||||
|
||||
if (workGone && personalExists) {
|
||||
console.log(' → Verified: work deleted, personal still exists');
|
||||
}
|
||||
|
||||
return { pass: true, same: false, propagates: false };
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
utils.Formatters.consoleResult('Test 6', 'FAIL', error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Run all tests
|
||||
async function runDeletionTests() {
|
||||
console.log('\n' + '='.repeat(60));
|
||||
console.log(' ' + SCENARIO_NAME);
|
||||
console.log('='.repeat(60));
|
||||
|
||||
const results = [];
|
||||
|
||||
try {
|
||||
results[0] = await test5_DeletePropagation();
|
||||
results[1] = await test6_DeleteSameUserDifferentKeys();
|
||||
} catch (error) {
|
||||
console.error('Test suite error:', error.message);
|
||||
utils.Helpers.resetBookmarks();
|
||||
}
|
||||
|
||||
console.log('\n' + '='.repeat(60));
|
||||
console.log(' Deletion Tests Complete');
|
||||
console.log('='.repeat(60));
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
// Export
|
||||
window.LinkdingSyncTests.TestDeletion = {
|
||||
run: runDeletionTests,
|
||||
test5: test5_DeletePropagation,
|
||||
test6: test6_DeleteSameUserDifferentKeys
|
||||
};
|
||||
176
Linkding Browser Extension/LinkdingSync/tests/test-isolation.js
Normal file
176
Linkding Browser Extension/LinkdingSync/tests/test-isolation.js
Normal file
@@ -0,0 +1,176 @@
|
||||
/*
|
||||
* Test Module: API Key & User Isolation
|
||||
* Tests scenarios 1 and 2
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const utils = require('../utils.js').LinkdingSyncTests;
|
||||
|
||||
const SCENARIO_NAME = 'API Key & User Isolation Tests';
|
||||
|
||||
// Helper to create a test bookmark with work API key
|
||||
async function createWorkBookmark(url, options) {
|
||||
utils.SessionManager.setContext(
|
||||
CONFIG.serverUrl,
|
||||
CONFIG.workApiKey,
|
||||
CONFIG.workUser,
|
||||
CONFIG.workBundle
|
||||
);
|
||||
return utils.Helpers.createBookmark(url, options);
|
||||
}
|
||||
|
||||
// Helper to create a test bookmark with personal API key
|
||||
async function createPersonalBookmark(url, options) {
|
||||
utils.SessionManager.setContext(
|
||||
CONFIG.serverUrl,
|
||||
CONFIG.personalApiKey,
|
||||
CONFIG.personalUser,
|
||||
CONFIG.personalBundle
|
||||
);
|
||||
return utils.Helpers.createBookmark(url, options);
|
||||
}
|
||||
|
||||
// Helper to fetch with work API key
|
||||
async function fetchWork(id) {
|
||||
utils.SessionManager.setContext(
|
||||
CONFIG.serverUrl,
|
||||
CONFIG.workApiKey,
|
||||
CONFIG.workUser,
|
||||
CONFIG.workBundle
|
||||
);
|
||||
return utils.Helpers.fetchBookmark(id);
|
||||
}
|
||||
|
||||
// Helper to fetch with personal API key
|
||||
async function fetchPersonal(id) {
|
||||
utils.SessionManager.setContext(
|
||||
CONFIG.serverUrl,
|
||||
CONFIG.personalApiKey,
|
||||
CONFIG.personalUser,
|
||||
CONFIG.personalBundle
|
||||
);
|
||||
return utils.Helpers.fetchBookmark(id);
|
||||
}
|
||||
|
||||
// Helper to list with personal API key
|
||||
async function listPersonal(queryParams = {}) {
|
||||
utils.SessionManager.setContext(
|
||||
CONFIG.serverUrl,
|
||||
CONFIG.personalApiKey,
|
||||
CONFIG.personalUser,
|
||||
CONFIG.personalBundle
|
||||
);
|
||||
return utils.SessionManager.call('/api/bookmarks/', 'GET', queryParams);
|
||||
}
|
||||
|
||||
// Test 1: Same URL, Different API Keys, Same User
|
||||
async function test1_SameUserDifferentKeys() {
|
||||
console.log('\n=== Test 1: Same URL, Different API Keys, Same User ===');
|
||||
console.log('Purpose: Verify if API keys provide isolation within same user');
|
||||
|
||||
try {
|
||||
// Create with work key
|
||||
const bm1 = await createWorkBookmark('https://isolation-test.example.com', {
|
||||
title: 'Isolation Test - Work Key'
|
||||
});
|
||||
|
||||
// Create same URL with personal key
|
||||
const bm2 = await createPersonalBookmark('https://isolation-test.example.com', {
|
||||
title: 'Isolation Test - Personal Key'
|
||||
});
|
||||
|
||||
console.log(` Work bookmark ID: ${bm1.id}`);
|
||||
console.log(` Personal bookmark ID: ${bm2.id}`);
|
||||
|
||||
if (bm1.id === bm2.id) {
|
||||
utils.Formatters.consoleResult('Test 1', 'FAIL', 'Same bookmark ID - API keys do NOT provide isolation');
|
||||
console.log(' → Same user means same bookmarks regardless of API key');
|
||||
return { pass: false, reason: 'API keys do not provide isolation within same user' };
|
||||
} else {
|
||||
utils.Formatters.consoleResult('Test 1', 'PASS', 'Different bookmark IDs - API keys provide isolation');
|
||||
console.log(' → Different API keys create separate bookmarks');
|
||||
return { pass: true, ids: { work: bm1.id, personal: bm2.id } };
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
utils.Formatters.consoleResult('Test 1', 'FAIL', error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Test 2: Different Users - Verify isolation
|
||||
async function test2_DifferentUsers() {
|
||||
console.log('\n=== Test 2: Different Users - Verify Isolation ===');
|
||||
console.log('Purpose: Verify isolation between different users');
|
||||
|
||||
try {
|
||||
// Create bookmark as work user
|
||||
const workUrl = 'https://cross-user-isolation.example.com';
|
||||
const workBookmark = await createWorkBookmark(workUrl, {
|
||||
title: 'Cross-User Test - Work'
|
||||
});
|
||||
|
||||
console.log(` Bookmark created by work user: ID=${workBookmark.id}`);
|
||||
|
||||
// Work user sees their own bookmark
|
||||
const workFetch = await fetchWork(workBookmark.id);
|
||||
console.log(` Work user sees bookmark: ${workFetch.title}`);
|
||||
|
||||
// Personal user queries for the test bookmark
|
||||
utils.SessionManager.setContext(
|
||||
CONFIG.serverUrl,
|
||||
CONFIG.personalApiKey,
|
||||
CONFIG.personalUser,
|
||||
CONFIG.personalBundle
|
||||
);
|
||||
|
||||
const personalFetch = await listPersonal({ limit: 100 });
|
||||
|
||||
console.log(` Personal user sees ${personalFetch.count || personalFetch.results?.length || 0} bookmarks`);
|
||||
|
||||
if (personalFetch.results && personalFetch.results.length > 0) {
|
||||
utils.Formatters.consoleResult('Test 2', 'FAIL', 'Users can see each other\'s bookmarks');
|
||||
console.log(' → Sharing enabled or same underlying user');
|
||||
return { pass: false, reason: 'Users can see each other\'s bookmarks (sharing or same user)' };
|
||||
} else {
|
||||
utils.Formatters.consoleResult('Test 2', 'PASS', 'Proper user isolation exists');
|
||||
console.log(' → Can use different API keys for isolation');
|
||||
return { pass: true };
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
utils.Formatters.consoleResult('Test 2', 'FAIL', error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Run all tests
|
||||
async function runIsolationTests() {
|
||||
console.log('\n' + '='.repeat(60));
|
||||
console.log(' ' + SCENARIO_NAME);
|
||||
console.log('='.repeat(60));
|
||||
|
||||
const results = [];
|
||||
|
||||
try {
|
||||
results[0] = await test1_SameUserDifferentKeys();
|
||||
results[1] = await test2_DifferentUsers();
|
||||
} catch (error) {
|
||||
console.error('Test suite error:', error.message);
|
||||
utils.Helpers.resetBookmarks();
|
||||
}
|
||||
|
||||
console.log('\n' + '='.repeat(60));
|
||||
console.log(' Isolation Tests Complete');
|
||||
console.log('='.repeat(60));
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
// Export
|
||||
window.LinkdingSyncTests.TestIsolation = {
|
||||
run: runIsolationTests,
|
||||
test1: test1_SameUserDifferentKeys,
|
||||
test2: test2_DifferentUsers
|
||||
};
|
||||
192
Linkding Browser Extension/LinkdingSync/tests/test.html
Normal file
192
Linkding Browser Extension/LinkdingSync/tests/test.html
Normal file
@@ -0,0 +1,192 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>LinkdingSync Tests</title>
|
||||
<style>body{font-family:monospace;padding:20px;}.log{white-space:pre-wrap;background:#1e1e1e;color:#d4d4d4;padding:10px;border-radius:4px;min-height:200px;}</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>LinkdingSync Test Runner</h1>
|
||||
<p>Paste your Linkding API keys below or use defaults</p>
|
||||
<table>
|
||||
<tr><td>Server URL:</td><td><input type="text" id="server" value="https://links.blabber1565.com" style="width:300px"></td></tr>
|
||||
<tr><td>Work API Key:</td><td><input type="password" id="wkey" value="4108e3aff26fb82bf074f5d4dfa4757763520b06" style="width:300px"></td></tr>
|
||||
<tr><td>Personal API Key:</td><td><input type="password" id="pkey" value="9b80accd3b9b4b91c2a7adc3dcf41621b025329a" style="width:300px"></td></tr>
|
||||
</table>
|
||||
<p><button id="run" style="margin-top:10px;">Run Tests</button></p>
|
||||
<p><button id="cleanup" style="margin-top:5px;">Cleanup Test Bookmarks</button></p>
|
||||
<p><button id="list" style="margin-top:5px;">List All Bookmarks</button></p>
|
||||
<div class="log" id="log"></div>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
'use strict';
|
||||
var log = document.getElementById('log');
|
||||
var server = document.getElementById('server').value;
|
||||
var wkey = document.getElementById('wkey').value;
|
||||
var pkey = document.getElementById('pkey').value;
|
||||
|
||||
function logMsg(msg) {
|
||||
log.innerHTML = msg + log.innerHTML;
|
||||
}
|
||||
|
||||
function fetch(m, e, d) {
|
||||
return fetch(server + e, { method: m, headers: { Authorization: 'Token ' + wkey, 'Content-Type': 'application/json' }, body: d ? JSON.stringify(d) : null })
|
||||
.then(function(r) {
|
||||
if (r.ok) return r.json();
|
||||
if (r.status === 404) return { error: '404', status: r.status };
|
||||
throw new Error(r.status + ': ' + r.statusText);
|
||||
});
|
||||
}
|
||||
|
||||
var results = [];
|
||||
var ctx = { key: wkey };
|
||||
|
||||
// TEST 1
|
||||
fetch('POST', '/api/bookmarks/', { url: 'https://t1.example.com', title: 'W1', notes: '{"test":1}' })
|
||||
.then(function(b1) {
|
||||
logMsg('T1: Work bookmark ID: ' + b1.id);
|
||||
ctx.key = pkey;
|
||||
return fetch('POST', '/api/bookmarks/', { url: 'https://t1.example.com', title: 'P1', notes: '{"test":1}' });
|
||||
})
|
||||
.then(function(b2) {
|
||||
logMsg('T1: Personal bookmark ID: ' + b2.id);
|
||||
logMsg('Same ID? ' + (b1.id === b2.id));
|
||||
if (b1.id === b2.id) results.push({pass:false,reason:'API keys do NOT isolate'});
|
||||
else results.push({pass:true,reason:'API keys provide isolation'});
|
||||
logMsg('T1: ' + (results[results.length-1].pass ? '✓ PASS' : '✗ FAIL'));
|
||||
logMsg(' ' + results[results.length-1].reason);
|
||||
})
|
||||
.then(function() {
|
||||
// TEST 2
|
||||
ctx.key = pkey;
|
||||
return fetch('GET', '/api/bookmarks/?limit=100');
|
||||
})
|
||||
.then(function(d) {
|
||||
logMsg('T2: Personal sees ' + d.count + ' bookmarks');
|
||||
var myTests = d.results ? d.results.filter(function(b) { return b.testId || b.notes?.testId; }) : [];
|
||||
logMsg('T2: My test bookmarks: ' + myTests.length);
|
||||
if (myTests.length === 1) results.push({pass:true,reason:'User isolation works'});
|
||||
else if (myTests.length > 1) results.push({pass:false,reason:'Personal sees multiple test bookmarks'});
|
||||
else results.push({pass:null,reason:'Unexpected count'});
|
||||
logMsg('T2: ' + (results[results.length-1].pass ? '✓ PASS' : (results[results.length-1].pass === false ? '✗ FAIL' : '⚠ WARN')));
|
||||
})
|
||||
.then(function() {
|
||||
// TEST 3
|
||||
ctx.key = wkey;
|
||||
return fetch('POST', '/api/bookmarks/', { url: 'https://t3.example.com', title: 'W', path: 'W', notes: '{"test":3}' });
|
||||
})
|
||||
.then(function(b1) {
|
||||
ctx.key = pkey;
|
||||
return fetch('POST', '/api/bookmarks/', { url: 'https://t3.example.com', title: 'P', path: 'P', notes: '{"test":3}' });
|
||||
})
|
||||
.then(function(b2) {
|
||||
logMsg('T3: Work ID: ' + b1.id + ' Personal ID: ' + b2.id);
|
||||
logMsg('T3: Same? ' + (b1.id === b2.id));
|
||||
if (b1.id === b2.id) results.push({pass:false,reason:'Server merges by URL'});
|
||||
else results.push({pass:true,reason:'Server creates separate'});
|
||||
logMsg('T3: ' + (results[results.length-1].pass ? '✓ PASS' : '✗ FAIL'));
|
||||
})
|
||||
.then(function() {
|
||||
// TEST 4
|
||||
ctx.key = wkey;
|
||||
return fetch('POST', '/api/bookmarks/', { url: 'https://t4.example.com', title: 'Initial', notes: '{"test":4}' });
|
||||
})
|
||||
.then(function(bm) {
|
||||
logMsg('T4: Initial ID: ' + bm.id);
|
||||
return fetch('PUT', '/api/bookmarks/' + bm.id + '/', { title: 'Work Title', notes: '{"test":4}' });
|
||||
})
|
||||
.then(function() {
|
||||
return fetch('GET', '/api/bookmarks/' + bm.id + '/');
|
||||
})
|
||||
.then(function(f) {
|
||||
logMsg('T4: Final title: ' + f.title);
|
||||
if (f.title === 'Work Title') results.push({pass:true,reason:'Title update works'});
|
||||
else if (f.title === 'Initial') results.push({pass:true,reason:'Title NOT updated'});
|
||||
else results.push({pass:null,reason:'Unknown title'});
|
||||
logMsg('T4: ' + (results[results.length-1].pass ? '✓ PASS' : (results[results.length-1].pass === false ? '✗ FAIL' : '⚠ WARN')));
|
||||
})
|
||||
.then(function() {
|
||||
// TEST 5
|
||||
ctx.key = wkey;
|
||||
return fetch('POST', '/api/bookmarks/', { url: 'https://t5.example.com', title: 'W', notes: '{"test":5}' });
|
||||
})
|
||||
.then(function(b1) {
|
||||
ctx.key = pkey;
|
||||
return fetch('POST', '/api/bookmarks/', { url: 'https://t5.example.com', title: 'P', notes: '{"test":5}' });
|
||||
})
|
||||
.then(function(b2) {
|
||||
logMsg('T5: IDs: ' + b1.id + ' ' + b2.id);
|
||||
ctx.key = wkey;
|
||||
return fetch('DELETE', '/api/bookmarks/' + b1.id + '/');
|
||||
})
|
||||
.then(function() {
|
||||
ctx.key = pkey;
|
||||
return fetch('GET', '/api/bookmarks/?limit=100&url=https://t5.example.com');
|
||||
})
|
||||
.then(function(d) {
|
||||
var cnt = d.count || 0;
|
||||
logMsg('T5: Personal sees with URL: ' + cnt);
|
||||
if (cnt === 0) results.push({pass:false,reason:'Delete propagated'});
|
||||
else results.push({pass:true,reason:'Delete isolated'});
|
||||
logMsg('T5: ' + (results[results.length-1].pass ? '✓ PASS' : '✗ FAIL'));
|
||||
})
|
||||
.then(function() {
|
||||
// TEST 6
|
||||
ctx.key = wkey;
|
||||
return Promise.all([
|
||||
fetch('POST', '/api/bookmarks/', { url: 'https://b6.1.example.com', title: 'B1', notes: '{"test":6}' }),
|
||||
fetch('POST', '/api/bookmarks/', { url: 'https://b6.2.example.com', title: 'B2', notes: '{"test":6}' })
|
||||
]);
|
||||
})
|
||||
.then(function() {
|
||||
logMsg('T6: Created 2 W bookmarks');
|
||||
ctx.key = wkey;
|
||||
return fetch('GET', '/api/bookmarks/?all=work&limit=100');
|
||||
})
|
||||
.then(function(wd) {
|
||||
ctx.key = pkey;
|
||||
return fetch('GET', '/api/bookmarks/?all=personal&limit=100');
|
||||
})
|
||||
.then(function(pd) {
|
||||
logMsg('T6: W bundle: ' + wd.count + ' Personal: ' + pd.count);
|
||||
results.push({pass:true,reason:'Bundle filtering works'});
|
||||
logMsg('T6: ✓ PASS');
|
||||
})
|
||||
.then(function() {
|
||||
// SUMMARY
|
||||
logMsg(''); logMsg('='.repeat(60)); logMsg(' Summary'); logMsg('='.repeat(60));
|
||||
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;
|
||||
logMsg(' Total: ' + results.length + ' Passed: ' + passed + ' Failed: ' + failed + ' Warn: ' + warned);
|
||||
logMsg('='.repeat(60));
|
||||
});
|
||||
|
||||
document.getElementById('run').addEventListener('click', function() {
|
||||
log.innerHTML = '';
|
||||
ctx.key = wkey;
|
||||
});
|
||||
|
||||
document.getElementById('cleanup').addEventListener('click', function() {
|
||||
fetch('GET', '/api/bookmarks/?limit=100').then(function(d) {
|
||||
var tests = d.results ? d.results.filter(function(b) { return b.testId || b.notes?.testId; }) : [];
|
||||
logMsg(''); logMsg('[Cleanup] ' + tests.length + ' test bookmarks');
|
||||
if (tests.length) {
|
||||
Promise.all(tests.map(function(t) { return fetch('DELETE', '/api/bookmarks/' + t.id + '/'); })).then(function() { logMsg('[Cleanup] Done'); });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById('list').addEventListener('click', function() {
|
||||
fetch('GET', '/api/bookmarks/?limit=100').then(function(d) {
|
||||
logMsg(''); logMsg('All bookmarks: ' + d.count);
|
||||
if (d.results) {
|
||||
d.results.forEach(function(b) { logMsg(' ' + b.url + ' [' + b.title + ']'); });
|
||||
}
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
201
Linkding Browser Extension/LinkdingSync/tests/utils.js
Normal file
201
Linkding Browser Extension/LinkdingSync/tests/utils.js
Normal file
@@ -0,0 +1,201 @@
|
||||
/*
|
||||
* LinkdingSync Test Utilities
|
||||
* Shared functions for test modules
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
// ====================================================================
|
||||
// CONFIGURATION
|
||||
// ====================================================================
|
||||
|
||||
const CONFIG = {
|
||||
serverUrl: 'https://links.blabber1565.com',
|
||||
workApiKey: '4108e3aff26fb82bf074f5d4dfa4757763520b06',
|
||||
workUser: 'linkdingsync_tester',
|
||||
workBundle: 'work',
|
||||
personalApiKey: '9b80accd3b9b4b91c2a7adc3dcf41621b025329a',
|
||||
personalUser: 'linkdingsync_tester_2',
|
||||
personalBundle: 'personal',
|
||||
cleanupAfterTests: true
|
||||
};
|
||||
|
||||
// ====================================================================
|
||||
// SESSION MANAGEMENT
|
||||
// ====================================================================
|
||||
|
||||
const SessionManager = {
|
||||
currentContext: null,
|
||||
|
||||
setContext(serverUrl, apiKey, userId, bundle) {
|
||||
this.currentContext = {
|
||||
serverUrl: serverUrl.endsWith('/') ? serverUrl : serverUrl + '/',
|
||||
apiKey,
|
||||
userId,
|
||||
bundle
|
||||
};
|
||||
return this;
|
||||
},
|
||||
|
||||
getHeaders() {
|
||||
return {
|
||||
'Authorization': `Token ${this.currentContext.apiKey}`,
|
||||
'Content-Type': 'application/json'
|
||||
};
|
||||
},
|
||||
|
||||
async call(endpoint, method = 'GET', queryParams = {}) {
|
||||
const url = new URL(endpoint, this.currentContext.serverUrl);
|
||||
Object.entries(queryParams).forEach(([key, value]) => {
|
||||
url.searchParams.append(key, value);
|
||||
});
|
||||
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers: this.getHeaders(),
|
||||
body: null
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text().slice(0, 200);
|
||||
throw new Error(`${response.status}: ${response.statusText} - ${text}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
};
|
||||
|
||||
// ====================================================================
|
||||
// HELPERS
|
||||
// ====================================================================
|
||||
|
||||
const Helpers = {
|
||||
generateTestId(prefix = 'test') {
|
||||
return `${prefix}-${Date.now().toString().slice(-4)}-${Math.random().toString(36).substring(2, 4)}`;
|
||||
},
|
||||
|
||||
async createBookmark(url, options = {}) {
|
||||
const testId = this.generateTestId();
|
||||
const baseUrl = new URL(url);
|
||||
baseUrl.hostname = `${testId}.${baseUrl.hostname}`;
|
||||
|
||||
const bookmarkData = {
|
||||
url: baseUrl.href,
|
||||
title: options.title || `Test: ${testId}`,
|
||||
description: options.description || 'Test bookmark',
|
||||
notes: JSON.stringify({
|
||||
path: options.path || `Test/${testId}`,
|
||||
userNotes: options.notes || 'Test bookmark',
|
||||
testId
|
||||
})
|
||||
};
|
||||
|
||||
const response = await SessionManager.call('/api/bookmarks/', 'POST', null, {});
|
||||
console.log(` Created: ID=${response.id}`);
|
||||
return response;
|
||||
},
|
||||
|
||||
async updateBookmark(bookmarkId, data) {
|
||||
const response = await SessionManager.call(`/api/bookmarks/${bookmarkId}/`, 'PUT', null, {});
|
||||
console.log(` Updated: ID=${bookmarkId}`);
|
||||
return response;
|
||||
},
|
||||
|
||||
async deleteBookmark(bookmarkId) {
|
||||
await SessionManager.call(`/api/bookmarks/${bookmarkId}/`, 'DELETE', {});
|
||||
console.log(` Deleted: ID=${bookmarkId}`);
|
||||
return true;
|
||||
},
|
||||
|
||||
async fetchBookmark(id) {
|
||||
return SessionManager.call(`/api/bookmarks/${id}/`);
|
||||
},
|
||||
|
||||
parseNotes(noteString) {
|
||||
if (!noteString) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(noteString);
|
||||
return parsed;
|
||||
} catch {
|
||||
return { userNotes: noteString, version: '1.0', path: '', autoTags: [], bundleTag: null };
|
||||
}
|
||||
},
|
||||
|
||||
async getAllBookmarks() {
|
||||
let bookmarks = [];
|
||||
let offset = 0;
|
||||
const batchSize = 100;
|
||||
|
||||
do {
|
||||
const response = await SessionManager.call('/api/bookmarks/', 'GET', { limit: batchSize, offset });
|
||||
bookmarks.push(...(response.results || []));
|
||||
offset += batchSize;
|
||||
} while (bookmarks.length > offset);
|
||||
|
||||
return bookmarks;
|
||||
},
|
||||
|
||||
// Reset all bookmarks to clean state
|
||||
async resetBookmarks() {
|
||||
console.log('[Utils] Resetting all bookmarks...');
|
||||
try {
|
||||
const allBookmarks = await this.getAllBookmarks();
|
||||
const testBookmarks = allBookmarks.filter(b => b.testId);
|
||||
|
||||
if (testBookmarks.length > 0) {
|
||||
console.log(`[Utils] Found ${testBookmarks.length} test bookmarks to delete`);
|
||||
|
||||
for (const bm of testBookmarks) {
|
||||
await this.deleteBookmark(bm.id);
|
||||
}
|
||||
|
||||
console.log('[Utils] Reset complete');
|
||||
} else {
|
||||
console.log('[Utils] No test bookmarks found');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Utils] Reset failed:', error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// ====================================================================
|
||||
// FORMATTERS
|
||||
// ====================================================================
|
||||
|
||||
const Formatters = {
|
||||
formatTimestamp(timestamp) {
|
||||
if (!timestamp) return 'Never';
|
||||
return new Date(timestamp).toLocaleString('en-US', {
|
||||
year: 'numeric', month: 'short', day: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit'
|
||||
});
|
||||
},
|
||||
|
||||
consoleHeader(text) {
|
||||
console.log(''.padEnd(60, '='));
|
||||
console.log(` ${text}`.padEnd(60, '='.charCodeAt(0) === text.charCodeAt(0) ? '=' : '-').padEnd(60, '='));
|
||||
console.log(''.padEnd(60, '='));
|
||||
},
|
||||
|
||||
consoleResult(scenario, status, details = '') {
|
||||
const icon = status === 'PASS' ? '✓' : status === 'FAIL' ? '✗' : '⚠';
|
||||
const emoji = status === 'PASS' ? '✅' : status === 'FAIL' ? '❌' : '⚠️';
|
||||
console.log(` [${scenario}] ${icon} ${emoji} ${status}`);
|
||||
if (details) console.log(` ${details}`);
|
||||
}
|
||||
};
|
||||
|
||||
// ====================================================================
|
||||
// EXPORT
|
||||
// ====================================================================
|
||||
|
||||
window.LinkdingSyncTests = {
|
||||
CONFIG,
|
||||
SessionManager,
|
||||
Helpers,
|
||||
Formatters,
|
||||
consoleHeader: Formatters.consoleHeader,
|
||||
consoleResult: Formatters.consoleResult
|
||||
};
|
||||
217
Linkding Browser Extension/LinkdingSync/tests/verified-test.js
Normal file
217
Linkding Browser Extension/LinkdingSync/tests/verified-test.js
Normal file
@@ -0,0 +1,217 @@
|
||||
/*
|
||||
* LinkdingSync Verified Test Runner
|
||||
* Includes verification and cleanup between tests
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
(function(w) {
|
||||
'use strict';
|
||||
var window = w || window;
|
||||
|
||||
var serverUrl = 'https://links.blabber1565.com';
|
||||
var workApiKey = '4108e3aff26fb82bf074f5d4dfa4757763520b06';
|
||||
var personalApiKey = '9b80accd3b9b4b91c2a7adc3dcf41621b025329a';
|
||||
var workUser = 'linkdingsync_tester';
|
||||
var personalUser = 'linkdingsync_tester_2';
|
||||
|
||||
var state = { url: '', apiKey: '', userId: null, results: [] };
|
||||
|
||||
function call(method, endpoint, data) {
|
||||
var u = new URL(endpoint, state.url);
|
||||
var r = fetch(u, {
|
||||
method: method,
|
||||
headers: { 'Authorization': 'Token ' + state.apiKey, 'Content-Type': 'application/json' },
|
||||
body: data ? JSON.stringify(data) : null
|
||||
});
|
||||
return r.then(function(res) {
|
||||
if (!res.ok) {
|
||||
if (res.status === 404) return { error: '404', status: res.status };
|
||||
throw new Error(res.status + ': ' + res.statusText);
|
||||
}
|
||||
return res.json();
|
||||
});
|
||||
}
|
||||
|
||||
function createBookmark(url, opts) {
|
||||
var testId = 'test-' + Date.now().toString().slice(-4) + '-' + Math.random().toString(36).slice(2, 4);
|
||||
var base = new URL(url);
|
||||
base.hostname = testId + '.' + base.hostname;
|
||||
var data = { url: base.href, title: opts.title || 'Test: ' + testId, description: 'Test', notes: JSON.stringify({ testId, path: 'Test/' + testId }) };
|
||||
console.log(' Created: ' + data.url);
|
||||
return call('POST', '/api/bookmarks/', data);
|
||||
}
|
||||
|
||||
function listBookmarks() {
|
||||
return call('GET', '/api/bookmarks/?limit=100');
|
||||
}
|
||||
|
||||
function cleanup() {
|
||||
return listBookmarks().then(function(data) {
|
||||
var tests = data.results.filter(function(b) { return b.testId; });
|
||||
console.log('[Cleanup] Found ' + tests.length + ' test bookmarks to delete');
|
||||
if (tests.length > 0) {
|
||||
return Promise.all(tests.map(function(t) { return call('DELETE', '/api/bookmarks/' + t.id + '/'); }));
|
||||
}
|
||||
}).then(function() { console.log('[Cleanup] Done'); });
|
||||
}
|
||||
|
||||
// TEST 1
|
||||
function test1() {
|
||||
console.log('\n=== TEST 1: API Key Isolation ===');
|
||||
return verify().then(function() {
|
||||
state.url = serverUrl; state.apiKey = workApiKey; state.userId = workUser;
|
||||
return createBookmark('https://t1.example.com', { title: 'T1-Work' });
|
||||
}).then(function(b1) {
|
||||
state.url = serverUrl; state.apiKey = personalApiKey; state.userId = personalUser;
|
||||
return createBookmark('https://t1.example.com', { title: 'T1-Personal' });
|
||||
}).then(function(b2) {
|
||||
console.log(' Work ID: ' + b1.id + ' Personal ID: ' + b2.id + ' Same? ' + (b1.id === b2.id));
|
||||
if (b1.id === b2.id) { console.log(' [TEST 1] ✗ FAIL - API keys do NOT provide isolation'); return { pass: false, reason: 'API keys do not provide isolation' }; }
|
||||
else { console.log(' [TEST 1] ✓ PASS - API keys provide isolation'); return { pass: true, reason: 'API keys provide isolation' }; }
|
||||
});
|
||||
}
|
||||
|
||||
// TEST 2
|
||||
function test2() {
|
||||
console.log('\n=== TEST 2: Cross-User Visibility ===');
|
||||
return test1().then(function(r1) {
|
||||
state.url = serverUrl; state.apiKey = personalApiKey; state.userId = personalUser;
|
||||
return listBookmarks();
|
||||
}).then(function(data) {
|
||||
var testCount = 0;
|
||||
if (data.results && Array.isArray(data.results)) {
|
||||
testCount = data.results.filter(function(b) { return b.url && b.url.indexOf('test-') === -1; }).length;
|
||||
}
|
||||
console.log(' Personal sees ' + testCount + ' non-test bookmarks');
|
||||
var hasWorkBookmark = data.results && Array.isArray(data.results) && data.results.some(function(b) { return b.url === 'https://t2.example.com'; });
|
||||
if (hasWorkBookmark) { console.log(' [TEST 2] ✗ FAIL - Personal can see work\'s bookmark'); return { pass: false, reason: 'Cross-user visibility' }; }
|
||||
else { console.log(' [TEST 2] ✓ PASS - Proper user isolation'); return { pass: true, reason: 'Proper user isolation' }; }
|
||||
});
|
||||
}
|
||||
|
||||
// TEST 3
|
||||
function test3() {
|
||||
console.log('\n=== TEST 3: Conflict Resolution ===');
|
||||
return test2().then(function(r2) {
|
||||
state.url = serverUrl; state.apiKey = workApiKey; state.userId = workUser;
|
||||
return createBookmark('https://t3.example.com', { title: 'T3-Work', path: 'Work' });
|
||||
}).then(function(b1) {
|
||||
state.url = serverUrl; state.apiKey = personalApiKey; state.userId = personalUser;
|
||||
return createBookmark('https://t3.example.com', { title: 'T3-Personal', path: 'Personal' });
|
||||
}).then(function(b2) {
|
||||
console.log(' Work ID: ' + b1.id + ' Personal ID: ' + b2.id + ' Same? ' + (b1.id === b2.id));
|
||||
if (b1.id === b2.id) { console.log(' [TEST 3] ✗ FAIL - Server merges by URL'); return { pass: false, reason: 'Server merges by URL' }; }
|
||||
else { console.log(' [TEST 3] ✓ PASS - Separate bookmarks'); return { pass: true, reason: 'Separate bookmarks' }; }
|
||||
});
|
||||
}
|
||||
|
||||
// TEST 4
|
||||
function test4() {
|
||||
console.log('\n=== TEST 4: Field Update Behavior ===');
|
||||
return test3().then(function(r3) {
|
||||
state.url = serverUrl; state.apiKey = workApiKey; state.userId = workUser;
|
||||
return createBookmark('https://t4.example.com', { title: 'Initial', path: 'Initial' });
|
||||
}).then(function(bm) {
|
||||
console.log(' Initial ID: ' + bm.id);
|
||||
return call('PUT', '/api/bookmarks/' + bm.id + '/', { title: 'Work Title', description: 'Work', notes: JSON.stringify({ path: 'Work', userNotes: 'Work' }) });
|
||||
}).then(function(resp) {
|
||||
console.log(' Work update: ' + (resp.error ? resp.error : 'OK'));
|
||||
return call('GET', '/api/bookmarks/' + bm.id + '/');
|
||||
}).then(function(final) {
|
||||
console.log(' Final title: ' + final.title);
|
||||
if (final.title === 'Work Title') { console.log(' [TEST 4] ✓ Title updated'); return { pass: true, strategy: 'title-update', title: final.title }; }
|
||||
else if (final.title === 'Initial') { console.log(' [TEST 4] ✓ Title NOT updated (notes only)'); return { pass: true, strategy: 'notes-only', title: final.title }; }
|
||||
else { console.log(' [TEST 4] ? UNKNOWN title: ' + final.title); return { pass: null, strategy: 'unknown', title: final.title }; }
|
||||
});
|
||||
}
|
||||
|
||||
// TEST 5
|
||||
function test5() {
|
||||
console.log('\n=== TEST 5: Delete Behavior ===');
|
||||
return test4().then(function(r4) {
|
||||
state.url = serverUrl; state.apiKey = workApiKey; state.userId = workUser;
|
||||
return createBookmark('https://t5.example.com', { title: 'T5-Work', path: 'Work' });
|
||||
}).then(function(b1) {
|
||||
state.url = serverUrl; state.apiKey = personalApiKey; state.userId = personalUser;
|
||||
return createBookmark('https://t5.example.com', { title: 'T5-Personal', path: 'Personal' });
|
||||
}).then(function(b2) {
|
||||
console.log(' Work ID: ' + b1.id + ' Personal ID: ' + b2.id + ' Same? ' + (b1.id === b2.id));
|
||||
state.url = serverUrl; state.apiKey = workApiKey; state.userId = workUser;
|
||||
return call('DELETE', '/api/bookmarks/' + b1.id + '/');
|
||||
}).then(function() {
|
||||
state.url = serverUrl; state.apiKey = personalApiKey; state.userId = personalUser;
|
||||
return call('GET', '/api/bookmarks/?limit=100&url=https://t5.example.com');
|
||||
}).then(function(data) {
|
||||
console.log(' Personal sees ' + (data.count || 0) + ' with that URL');
|
||||
if (data.count === 0 || !data.results) { console.log(' [TEST 5] ✗ FAIL - Delete propagated'); return { pass: false, reason: 'Delete propagated' }; }
|
||||
else { console.log(' [TEST 5] ✓ PASS - Delete isolated'); return { pass: true, reason: 'Delete isolated' }; }
|
||||
});
|
||||
}
|
||||
|
||||
// TEST 6
|
||||
function test6() {
|
||||
console.log('\n=== TEST 6: Bundle Filtering ===');
|
||||
return test5().then(function(r5) {
|
||||
state.url = serverUrl; state.apiKey = workApiKey; state.userId = workUser;
|
||||
return Promise.all([createBookmark('https://b6-1.example.com', { title: 'B6-W1', path: 'W1' }), createBookmark('https://b6-2.example.com', { title: 'B6-W2', path: 'W2' })]);
|
||||
}).then(function() {
|
||||
console.log(' Created 2 work bookmarks');
|
||||
state.url = serverUrl; state.apiKey = workApiKey; state.userId = workUser;
|
||||
return call('GET', '/api/bookmarks/?all=work&limit=100');
|
||||
}).then(function(data) {
|
||||
var wc = data.count || (data.results ? data.results.length : 0);
|
||||
console.log(' Work bundle: ' + wc + ' bookmarks');
|
||||
state.url = serverUrl; state.apiKey = personalApiKey; state.userId = personalUser;
|
||||
return call('GET', '/api/bookmarks/?all=personal&limit=100');
|
||||
}).then(function(data) {
|
||||
var pc = data.count || (data.results ? data.results.length : 0);
|
||||
console.log(' Personal bundle: ' + pc + ' bookmarks');
|
||||
if (wc > 0) { console.log(' [TEST 6] ✓ PASS - Bundle filtering works'); return { pass: true, work: wc, personal: pc }; }
|
||||
else { console.log(' [TEST 6] ? WARN - Bundle filtering unclear'); return { pass: null, reason: 'Bundle unclear' }; }
|
||||
});
|
||||
}
|
||||
|
||||
function verify() {
|
||||
return listBookmarks().then(function(data) {
|
||||
var tests = data.results.filter(function(b) { return b.testId; });
|
||||
console.log('[Verify] Test bookmarks: ' + tests.length);
|
||||
return tests.length === 0;
|
||||
});
|
||||
}
|
||||
|
||||
// MAIN
|
||||
(function main() {
|
||||
console.log(''); console.log('LinkdingSync Verified Test Runner loaded'); console.log('');
|
||||
console.log('Running tests...'); console.log('');
|
||||
test1().then(function(r1) { consoleLog(r1); return r1; });
|
||||
test2().then(function(r2) { consoleLog(r2); return r2; });
|
||||
test3().then(function(r3) { consoleLog(r3); return r3; });
|
||||
test4().then(function(r4) { consoleLog(r4); return r4; });
|
||||
test5().then(function(r5) { consoleLog(r5); return r5; });
|
||||
test6().then(function(r6) {
|
||||
console.log(''); console.log('='.repeat(60)); console.log(' Summary'); console.log('='.repeat(60));
|
||||
var passed = 0, failed = 0, warned = 0;
|
||||
[r1, r2, r3, r4, r5, r6].forEach(function(r) {
|
||||
if (r.pass === true) passed++; else if (r.pass === false) failed++; else warned++;
|
||||
});
|
||||
console.log(' Total: 6, Passed: ' + passed + ', Failed: ' + failed + ', Warning: ' + warned);
|
||||
console.log('='.repeat(60));
|
||||
return cleanup().then(function() {
|
||||
console.log(''); console.log('LinkdingSyncTests available:'); console.log(' cleanup()'); console.log(' listAll()'); console.log(''); console.log('Done!');
|
||||
return { r1, r2, r3, r4, r5, r6, passed, failed, warned };
|
||||
});
|
||||
});
|
||||
})();
|
||||
|
||||
function consoleLog(r) {
|
||||
if (r.pass === true) console.log(' [PASS]');
|
||||
else if (r.pass === false) console.log(' [FAIL]');
|
||||
else console.log(' [WARN]');
|
||||
console.log(' ' + r.reason);
|
||||
}
|
||||
|
||||
window.LinkdingSyncTests = { cleanup: cleanup, listAll: listBookmarks, verify: verify };
|
||||
})(window);
|
||||
|
||||
console.log(''); console.log('LinkdingSync Verified Test Runner loaded');
|
||||
Reference in New Issue
Block a user