"""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