"""Tests for bookmark.py — reading-list CRUD, article-content storage, migration.""" import io import sqlite3 import subprocess import sys from pathlib import Path import pytest SCRIPTS = Path(__file__).resolve().parent.parent / "scripts" sys.path.insert(0, str(SCRIPTS)) import bookmark # noqa: E402 HTML_TO_MARKDOWN = SCRIPTS / "html_to_markdown.py" @pytest.fixture def db(tmp_path, monkeypatch): path = tmp_path / "db" / "bookmark.sqlite" monkeypatch.setattr(bookmark, "DB_PATH", path) return path def _run(argv, stdin=None, monkeypatch=None): monkeypatch.setattr(sys, "argv", ["bookmark.py"] + argv) if stdin is not None: monkeypatch.setattr(sys, "stdin", io.StringIO(stdin)) bookmark.main() def _capture(capsys): return capsys.readouterr().out def test_add_stores_content_from_stdin(db, monkeypatch, capsys): _run(["add", "https://ex.com/a", "Test", "--content-file", "-"], stdin="# Title\n\nBody paragraph.\n", monkeypatch=monkeypatch) assert "article content stored" in _capture(capsys) conn = sqlite3.connect(db) content = conn.execute("SELECT content FROM bookmarks").fetchone()[0] conn.close() assert content == "# Title\n\nBody paragraph." def test_add_without_content_leaves_null(db, monkeypatch, capsys): _run(["add", "https://ex.com/b", "No content"], monkeypatch=monkeypatch) conn = sqlite3.connect(db) content = conn.execute("SELECT content FROM bookmarks").fetchone()[0] conn.close() assert content is None def test_content_command_prints_stored_markdown(db, monkeypatch, capsys): _run(["add", "https://ex.com/a", "Test", "--content-file", "-"], stdin="# Title\n\nBody.\n", monkeypatch=monkeypatch) capsys.readouterr() _run(["content", "1"], monkeypatch=monkeypatch) assert "# Title\n\nBody." in _capture(capsys) def test_content_command_reports_empty(db, monkeypatch, capsys): _run(["add", "https://ex.com/b", "No content"], monkeypatch=monkeypatch) capsys.readouterr() _run(["content", "1"], monkeypatch=monkeypatch) assert "no stored article content" in _capture(capsys) def test_list_marks_only_rows_with_content(db, monkeypatch, capsys): _run(["add", "https://ex.com/plain", "Plain"], monkeypatch=monkeypatch) _run(["add", "https://ex.com/full", "Full", "--content-file", "-"], stdin="# Doc\n\ntext\n", monkeypatch=monkeypatch) capsys.readouterr() _run(["list"], monkeypatch=monkeypatch) out = _capture(capsys) # Newest first: #1 is the one WITH content (has 📄), #2 plain (no marker). lines = [ln for ln in out.splitlines() if ln.startswith("#")] assert "📄" in lines[0] assert "📄" not in lines[1] def test_migration_adds_columns_to_legacy_db(db, monkeypatch, capsys): db.parent.mkdir(parents=True) conn = sqlite3.connect(db) conn.executescript( """ CREATE TABLE bookmarks ( id INTEGER PRIMARY KEY AUTOINCREMENT, url TEXT NOT NULL, description TEXT NOT NULL DEFAULT '', tags TEXT NOT NULL DEFAULT '[]', created_at TEXT NOT NULL, read_at TEXT ); INSERT INTO bookmarks (url, description, tags, created_at) VALUES ('https://old.com', 'legacy', '[]', '2026-01-01T00:00:00+00:00'); """ ) conn.commit() conn.close() _run(["list"], monkeypatch=monkeypatch) # triggers migration assert "legacy" in _capture(capsys) conn = sqlite3.connect(db) cols = {row[1] for row in conn.execute("PRAGMA table_info(bookmarks)")} count = conn.execute("SELECT COUNT(*) FROM bookmarks").fetchone()[0] conn.close() assert "content" in cols assert "deleted_at" in cols assert count == 1 def test_delete_soft_hides_but_keeps_row(db, monkeypatch, capsys): _run(["add", "https://ex.com/keep", "Keep"], monkeypatch=monkeypatch) _run(["add", "https://ex.com/dup", "Dup"], monkeypatch=monkeypatch) capsys.readouterr() _run(["delete", "1"], monkeypatch=monkeypatch) # newest (dup) is #1 assert "Deleted bookmark #1" in _capture(capsys) _run(["list"], monkeypatch=monkeypatch) out = _capture(capsys) assert "Keep" in out assert "Dup" not in out conn = sqlite3.connect(db) total = conn.execute("SELECT COUNT(*) FROM bookmarks").fetchone()[0] deleted = conn.execute( "SELECT COUNT(*) FROM bookmarks WHERE deleted_at IS NOT NULL" ).fetchone()[0] conn.close() assert total == 2 # row kept in DB assert deleted == 1 def test_delete_out_of_range(db, monkeypatch, capsys): _run(["add", "https://ex.com/a", "One"], monkeypatch=monkeypatch) capsys.readouterr() _run(["delete", "9"], monkeypatch=monkeypatch) assert "No unread bookmark #9" in _capture(capsys) ARTICLE_HTML = """EFI Boot

Understanding EFI Boot

The EFI system partition stores the bootloader and kernel images that the firmware loads at startup, which is a crucial part of the modern boot process.

How it works

When the machine powers on, the firmware reads the EFI variables and locates the boot entry, then hands control to the kernel without a traditional bootloader.

""" def _run_html(html_input: str) -> subprocess.CompletedProcess: return subprocess.run( ["uv", "run", str(HTML_TO_MARKDOWN)], input=html_input, capture_output=True, text=True, ) def test_html_to_markdown_extracts_clean_article(): result = _run_html(ARTICLE_HTML) assert result.returncode == 0, result.stderr assert "# Understanding EFI Boot" in result.stdout assert "## How it works" in result.stdout # Boilerplate must be gone. assert "Buy our product" not in result.stdout assert "Subscribe to our newsletter" not in result.stdout def test_html_to_markdown_empty_input(): result = _run_html("") assert result.returncode == 1