use std::path::PathBuf;
use std::sync::Arc;
use std::thread;
use chrono::{TimeZone, Utc};
use linkmarks_core::model::{Bookmark, BookmarkId, SourceKind, SourceRef};
use linkmarks_core::store::{open as open_store, open_in_memory};
use rusqlite::Connection;
use tempfile::tempdir;
fn fixture_path() -> PathBuf {
let mut p = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
p.pop();
p.push("src/store.rs");
p
}
fn mk(canonical: &str, title: &str, source: SourceKind, secs: i64) -> Bookmark {
Bookmark {
id: BookmarkId::generate(),
original_url: format!("https://example.com/{title}"),
canonical_url: canonical.into(),
title: title.into(),
description: None,
tags: vec![],
collection: None,
created_at: Utc.timestamp_opt(secs, 0).unwrap(),
updated_at: Utc.timestamp_opt(secs, 0).unwrap(),
source: SourceRef {
kind: source,
external_id: Some(format!("ext-{title}")),
imported_at: Utc.timestamp_opt(secs, 0).unwrap(),
raw: Some(serde_json::json!({"marker": title})),
},
content_type: None,
archived: false,
}
}
#[test]
fn open_creates_schema_and_is_idempotent() {
let dir = tempdir().unwrap();
let path = dir.path().join("store.db");
{
let s = open_store(&path).unwrap();
assert_eq!(s.count().unwrap(), 0);
}
{
let s = open_store(&path).unwrap();
assert_eq!(s.count().unwrap(), 0);
}
}
#[test]
fn upsert_new_canonical_inserts_and_sets_tags() {
let mut s = open_in_memory().unwrap();
let mut b = mk("https://example.com/a", "A", SourceKind::Chromium, 100);
b.tags = vec!["rust".into(), "cli".into()];
let id = s.upsert(&b).unwrap();
let tags = s.tags_for(&id).unwrap();
assert_eq!(tags.len(), 2);
assert!(tags.contains(&"rust".to_string()));
assert!(tags.contains(&"cli".to_string()));
}
#[test]
fn upsert_existing_canonical_updates_and_replaces_tags() {
let mut s = open_in_memory().unwrap();
let mut b1 = mk(
"https://example.com/x",
"Old Title",
SourceKind::Chromium,
100,
);
b1.tags = vec!["old".into()];
let id = s.upsert(&b1).unwrap();
let mut b2 = mk(
"https://example.com/x",
"New Title",
SourceKind::Chromium,
200,
);
b2.tags = vec!["new".into()];
let id2 = s.upsert(&b2).unwrap();
assert_eq!(id, id2);
let row = s.by_canonical("https://example.com/x").unwrap().unwrap();
assert_eq!(row.title, "New Title");
assert_eq!(row.updated_at.timestamp(), 200);
assert_eq!(s.tags_for(&id).unwrap(), vec!["new".to_string()]);
}
#[test]
fn by_canonical_returns_some_or_none() {
let mut s = open_in_memory().unwrap();
let b = mk("https://example.com/x", "X", SourceKind::Chromium, 100);
s.upsert(&b).unwrap();
let found = s.by_canonical("https://example.com/x").unwrap();
assert!(found.is_some());
let missing = s.by_canonical("https://example.com/missing").unwrap();
assert!(missing.is_none());
}
#[test]
fn list_paginates_by_last_seen_then_id() {
let mut s = open_in_memory().unwrap();
let mut ids = Vec::new();
for i in 0..5i64 {
let mut b = mk(
&format!("https://example.com/{i}"),
&format!("B{i}"),
SourceKind::Chromium,
100 + i,
);
b.id = BookmarkId(format!("id-{i:02}"));
ids.push(s.upsert(&b).unwrap());
}
let all = s.list(10, 0).unwrap();
assert_eq!(all.len(), 5);
let titles: Vec<&str> = all.iter().map(|b| b.title.as_str()).collect();
assert_eq!(titles, vec!["B4", "B3", "B2", "B1", "B0"]);
let page = s.list(2, 1).unwrap();
assert_eq!(page.len(), 2);
assert_eq!(page[0].title, "B3");
assert_eq!(page[1].title, "B2");
let _ = ids;
}
#[test]
fn count_returns_total() {
let mut s = open_in_memory().unwrap();
assert_eq!(s.count().unwrap(), 0);
s.upsert(&mk("https://example.com/a", "A", SourceKind::Chromium, 100))
.unwrap();
s.upsert(&mk("https://example.com/b", "B", SourceKind::Chromium, 200))
.unwrap();
s.upsert(&mk("https://example.com/c", "C", SourceKind::Chromium, 300))
.unwrap();
assert_eq!(s.count().unwrap(), 3);
}
#[test]
fn delete_soft_archives_and_reinsert_works() {
let mut s = open_in_memory().unwrap();
let b = mk("https://example.com/a", "A", SourceKind::Chromium, 100);
let id = s.upsert(&b).unwrap();
s.delete(&id).unwrap();
assert_eq!(s.count().unwrap(), 0);
assert_eq!(s.count_all().unwrap(), 1);
let b2 = mk("https://example.com/a", "A-v2", SourceKind::Chromium, 200);
let id2 = s.upsert(&b2).unwrap();
assert_eq!(s.count().unwrap(), 1);
assert_eq!(s.count_all().unwrap(), 2); assert_ne!(id, id2); let fetched = s.by_canonical("https://example.com/a").unwrap().unwrap();
assert_eq!(fetched.title, "A-v2");
}
#[test]
fn tags_set_replaces_and_index_rebuilt() {
let mut s = open_in_memory().unwrap();
let mut b = mk("https://example.com/a", "A", SourceKind::Chromium, 100);
b.tags = vec!["alpha".into(), "beta".into()];
let id = s.upsert(&b).unwrap();
s.set_tags(&id, &["gamma".into()]).unwrap();
assert_eq!(s.tags_for(&id).unwrap(), vec!["gamma".to_string()]);
s.set_tags(&id, &["delta".into(), "delta".into(), "delta".into()])
.unwrap();
assert_eq!(s.tags_for(&id).unwrap(), vec!["delta".to_string()]);
s.set_tags(&id, &["keep".into(), "drop".into()]).unwrap();
let before: i64 = s
.connection()
.query_row(
"SELECT COUNT(*) FROM tags WHERE tag IN ('keep','drop')",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(before, 2);
s.delete(&id).unwrap();
let after_soft: i64 = s
.connection()
.query_row(
"SELECT COUNT(*) FROM tags WHERE tag IN ('keep','drop')",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(after_soft, 2, "soft-delete must NOT cascade-delete tags");
}
#[test]
fn migrator_runs_each_migration_once_and_user_version_matches() {
let dir = tempdir().unwrap();
let path = dir.path().join("store.db");
let s = open_store(&path).unwrap();
let version: i64 = s
.connection()
.query_row("PRAGMA user_version", [], |r| r.get(0))
.unwrap();
assert_eq!(version, 1);
let rows: i64 = s
.connection()
.query_row("SELECT COUNT(*) FROM schema_migrations", [], |r| r.get(0))
.unwrap();
assert_eq!(rows, 1);
drop(s);
let s = open_store(&path).unwrap();
let version2: i64 = s
.connection()
.query_row("PRAGMA user_version", [], |r| r.get(0))
.unwrap();
assert_eq!(version2, 1);
let rows2: i64 = s
.connection()
.query_row("SELECT COUNT(*) FROM schema_migrations", [], |r| r.get(0))
.unwrap();
assert_eq!(rows2, 1);
}
#[test]
fn migrator_on_newer_db_returns_typed_error() {
let dir = tempdir().unwrap();
let path = dir.path().join("store.db");
let s = open_store(&path).unwrap();
s.connection()
.pragma_update(None, "user_version", 999)
.unwrap();
drop(s);
let err = open_store(&path).unwrap_err();
let msg = format!("{err:?}");
assert!(
msg.contains("newer schema"),
"expected 'newer schema' error, got: {msg}"
);
}
#[test]
fn concurrent_writer_and_reader() {
let dir = tempdir().unwrap();
let path = dir.path().join("store.db");
let _ = open_store(&path).unwrap();
let path_arc: Arc<PathBuf> = Arc::new(path.clone());
let writer_path = Arc::clone(&path_arc);
let writer = thread::spawn(move || {
let mut s = open_store(&writer_path).unwrap();
for i in 0..20i64 {
let mut b = mk(
&format!("https://example.com/w{i}"),
&format!("W{i}"),
SourceKind::Chromium,
1_000 + i,
);
b.id = BookmarkId(format!("wid-{i:02}"));
s.upsert(&b).unwrap();
}
});
let reader_path = Arc::clone(&path_arc);
let reader = thread::spawn(move || {
let s = open_store(&reader_path).unwrap();
let mut seen = 0;
for _ in 0..100 {
seen = s.count().unwrap();
if seen > 0 {
break;
}
thread::sleep(std::time::Duration::from_millis(5));
}
seen
});
let seen_by_reader = reader.join().expect("reader thread");
writer.join().expect("writer thread");
assert!(
seen_by_reader >= 1,
"reader starved; saw {seen_by_reader} rows"
);
let s = open_store(&path).unwrap();
assert_eq!(s.count().unwrap(), 20);
}
#[test]
fn archived_excluded_from_default_list() {
let mut s = open_in_memory().unwrap();
let b1 = mk("https://example.com/a", "A", SourceKind::Chromium, 100);
let b2 = mk("https://example.com/b", "B", SourceKind::Chromium, 200);
let id1 = s.upsert(&b1).unwrap();
s.upsert(&b2).unwrap();
s.delete(&id1).unwrap();
let list = s.list(10, 0).unwrap();
assert_eq!(list.len(), 1);
assert_eq!(list[0].title, "B");
assert!(s.by_canonical("https://example.com/a").unwrap().is_none());
assert!(s.by_canonical("https://example.com/b").unwrap().is_some());
}
#[test]
fn empty_store_returns_empty_list() {
let s = open_in_memory().unwrap();
let list = s.list(10, 0).unwrap();
assert!(list.is_empty());
assert_eq!(s.count().unwrap(), 0);
assert_eq!(s.count_all().unwrap(), 0);
}
#[test]
fn raw_payload_round_trip_preserved() {
let mut s = open_in_memory().unwrap();
let mut b = mk("https://example.com/a", "A", SourceKind::Chromium, 100);
let payload = serde_json::json!({
"browser": "chrome",
"profile": "Default",
"date_added": "13350000000000001",
"folder_path": ["Bookmarks bar", "Work"],
});
b.source.raw = Some(payload.clone());
let id = s.upsert(&b).unwrap();
let row = s.by_canonical("https://example.com/a").unwrap().unwrap();
assert_eq!(row.id, id);
assert_eq!(row.source.raw.as_ref(), Some(&payload));
}
#[test]
fn ulid_generation_is_monotonic() {
let first = ulid::Ulid::from_string(&BookmarkId::generate().0).unwrap();
std::thread::sleep(std::time::Duration::from_millis(2));
let second = ulid::Ulid::from_string(&BookmarkId::generate().0).unwrap();
let first_ms = first.timestamp_ms();
let second_ms = second.timestamp_ms();
assert!(
second_ms >= first_ms,
"ULID timestamp went backwards: {first_ms} -> {second_ms}"
);
let sample = BookmarkId::generate().0;
assert_eq!(sample.len(), 26);
for c in sample.chars() {
assert!(c.is_ascii_alphanumeric(), "non-base32 char in ULID: {c}");
}
}
#[allow(dead_code)]
fn _check_fixture_exists() {
let _ = fixture_path();
}
#[allow(dead_code)]
fn _check_connection_type() {
let _: fn(&Connection) -> &Connection = |c| c;
}