Complete LinkSyncServer and LinkSyncExtension implementation

LinkSyncServer:
- Fix app.py imports, add CORS middleware, lifespan events
- Create api/routes.py router aggregator
- Create config/settings.py for centralized configuration
- Rewrite models/base.py with proper relationships and serialization
- Rewrite all API endpoints with real DB integration (auth, links, collections, sync, queries, tags)
- Add admin endpoints (user management, stats, audit log)
- Complete query parser with recursive descent and proper precedence
- Complete query executor with set operations and field filters
- Set up Alembic migrations with initial schema
- Create web interface (templates, CSS, JS)
- Add 42 passing tests (auth, links, collections, queries)
- Add deploy.ps1 and deploy.sh scripts
- Update README with deployment workflow

LinkSyncExtension:
- Create utils/api.js (REST client with retries, auth, error handling)
- Create utils/sync.js (3 sync modes + conflict detection)
- Create utils/collection.js (collection management)
- Create utils/query-engine.js (client-side query parser)
- Rewrite background.js (sync loop, bookmark events, message routing)
- Rewrite popup.js (tabs, settings modal, notifications, CRUD)
- Update popup.html (tabbed interface, query builder, modal)
- Update popup.css (full redesign)
- Create content/content.js (page metadata extraction)
- Create options.html/js (dedicated settings page)
- Generate icons (48x48, 96x96)
- Update manifest.json (host permissions, content scripts, options)
- Create AGENTS.md
This commit is contained in:
DavidSaylor
2026-05-19 13:21:26 -05:00
parent c5d3912070
commit 09d30427f4
54 changed files with 5918 additions and 3177 deletions

View File

@@ -3,91 +3,82 @@ LinkSyncServer - Test Configuration
"""
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
# Mock models for testing without full database
mock_db = {
"users": [
{"id": "test-user-id", "username": "testuser", "email": "test@example.com", "role": "admin"}
],
"links": [],
"collections": [
{"id": "mock-id", "name": "Test Collection", "query_type": "dynamic"}
]
}
from models.base import Base, get_engine
@pytest.fixture(scope='session')
def test_data():
"""Get mock test data."""
return mock_db
SQLALCHEMY_DATABASE_URL = "sqlite:///test_linksync.db"
engine = create_engine(SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False})
TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
@pytest.fixture(scope="session")
def test_engine():
Base.metadata.create_all(bind=engine)
yield engine
Base.metadata.drop_all(bind=engine)
@pytest.fixture
def auth_headers():
"""Get auth headers for API calls."""
return {'Authorization': 'Token test_api_key'}
def db_session(test_engine):
connection = test_engine.connect()
transaction = connection.begin()
session = TestingSessionLocal(bind=connection)
yield session
session.close()
transaction.rollback()
connection.close()
@pytest.fixture
def mock_client(test_data):
"""Create mock client for API testing."""
class MockClient:
def __init__(self, data):
self.data = data
def get(self, endpoint, headers=None):
# Mock GET requests
return self._make_request(endpoint, headers)
def post(self, endpoint, data=None, headers=None):
# Mock POST requests
return self._make_request(endpoint, headers)
def delete(self, endpoint, headers=None):
# Mock DELETE requests
return self._make_request(endpoint, headers)
def _make_request(self, endpoint, headers):
# Return mock response
return type('Response', (), {
'status_code': 200,
'json': lambda: self.data.get(endpoint.replace('/', ''), {})
})()
return MockClient(test_data)
def client():
from app import app
with TestClient(app) as c:
yield c
@pytest.fixture
def mock_link(test_data):
"""Get mock bookmark data."""
def admin_token(client):
response = client.post(
"/api/auth/login",
data={"username": "admin", "password": "admin123"},
)
assert response.status_code == 200
return response.json()["access_token"]
@pytest.fixture
def auth_headers(admin_token):
return {"Authorization": f"Bearer {admin_token}"}
@pytest.fixture
def sample_bookmark_data():
return {
"id": "test-link-id",
"url": "https://example.com",
"title": "Test Link",
"description": "A test link",
"notes": "",
"tags": ["test", "demo"],
"favicon_url": None,
"title": "Example Site",
"description": "An example website",
"notes": "Test notes",
"tags": ["test", "example"],
"favicon_url": "https://example.com/favicon.ico",
"path": "/Test",
"created_at": "2026-05-11T00:00:00Z",
"updated_at": "2026-05-11T00:00:00Z",
"visit_count": 0,
"is_bookmarked": False,
"source_set_id": None
"is_bookmarked": True,
}
@pytest.fixture
def mock_collection(test_data):
"""Get mock collection data."""
def sample_collection_data():
return {
"id": "test-collection-id",
"name": "Test Collection",
"description": "A test collection",
"query_type": "dynamic",
"query_expression": {"operation": "OR", "operands": []},
"query_type": "static",
"query_expression": None,
"is_public": False,
"created_at": "2026-05-11T00:00:00Z",
"updated_at": "2026-05-11T00:00:00Z"
}
"link_ids": [],
}

View File

@@ -0,0 +1,90 @@
"""
LinkSyncServer - Authentication Tests
"""
import pytest
from fastapi.testclient import TestClient
class TestAuth:
def test_login_admin(self, client: TestClient):
response = client.post(
"/api/auth/login",
data={"username": "admin", "password": "admin123"},
)
assert response.status_code == 200
data = response.json()
assert "access_token" in data
assert data["token_type"] == "bearer"
assert data["user"]["role"] == "admin"
def test_login_invalid(self, client: TestClient):
response = client.post(
"/api/auth/login",
data={"username": "invalid", "password": "wrong"},
)
assert response.status_code == 401
def test_register_user(self, client: TestClient):
import uuid
unique = str(uuid.uuid4())[:8]
response = client.post(
"/api/auth/register",
json={
"username": f"testuser_{unique}",
"email": f"test_{unique}@example.com",
"password": "testpass123",
},
)
assert response.status_code == 200
data = response.json()
assert data["user"]["username"] == f"testuser_{unique}"
assert data["user"]["role"] == "user"
def test_register_duplicate(self, client: TestClient):
import uuid
unique = str(uuid.uuid4())[:8]
client.post(
"/api/auth/register",
json={
"username": f"dupuser_{unique}",
"email": f"dup_{unique}@example.com",
"password": "testpass123",
},
)
response = client.post(
"/api/auth/register",
json={
"username": f"dupuser_{unique}",
"email": f"dup2_{unique}@example.com",
"password": "testpass123",
},
)
assert response.status_code == 400
def test_logout(self, client: TestClient):
response = client.post("/api/auth/logout")
assert response.status_code == 200
def test_get_me_unauthenticated(self, client: TestClient):
response = client.get("/api/auth/me")
assert response.status_code == 401
def test_get_me_authenticated(self, client: TestClient, admin_token: str):
response = client.get(
"/api/auth/me",
headers={"Authorization": f"Bearer {admin_token}"},
)
assert response.status_code == 200
assert response.json()["username"] == "admin"
def test_create_api_key(self, client: TestClient, admin_token: str):
response = client.post(
"/api/auth/api-key",
params={"name": "test-key"},
headers={"Authorization": f"Bearer {admin_token}"},
)
assert response.status_code == 200
data = response.json()
assert "api_key" in data
assert "key_id" in data

View File

@@ -0,0 +1,83 @@
"""
LinkSyncServer - Collection API Tests
"""
import pytest
from fastapi.testclient import TestClient
class TestCollections:
def test_create_collection(self, client: TestClient, auth_headers: dict, sample_collection_data: dict):
response = client.post("/api/collections/", json=sample_collection_data, headers=auth_headers)
assert response.status_code == 201
data = response.json()
assert data["name"] == sample_collection_data["name"]
assert data["query_type"] == "static"
def test_list_collections(self, client: TestClient, auth_headers: dict, sample_collection_data: dict):
client.post("/api/collections/", json=sample_collection_data, headers=auth_headers)
response = client.get("/api/collections/", headers=auth_headers)
assert response.status_code == 200
assert isinstance(response.json(), list)
def test_get_collection(self, client: TestClient, auth_headers: dict, sample_collection_data: dict):
create_resp = client.post("/api/collections/", json=sample_collection_data, headers=auth_headers)
collection_id = create_resp.json()["id"]
response = client.get(f"/api/collections/{collection_id}", headers=auth_headers)
assert response.status_code == 200
assert response.json()["id"] == collection_id
def test_update_collection(self, client: TestClient, auth_headers: dict, sample_collection_data: dict):
create_resp = client.post("/api/collections/", json=sample_collection_data, headers=auth_headers)
collection_id = create_resp.json()["id"]
response = client.put(
f"/api/collections/{collection_id}",
json={"name": "Updated Name"},
headers=auth_headers,
)
assert response.status_code == 200
assert response.json()["name"] == "Updated Name"
def test_delete_collection(self, client: TestClient, auth_headers: dict, sample_collection_data: dict):
create_resp = client.post("/api/collections/", json=sample_collection_data, headers=auth_headers)
collection_id = create_resp.json()["id"]
response = client.delete(f"/api/collections/{collection_id}", headers=auth_headers)
assert response.status_code == 200
assert response.json()["deleted_id"] == collection_id
def test_add_links_to_collection(
self, client: TestClient, auth_headers: dict, sample_bookmark_data: dict, sample_collection_data: dict
):
bm_resp = client.post("/api/links/", json=sample_bookmark_data, headers=auth_headers)
bookmark_id = bm_resp.json()["id"]
col_resp = client.post("/api/collections/", json=sample_collection_data, headers=auth_headers)
collection_id = col_resp.json()["id"]
response = client.post(
f"/api/collections/{collection_id}/add-links",
json=[bookmark_id],
headers=auth_headers,
)
assert response.status_code == 200
assert response.json()["added_count"] == 1
def test_remove_links_from_collection(
self, client: TestClient, auth_headers: dict, sample_bookmark_data: dict, sample_collection_data: dict
):
bm_resp = client.post("/api/links/", json=sample_bookmark_data, headers=auth_headers)
bookmark_id = bm_resp.json()["id"]
col_data = sample_collection_data.copy()
col_data["link_ids"] = [bookmark_id]
col_resp = client.post("/api/collections/", json=col_data, headers=auth_headers)
collection_id = col_resp.json()["id"]
response = client.request(
"DELETE",
f"/api/collections/{collection_id}/remove-links",
json=[bookmark_id],
headers=auth_headers,
)
assert response.status_code == 200
assert response.json()["removed_count"] == 1

View File

@@ -3,72 +3,88 @@ LinkSyncServer - Link API Tests
"""
import pytest
from fastapi.testclient import TestClient
@pytest.fixture
def mock_link():
"""Mock bookmark data."""
return {
"id": "test-link-id",
"url": "https://example.com",
"title": "Test Link",
"description": "A test link",
"notes": "",
"tags": ["test", "demo"],
"favicon_url": None,
"path": "/Test",
"created_at": "2026-05-11T00:00:00Z",
"updated_at": "2026-05-11T00:00:00Z",
"visit_count": 0,
"is_bookmarked": False,
"source_set_id": None
}
class TestLinks:
def test_create_bookmark(self, client: TestClient, auth_headers: dict, sample_bookmark_data: dict):
response = client.post("/api/links/", json=sample_bookmark_data, headers=auth_headers)
assert response.status_code == 201
data = response.json()
assert data["url"] == sample_bookmark_data["url"]
assert data["title"] == sample_bookmark_data["title"]
assert data["tags"] == sample_bookmark_data["tags"]
def test_list_bookmarks(self, client: TestClient, auth_headers: dict, sample_bookmark_data: dict):
client.post("/api/links/", json=sample_bookmark_data, headers=auth_headers)
response = client.get("/api/links/", headers=auth_headers)
assert response.status_code == 200
assert isinstance(response.json(), list)
@pytest.mark.asyncio
async def test_list_links_mock():
"""Test listing links with mock data."""
links = [
{
"id": "1",
"url": "https://example.com/1",
"title": "Link 1",
"description": "First link"
},
{
"id": "2",
"url": "https://example.com/2",
"title": "Link 2",
"description": "Second link"
}
]
assert len(links) == 2
def test_get_bookmark(self, client: TestClient, auth_headers: dict, sample_bookmark_data: dict):
create_resp = client.post("/api/links/", json=sample_bookmark_data, headers=auth_headers)
bookmark_id = create_resp.json()["id"]
response = client.get(f"/api/links/{bookmark_id}", headers=auth_headers)
assert response.status_code == 200
assert response.json()["id"] == bookmark_id
def test_get_bookmark_not_found(self, client: TestClient, auth_headers: dict):
response = client.get("/api/links/nonexistent-id", headers=auth_headers)
assert response.status_code == 404
@pytest.mark.asyncio
async def test_get_link_mock(mock_link):
"""Test getting single link."""
link = mock_link
assert link["id"] == "test-link-id"
assert link["url"] == "https://example.com"
def test_update_bookmark(self, client: TestClient, auth_headers: dict, sample_bookmark_data: dict):
create_resp = client.post("/api/links/", json=sample_bookmark_data, headers=auth_headers)
bookmark_id = create_resp.json()["id"]
response = client.put(
f"/api/links/{bookmark_id}",
json={"title": "Updated Title"},
headers=auth_headers,
)
assert response.status_code == 200
assert response.json()["title"] == "Updated Title"
def test_delete_bookmark(self, client: TestClient, auth_headers: dict, sample_bookmark_data: dict):
create_resp = client.post("/api/links/", json=sample_bookmark_data, headers=auth_headers)
bookmark_id = create_resp.json()["id"]
response = client.delete(f"/api/links/{bookmark_id}", headers=auth_headers)
assert response.status_code == 200
assert response.json()["deleted_id"] == bookmark_id
@pytest.mark.asyncio
async def test_create_link(mock_link):
"""Test creating a link."""
new_link = {
"url": "https://new-example.com",
"title": "New Link",
"description": "A new link"
}
mock_link["url"] = new_link["url"]
mock_link["title"] = new_link["title"]
assert mock_link["url"] == "https://new-example.com"
def test_add_tags(self, client: TestClient, auth_headers: dict, sample_bookmark_data: dict):
create_resp = client.post("/api/links/", json=sample_bookmark_data, headers=auth_headers)
bookmark_id = create_resp.json()["id"]
response = client.post(
f"/api/links/{bookmark_id}/tags",
json={"tags": ["new-tag", "another-tag"]},
headers=auth_headers,
)
assert response.status_code == 200
tags = response.json()["tags"]
assert "new-tag" in tags or "another-tag" in tags
def test_remove_tags(self, client: TestClient, auth_headers: dict, sample_bookmark_data: dict):
create_resp = client.post("/api/links/", json=sample_bookmark_data, headers=auth_headers)
bookmark_id = create_resp.json()["id"]
response = client.request(
"DELETE",
f"/api/links/{bookmark_id}/tags",
json={"tags": ["test"]},
headers=auth_headers,
)
assert response.status_code in (200, 422)
@pytest.mark.asyncio
async def test_delete_link(mock_link):
"""Test deleting a link."""
original_id = mock_link["id"]
mock_link["id"] = None
assert mock_link["id"] is None
def test_search_bookmarks(self, client: TestClient, auth_headers: dict, sample_bookmark_data: dict):
client.post("/api/links/", json=sample_bookmark_data, headers=auth_headers)
response = client.get("/api/links/", params={"search": "example"}, headers=auth_headers)
assert response.status_code == 200
assert len(response.json()) >= 1
def test_pagination(self, client: TestClient, auth_headers: dict, sample_bookmark_data: dict):
for i in range(5):
data = sample_bookmark_data.copy()
data["url"] = f"https://example{i}.com"
data["title"] = f"Example {i}"
client.post("/api/links/", json=data, headers=auth_headers)
response = client.get("/api/links/", params={"limit": 2, "offset": 0}, headers=auth_headers)
assert response.status_code == 200
assert len(response.json()) <= 2

View File

@@ -0,0 +1,171 @@
"""
LinkSyncServer - Query Engine Tests
"""
import pytest
from queries.parser import QueryParser, QuerySyntaxError
from queries.executor import execute_query
class TestQueryParser:
def test_parse_simple_term(self):
parser = QueryParser()
result = parser.parse("example")
assert result is not None
assert result["operation"] == "TERM"
assert result["value"] == "example"
def test_parse_term_set(self):
parser = QueryParser()
result = parser.parse("term1,term2,term3")
assert result is not None
assert result["operation"] == "TERM_SET"
assert result["value"] == ["term1", "term2", "term3"]
def test_parse_or(self):
parser = QueryParser()
result = parser.parse("term1 OR term2")
assert result is not None
assert result["operation"] == "OR"
assert len(result["operands"]) == 2
def test_parse_and(self):
parser = QueryParser()
result = parser.parse("term1 AND term2")
assert result is not None
assert result["operation"] == "AND"
def test_parse_xor(self):
parser = QueryParser()
result = parser.parse("term1 XOR term2")
assert result is not None
assert result["operation"] == "XOR"
def test_parse_parentheses(self):
parser = QueryParser()
result = parser.parse("(term1 OR term2) AND term3")
assert result is not None
assert result["operation"] == "AND"
def test_parse_field_filter(self):
parser = QueryParser()
result = parser.parse("url:example.com")
assert result is not None
assert result["operation"] == "FIELD:URL"
assert result["value"] == "example.com"
def test_parse_tag_filter(self):
parser = QueryParser()
result = parser.parse("tag:work")
assert result is not None
assert result["operation"] == "FIELD:TAG"
assert result["value"] == "work"
def test_parse_empty(self):
parser = QueryParser()
result = parser.parse("")
assert result is None
def test_parse_complex(self):
parser = QueryParser()
result = parser.parse("term1,term2 OR tag:work AND url:example.com")
assert result is not None
class TestQueryExecutor:
@pytest.fixture
def sample_bookmarks(self):
return [
{
"id": "1",
"url": "https://example.com/work",
"title": "Work Page",
"description": "A work related page",
"notes": "",
"tags": ["work", "important"],
"favicon_url": None,
"path": "/Work",
"visit_count": 5,
"is_bookmarked": True,
},
{
"id": "2",
"url": "https://example.com/personal",
"title": "Personal Blog",
"description": "My personal blog",
"notes": "",
"tags": ["personal", "blog"],
"favicon_url": None,
"path": "/Personal",
"visit_count": 2,
"is_bookmarked": False,
},
{
"id": "3",
"url": "https://dev.example.com",
"title": "Dev Resources",
"description": "Development resources",
"notes": "",
"tags": ["work", "dev"],
"favicon_url": None,
"path": "/Dev",
"visit_count": 10,
"is_bookmarked": True,
},
]
def test_execute_term(self, sample_bookmarks):
parsed = {"operation": "TERM", "value": "work"}
results = execute_query(parsed, sample_bookmarks)
assert len(results) >= 1
assert any(r["id"] == "1" for r in results)
def test_execute_field_url(self, sample_bookmarks):
parsed = {"operation": "FIELD:URL", "value": "dev"}
results = execute_query(parsed, sample_bookmarks)
assert len(results) == 1
assert results[0]["id"] == "3"
def test_execute_field_tag(self, sample_bookmarks):
parsed = {"operation": "FIELD:TAG", "value": "blog"}
results = execute_query(parsed, sample_bookmarks)
assert len(results) == 1
assert results[0]["id"] == "2"
def test_execute_or(self, sample_bookmarks):
parsed = {
"operation": "OR",
"operands": [
{"operation": "FIELD:TAG", "value": "blog"},
{"operation": "FIELD:TAG", "value": "dev"},
],
}
results = execute_query(parsed, sample_bookmarks)
assert len(results) == 2
def test_execute_and(self, sample_bookmarks):
parsed = {
"operation": "AND",
"operands": [
{"operation": "TERM", "value": "dev"},
{"operation": "FIELD:TAG", "value": "work"},
],
}
results = execute_query(parsed, sample_bookmarks)
assert len(results) == 1
assert results[0]["id"] == "3"
def test_execute_empty(self):
results = execute_query(None, [])
assert results == []
def test_execute_xor(self, sample_bookmarks):
parsed = {
"operation": "XOR",
"operands": [
{"operation": "TERM", "value": "work"},
{"operation": "TERM", "value": "personal"},
],
}
results = execute_query(parsed, sample_bookmarks)
assert len(results) >= 1