use lantern::entities::{Entity, EntityKind, record_chunk_entities};
use lantern::sessions::{
RelatedSessionsOptions, SessionListOptions, TemporallyRelatedSessionsOptions, list_sessions,
related_sessions, temporally_related_sessions,
};
use lantern::store::Store;
use rusqlite::params;
use tempfile::tempdir;
fn seed_source(store: &mut Store, source_id: &str) {
let conn = store.conn();
conn.execute(
"INSERT INTO sources (id, uri, path, kind, bytes, content_sha256, mtime_unix, ingested_at)
VALUES (?1, ?2, NULL, 'application/jsonl', 0, ?3, NULL, 0)",
params![
source_id,
format!("mem://{source_id}"),
format!("sha-{source_id}")
],
)
.unwrap();
}
fn seed_chunk(
store: &mut Store,
chunk_id: &str,
source_id: &str,
ordinal: i64,
session_id: Option<&str>,
timestamp_unix: Option<i64>,
) {
let conn = store.conn();
conn.execute(
"INSERT INTO chunks (id, source_id, ordinal, byte_start, byte_end, char_count, text,
sha256, created_at, session_id, timestamp_unix)
VALUES (?1, ?2, ?3, 0, 0, 0, '', '', 0, ?4, ?5)",
params![chunk_id, source_id, ordinal, session_id, timestamp_unix],
)
.unwrap();
}
fn seed_chunk_with_project(
store: &mut Store,
chunk_id: &str,
source_id: &str,
ordinal: i64,
session_id: Option<&str>,
timestamp_unix: Option<i64>,
project: Option<&str>,
) {
let conn = store.conn();
conn.execute(
"INSERT INTO chunks (id, source_id, ordinal, byte_start, byte_end, char_count, text,
sha256, created_at, session_id, timestamp_unix, project)
VALUES (?1, ?2, ?3, 0, 0, 0, '', '', 0, ?4, ?5, ?6)",
params![
chunk_id,
source_id,
ordinal,
session_id,
timestamp_unix,
project
],
)
.unwrap();
}
fn seed_chunk_with_user(
store: &mut Store,
chunk_id: &str,
source_id: &str,
ordinal: i64,
session_id: Option<&str>,
timestamp_unix: Option<i64>,
user: Option<&str>,
) {
let conn = store.conn();
conn.execute(
"INSERT INTO chunks (id, source_id, ordinal, byte_start, byte_end, char_count, text,
sha256, created_at, session_id, timestamp_unix, user)
VALUES (?1, ?2, ?3, 0, 0, 0, '', '', 0, ?4, ?5, ?6)",
params![
chunk_id,
source_id,
ordinal,
session_id,
timestamp_unix,
user
],
)
.unwrap();
}
fn seed_chunk_with_topic(
store: &mut Store,
chunk_id: &str,
source_id: &str,
ordinal: i64,
session_id: Option<&str>,
timestamp_unix: Option<i64>,
topic: Option<&str>,
) {
let conn = store.conn();
conn.execute(
"INSERT INTO chunks (id, source_id, ordinal, byte_start, byte_end, char_count, text,
sha256, created_at, session_id, timestamp_unix, topic)
VALUES (?1, ?2, ?3, 0, 0, 0, '', '', 0, ?4, ?5, ?6)",
params![
chunk_id,
source_id,
ordinal,
session_id,
timestamp_unix,
topic
],
)
.unwrap();
}
fn seed_chunk_with_thread(
store: &mut Store,
chunk_id: &str,
source_id: &str,
ordinal: i64,
session_id: Option<&str>,
timestamp_unix: Option<i64>,
thread: Option<&str>,
) {
let conn = store.conn();
conn.execute(
"INSERT INTO chunks (id, source_id, ordinal, byte_start, byte_end, char_count, text,
sha256, created_at, session_id, timestamp_unix, thread)
VALUES (?1, ?2, ?3, 0, 0, 0, '', '', 0, ?4, ?5, ?6)",
params![
chunk_id,
source_id,
ordinal,
session_id,
timestamp_unix,
thread
],
)
.unwrap();
}
#[test]
fn empty_store_returns_empty_report() {
let dir = tempdir().unwrap();
let store = Store::initialize(&dir.path().join("store")).unwrap();
let report = list_sessions(store.conn(), &SessionListOptions::default()).unwrap();
assert!(report.entries.is_empty());
assert_eq!(report.total_sessions, 0);
}
#[test]
fn excludes_chunks_without_session_id() {
let dir = tempdir().unwrap();
let mut store = Store::initialize(&dir.path().join("store")).unwrap();
seed_source(&mut store, "src1");
seed_chunk(&mut store, "c1", "src1", 0, Some("sess-A"), Some(100));
seed_chunk(&mut store, "c2", "src1", 1, None, None);
seed_chunk(&mut store, "c3", "src1", 2, Some("sess-A"), Some(200));
let report = list_sessions(store.conn(), &SessionListOptions::default()).unwrap();
assert_eq!(report.total_sessions, 1);
assert_eq!(report.entries.len(), 1);
assert_eq!(report.entries[0].session_id, "sess-A");
assert_eq!(report.entries[0].chunk_count, 2);
}
#[test]
fn groups_chunks_by_session_id_with_source_counts() {
let dir = tempdir().unwrap();
let mut store = Store::initialize(&dir.path().join("store")).unwrap();
seed_source(&mut store, "src1");
seed_source(&mut store, "src2");
seed_chunk(&mut store, "c1", "src1", 0, Some("sess-A"), Some(100));
seed_chunk(&mut store, "c2", "src2", 0, Some("sess-A"), Some(200));
seed_chunk(&mut store, "c3", "src2", 1, Some("sess-A"), Some(300));
seed_chunk(&mut store, "c4", "src2", 2, Some("sess-B"), Some(400));
let report = list_sessions(store.conn(), &SessionListOptions::default()).unwrap();
assert_eq!(report.total_sessions, 2);
assert_eq!(report.entries.len(), 2);
assert_eq!(report.entries[0].session_id, "sess-A");
assert_eq!(report.entries[0].chunk_count, 3);
assert_eq!(report.entries[0].source_count, 2);
assert_eq!(report.entries[1].session_id, "sess-B");
assert_eq!(report.entries[1].chunk_count, 1);
assert_eq!(report.entries[1].source_count, 1);
}
#[test]
fn reports_first_and_last_timestamp_when_present() {
let dir = tempdir().unwrap();
let mut store = Store::initialize(&dir.path().join("store")).unwrap();
seed_source(&mut store, "src1");
seed_chunk(&mut store, "c1", "src1", 0, Some("sess-T"), Some(300));
seed_chunk(&mut store, "c2", "src1", 1, Some("sess-T"), Some(100));
seed_chunk(&mut store, "c3", "src1", 2, Some("sess-T"), Some(200));
let report = list_sessions(store.conn(), &SessionListOptions::default()).unwrap();
assert_eq!(report.entries.len(), 1);
assert_eq!(report.entries[0].first_timestamp_unix, Some(100));
assert_eq!(report.entries[0].last_timestamp_unix, Some(300));
}
#[test]
fn timestamps_are_none_when_no_chunk_has_one() {
let dir = tempdir().unwrap();
let mut store = Store::initialize(&dir.path().join("store")).unwrap();
seed_source(&mut store, "src1");
seed_chunk(&mut store, "c1", "src1", 0, Some("sess-T"), None);
seed_chunk(&mut store, "c2", "src1", 1, Some("sess-T"), None);
let report = list_sessions(store.conn(), &SessionListOptions::default()).unwrap();
assert_eq!(report.entries.len(), 1);
assert_eq!(report.entries[0].first_timestamp_unix, None);
assert_eq!(report.entries[0].last_timestamp_unix, None);
}
#[test]
fn orders_by_chunk_count_desc_then_session_id_asc() {
let dir = tempdir().unwrap();
let mut store = Store::initialize(&dir.path().join("store")).unwrap();
seed_source(&mut store, "src1");
seed_chunk(&mut store, "c1", "src1", 0, Some("sess-big"), Some(10));
seed_chunk(&mut store, "c2", "src1", 1, Some("sess-big"), Some(20));
seed_chunk(&mut store, "c3", "src1", 2, Some("sess-big"), Some(30));
seed_chunk(&mut store, "c4", "src1", 3, Some("sess-z"), Some(40));
seed_chunk(&mut store, "c5", "src1", 4, Some("sess-a"), Some(50));
let report = list_sessions(store.conn(), &SessionListOptions::default()).unwrap();
assert_eq!(report.entries.len(), 3);
assert_eq!(report.entries[0].session_id, "sess-big");
assert_eq!(report.entries[1].session_id, "sess-a");
assert_eq!(report.entries[2].session_id, "sess-z");
}
#[test]
fn limit_truncates_but_total_sessions_is_full() {
let dir = tempdir().unwrap();
let mut store = Store::initialize(&dir.path().join("store")).unwrap();
seed_source(&mut store, "src1");
for i in 0..5 {
seed_chunk(
&mut store,
&format!("c{i}"),
"src1",
i as i64,
Some(&format!("sess-{i}")),
Some(i as i64),
);
}
let opts = SessionListOptions { limit: Some(2) };
let report = list_sessions(store.conn(), &opts).unwrap();
assert_eq!(report.total_sessions, 5);
assert_eq!(report.entries.len(), 2);
}
fn link_entities(store: &mut Store, chunk_id: &str, entities: &[Entity], now: i64) {
let conn = store.conn_mut();
let tx = conn.transaction().unwrap();
record_chunk_entities(&tx, chunk_id, entities, now).unwrap();
tx.commit().unwrap();
}
#[test]
fn related_sessions_errors_on_unknown_session() {
let dir = tempdir().unwrap();
let store = Store::initialize(&dir.path().join("store")).unwrap();
let err = related_sessions(store.conn(), "ghost", &RelatedSessionsOptions::default())
.expect_err("unknown session must error");
assert!(err.to_string().contains("no session with id"));
}
#[test]
fn related_sessions_returns_empty_when_no_shared_entities() {
let dir = tempdir().unwrap();
let mut store = Store::initialize(&dir.path().join("store")).unwrap();
seed_source(&mut store, "src1");
seed_chunk(&mut store, "c1", "src1", 0, Some("sess-A"), Some(100));
seed_chunk(&mut store, "c2", "src1", 1, Some("sess-B"), Some(200));
link_entities(
&mut store,
"c1",
&[Entity {
kind: EntityKind::Url,
value: "https://only-in-a.test".into(),
}],
1,
);
link_entities(
&mut store,
"c2",
&[Entity {
kind: EntityKind::Url,
value: "https://only-in-b.test".into(),
}],
2,
);
let report =
related_sessions(store.conn(), "sess-A", &RelatedSessionsOptions::default()).unwrap();
assert_eq!(report.source_chunk_count, 1);
assert_eq!(report.source_entity_count, 1);
assert_eq!(report.total_related, 0);
assert!(report.sessions.is_empty());
}
#[test]
fn related_sessions_ranks_by_shared_entity_count_then_session_id() {
let dir = tempdir().unwrap();
let mut store = Store::initialize(&dir.path().join("store")).unwrap();
seed_source(&mut store, "src1");
seed_chunk(&mut store, "c1", "src1", 0, Some("sess-source"), Some(100));
seed_chunk(&mut store, "c2", "src1", 1, Some("sess-source"), Some(110));
seed_chunk(&mut store, "c3", "src1", 2, Some("sess-pair"), Some(200));
seed_chunk(&mut store, "c4", "src1", 3, Some("sess-pair"), Some(210));
seed_chunk(&mut store, "c5", "src1", 4, Some("sess-single"), Some(300));
seed_chunk(&mut store, "c6", "src1", 5, Some("sess-aaa"), Some(400));
seed_chunk(&mut store, "c7", "src1", 6, Some("sess-other"), Some(500));
let url = Entity {
kind: EntityKind::Url,
value: "https://shared.test/a".into(),
};
let mention = Entity {
kind: EntityKind::Mention,
value: "@shared".into(),
};
let unrelated = Entity {
kind: EntityKind::Url,
value: "https://unrelated.test".into(),
};
link_entities(&mut store, "c1", &[url.clone()], 1);
link_entities(&mut store, "c2", &[mention.clone()], 2);
link_entities(&mut store, "c3", &[url.clone()], 3);
link_entities(&mut store, "c4", &[mention.clone()], 4);
link_entities(&mut store, "c5", &[url.clone()], 5);
link_entities(&mut store, "c6", &[url.clone()], 6);
link_entities(&mut store, "c7", &[unrelated], 7);
let report = related_sessions(
store.conn(),
"sess-source",
&RelatedSessionsOptions::default(),
)
.unwrap();
assert_eq!(report.source_chunk_count, 2);
assert_eq!(report.source_entity_count, 2);
assert_eq!(report.total_related, 3);
let session_ids: Vec<&str> = report
.sessions
.iter()
.map(|s| s.session_id.as_str())
.collect();
assert_eq!(session_ids, vec!["sess-pair", "sess-aaa", "sess-single"]);
assert_eq!(report.sessions[0].shared_entity_count, 2);
assert_eq!(report.sessions[1].shared_entity_count, 1);
assert_eq!(report.sessions[2].shared_entity_count, 1);
assert!(
report
.sessions
.iter()
.all(|s| s.session_id != "sess-source")
);
}
#[test]
fn related_sessions_excludes_chunks_without_session_id() {
let dir = tempdir().unwrap();
let mut store = Store::initialize(&dir.path().join("store")).unwrap();
seed_source(&mut store, "src1");
seed_chunk(&mut store, "c1", "src1", 0, Some("sess-source"), Some(100));
seed_chunk(&mut store, "c2", "src1", 1, None, Some(200));
let shared = Entity {
kind: EntityKind::Url,
value: "https://shared.test/x".into(),
};
link_entities(&mut store, "c1", &[shared.clone()], 1);
link_entities(&mut store, "c2", &[shared], 2);
let report = related_sessions(
store.conn(),
"sess-source",
&RelatedSessionsOptions::default(),
)
.unwrap();
assert_eq!(report.total_related, 0);
assert!(report.sessions.is_empty());
}
#[test]
fn related_sessions_aggregates_chunk_count_and_timestamps() {
let dir = tempdir().unwrap();
let mut store = Store::initialize(&dir.path().join("store")).unwrap();
seed_source(&mut store, "src1");
seed_chunk(
&mut store,
"c_src",
"src1",
0,
Some("sess-source"),
Some(100),
);
seed_chunk(&mut store, "c_r1", "src1", 1, Some("sess-rel"), Some(200));
seed_chunk(&mut store, "c_r2", "src1", 2, Some("sess-rel"), Some(400));
seed_chunk(&mut store, "c_r3", "src1", 3, Some("sess-rel"), Some(300));
let shared = Entity {
kind: EntityKind::Url,
value: "https://shared.test/y".into(),
};
link_entities(&mut store, "c_src", &[shared.clone()], 1);
link_entities(&mut store, "c_r2", &[shared], 2);
let report = related_sessions(
store.conn(),
"sess-source",
&RelatedSessionsOptions::default(),
)
.unwrap();
assert_eq!(report.sessions.len(), 1);
let rel = &report.sessions[0];
assert_eq!(rel.session_id, "sess-rel");
assert_eq!(rel.shared_entity_count, 1);
assert_eq!(rel.shared_chunk_count, 1);
assert_eq!(rel.chunk_count, 3);
assert_eq!(rel.first_timestamp_unix, Some(200));
assert_eq!(rel.last_timestamp_unix, Some(400));
}
#[test]
fn related_sessions_limit_truncates_but_total_is_full() {
let dir = tempdir().unwrap();
let mut store = Store::initialize(&dir.path().join("store")).unwrap();
seed_source(&mut store, "src1");
seed_chunk(&mut store, "c0", "src1", 0, Some("sess-source"), Some(0));
let shared = Entity {
kind: EntityKind::Url,
value: "https://shared.test/hub".into(),
};
link_entities(&mut store, "c0", &[shared.clone()], 0);
for i in 0..4 {
let cid = format!("crel{i}");
let sid = format!("sess-rel-{i}");
seed_chunk(
&mut store,
&cid,
"src1",
(i + 1) as i64,
Some(&sid),
Some(100 + i as i64),
);
link_entities(&mut store, &cid, &[shared.clone()], i as i64 + 1);
}
let opts = RelatedSessionsOptions {
limit: Some(2),
with_entities: None,
};
let report = related_sessions(store.conn(), "sess-source", &opts).unwrap();
assert_eq!(report.total_related, 4);
assert_eq!(report.sessions.len(), 2);
}
#[test]
fn related_sessions_default_path_omits_shared_entity_evidence() {
let dir = tempdir().unwrap();
let mut store = Store::initialize(&dir.path().join("store")).unwrap();
seed_source(&mut store, "src1");
seed_chunk(&mut store, "src", "src1", 0, Some("sess-source"), Some(100));
seed_chunk(&mut store, "rel", "src1", 1, Some("sess-rel"), Some(200));
let shared = Entity {
kind: EntityKind::Hashtag,
value: "#shared".into(),
};
link_entities(&mut store, "src", &[shared.clone()], 1);
link_entities(&mut store, "rel", &[shared], 2);
let report = related_sessions(
store.conn(),
"sess-source",
&RelatedSessionsOptions::default(),
)
.unwrap();
assert_eq!(report.sessions.len(), 1);
assert!(report.sessions[0].shared_entities.is_none());
}
#[test]
fn related_sessions_with_entities_returns_only_shared_entities() {
let dir = tempdir().unwrap();
let mut store = Store::initialize(&dir.path().join("store")).unwrap();
seed_source(&mut store, "src1");
seed_chunk(
&mut store,
"src-1",
"src1",
0,
Some("sess-source"),
Some(100),
);
seed_chunk(
&mut store,
"src-2",
"src1",
1,
Some("sess-source"),
Some(110),
);
seed_chunk(&mut store, "rel-1", "src1", 2, Some("sess-rel"), Some(200));
seed_chunk(&mut store, "rel-2", "src1", 3, Some("sess-rel"), Some(210));
let shared_url = Entity {
kind: EntityKind::Url,
value: "https://shared.test/a".into(),
};
let shared_tag = Entity {
kind: EntityKind::Hashtag,
value: "#shared".into(),
};
let related_only = Entity {
kind: EntityKind::Mention,
value: "@related-only".into(),
};
link_entities(&mut store, "src-1", &[shared_url.clone()], 1);
link_entities(&mut store, "src-2", &[shared_tag.clone()], 2);
link_entities(
&mut store,
"rel-1",
&[shared_url.clone(), related_only.clone()],
3,
);
link_entities(&mut store, "rel-2", &[shared_tag.clone()], 4);
let report = related_sessions(
store.conn(),
"sess-source",
&RelatedSessionsOptions {
limit: Some(5),
with_entities: Some(5),
},
)
.unwrap();
let entities = report.sessions[0]
.shared_entities
.as_ref()
.expect("with_entities should populate shared entity evidence");
let values: Vec<&str> = entities.iter().map(|e| e.value.as_str()).collect();
assert_eq!(values, vec!["#shared", "https://shared.test/a"]);
}
#[test]
fn related_sessions_with_entities_orders_and_truncates_deterministically() {
let dir = tempdir().unwrap();
let mut store = Store::initialize(&dir.path().join("store")).unwrap();
seed_source(&mut store, "src1");
seed_chunk(
&mut store,
"src-1",
"src1",
0,
Some("sess-source"),
Some(100),
);
seed_chunk(
&mut store,
"src-2",
"src1",
1,
Some("sess-source"),
Some(110),
);
seed_chunk(
&mut store,
"src-3",
"src1",
2,
Some("sess-source"),
Some(120),
);
seed_chunk(&mut store, "rel-1", "src1", 3, Some("sess-rel"), Some(200));
seed_chunk(&mut store, "rel-2", "src1", 4, Some("sess-rel"), Some(210));
seed_chunk(&mut store, "rel-3", "src1", 5, Some("sess-rel"), Some(220));
let alpha = Entity {
kind: EntityKind::Hashtag,
value: "#alpha".into(),
};
let beta = Entity {
kind: EntityKind::Hashtag,
value: "#beta".into(),
};
let url = Entity {
kind: EntityKind::Url,
value: "https://shared.test/a".into(),
};
link_entities(&mut store, "src-1", &[alpha.clone()], 1);
link_entities(&mut store, "src-2", &[beta.clone()], 2);
link_entities(&mut store, "src-3", &[url.clone()], 3);
link_entities(
&mut store,
"rel-1",
&[alpha.clone(), beta.clone(), url.clone()],
4,
);
link_entities(&mut store, "rel-2", &[alpha.clone(), url.clone()], 5);
link_entities(&mut store, "rel-3", &[beta.clone()], 6);
let report = related_sessions(
store.conn(),
"sess-source",
&RelatedSessionsOptions {
limit: Some(5),
with_entities: Some(2),
},
)
.unwrap();
let entities = report.sessions[0]
.shared_entities
.as_ref()
.expect("with_entities should populate shared entity evidence");
let values: Vec<&str> = entities.iter().map(|e| e.value.as_str()).collect();
assert_eq!(values, vec!["#alpha", "#beta"]);
}
#[test]
fn related_sessions_surfaces_project_when_all_chunks_agree() {
let dir = tempdir().unwrap();
let mut store = Store::initialize(&dir.path().join("store")).unwrap();
seed_source(&mut store, "src1");
seed_chunk_with_project(
&mut store,
"c_src",
"src1",
0,
Some("sess-source"),
Some(100),
Some("lantern"),
);
seed_chunk_with_project(
&mut store,
"c_r1",
"src1",
1,
Some("sess-rel"),
Some(200),
Some("docs"),
);
seed_chunk_with_project(
&mut store,
"c_r2",
"src1",
2,
Some("sess-rel"),
Some(300),
Some("docs"),
);
let shared = Entity {
kind: EntityKind::Url,
value: "https://shared.test/p".into(),
};
link_entities(&mut store, "c_src", &[shared.clone()], 1);
link_entities(&mut store, "c_r1", &[shared], 2);
let report = related_sessions(
store.conn(),
"sess-source",
&RelatedSessionsOptions::default(),
)
.unwrap();
assert_eq!(report.sessions.len(), 1);
let rel = &report.sessions[0];
assert_eq!(rel.session_id, "sess-rel");
assert_eq!(rel.project.as_deref(), Some("docs"));
}
#[test]
fn related_sessions_omits_project_when_chunks_disagree() {
let dir = tempdir().unwrap();
let mut store = Store::initialize(&dir.path().join("store")).unwrap();
seed_source(&mut store, "src1");
seed_chunk_with_project(
&mut store,
"c_src",
"src1",
0,
Some("sess-source"),
Some(100),
Some("lantern"),
);
seed_chunk_with_project(
&mut store,
"c_r1",
"src1",
1,
Some("sess-rel"),
Some(200),
Some("docs"),
);
seed_chunk_with_project(
&mut store,
"c_r2",
"src1",
2,
Some("sess-rel"),
Some(300),
Some("other-repo"),
);
let shared = Entity {
kind: EntityKind::Url,
value: "https://shared.test/p".into(),
};
link_entities(&mut store, "c_src", &[shared.clone()], 1);
link_entities(&mut store, "c_r1", &[shared], 2);
let report = related_sessions(
store.conn(),
"sess-source",
&RelatedSessionsOptions::default(),
)
.unwrap();
assert_eq!(report.sessions.len(), 1);
assert_eq!(report.sessions[0].project, None);
}
#[test]
fn related_sessions_omits_project_when_any_chunk_missing_it() {
let dir = tempdir().unwrap();
let mut store = Store::initialize(&dir.path().join("store")).unwrap();
seed_source(&mut store, "src1");
seed_chunk_with_project(
&mut store,
"c_src",
"src1",
0,
Some("sess-source"),
Some(100),
Some("lantern"),
);
seed_chunk_with_project(
&mut store,
"c_r1",
"src1",
1,
Some("sess-rel"),
Some(200),
Some("docs"),
);
seed_chunk_with_project(
&mut store,
"c_r2",
"src1",
2,
Some("sess-rel"),
Some(300),
None,
);
let shared = Entity {
kind: EntityKind::Url,
value: "https://shared.test/p".into(),
};
link_entities(&mut store, "c_src", &[shared.clone()], 1);
link_entities(&mut store, "c_r1", &[shared], 2);
let report = related_sessions(
store.conn(),
"sess-source",
&RelatedSessionsOptions::default(),
)
.unwrap();
assert_eq!(report.sessions.len(), 1);
assert_eq!(report.sessions[0].project, None);
}
#[test]
fn related_sessions_json_includes_project_only_when_present() {
let dir = tempdir().unwrap();
let mut store = Store::initialize(&dir.path().join("store")).unwrap();
seed_source(&mut store, "src1");
seed_chunk_with_project(
&mut store,
"c_src",
"src1",
0,
Some("sess-source"),
Some(100),
Some("lantern"),
);
seed_chunk_with_project(
&mut store,
"c_tagged",
"src1",
1,
Some("sess-tagged"),
Some(200),
Some("docs"),
);
seed_chunk(
&mut store,
"c_untagged",
"src1",
2,
Some("sess-untagged"),
Some(300),
);
let shared = Entity {
kind: EntityKind::Url,
value: "https://shared.test/p".into(),
};
link_entities(&mut store, "c_src", &[shared.clone()], 1);
link_entities(&mut store, "c_tagged", &[shared.clone()], 2);
link_entities(&mut store, "c_untagged", &[shared], 3);
let report = related_sessions(
store.conn(),
"sess-source",
&RelatedSessionsOptions::default(),
)
.unwrap();
let json = serde_json::to_value(&report).unwrap();
let sessions = json["sessions"].as_array().unwrap();
let tagged = sessions
.iter()
.find(|s| s["session_id"] == "sess-tagged")
.unwrap();
assert_eq!(tagged["project"], "docs");
let untagged = sessions
.iter()
.find(|s| s["session_id"] == "sess-untagged")
.unwrap();
assert!(
untagged.get("project").is_none(),
"untagged related session should omit project field, got: {untagged}"
);
}
#[test]
fn temporal_sessions_orders_by_gap_and_applies_window() {
let dir = tempdir().unwrap();
let mut store = Store::initialize(&dir.path().join("store")).unwrap();
seed_source(&mut store, "src1");
seed_chunk(
&mut store,
"source-1",
"src1",
0,
Some("sess-source"),
Some(100),
);
seed_chunk(
&mut store,
"source-2",
"src1",
1,
Some("sess-source"),
Some(150),
);
seed_chunk(
&mut store,
"overlap-1",
"src1",
2,
Some("sess-overlap"),
Some(140),
);
seed_chunk(
&mut store,
"overlap-2",
"src1",
3,
Some("sess-overlap"),
Some(180),
);
seed_chunk(
&mut store,
"near-1",
"src1",
4,
Some("sess-near"),
Some(210),
);
seed_chunk(&mut store, "far-1", "src1", 5, Some("sess-far"), Some(1000));
seed_chunk(
&mut store,
"untimed-1",
"src1",
6,
Some("sess-untimed"),
None,
);
let report = temporally_related_sessions(
store.conn(),
"sess-source",
&TemporallyRelatedSessionsOptions {
limit: Some(5),
window_secs: None,
},
)
.unwrap();
assert_eq!(report.source_first_timestamp_unix, 100);
assert_eq!(report.source_last_timestamp_unix, 150);
assert_eq!(report.total_related, 3);
let ids: Vec<&str> = report
.sessions
.iter()
.map(|s| s.session_id.as_str())
.collect();
assert_eq!(ids, vec!["sess-overlap", "sess-near", "sess-far"]);
assert_eq!(report.sessions[0].gap_secs, 0);
assert_eq!(report.sessions[0].overlap_secs, 10);
assert_eq!(report.sessions[1].gap_secs, 60);
assert_eq!(report.sessions[2].gap_secs, 850);
let windowed = temporally_related_sessions(
store.conn(),
"sess-source",
&TemporallyRelatedSessionsOptions {
limit: Some(5),
window_secs: Some(100),
},
)
.unwrap();
assert_eq!(windowed.total_related, 2);
let windowed_ids: Vec<&str> = windowed
.sessions
.iter()
.map(|s| s.session_id.as_str())
.collect();
assert_eq!(windowed_ids, vec!["sess-overlap", "sess-near"]);
}
#[test]
fn list_sessions_surfaces_project_when_all_chunks_agree() {
let dir = tempdir().unwrap();
let mut store = Store::initialize(&dir.path().join("store")).unwrap();
seed_source(&mut store, "src1");
seed_chunk_with_project(
&mut store,
"c1",
"src1",
0,
Some("sess-A"),
Some(100),
Some("lantern"),
);
seed_chunk_with_project(
&mut store,
"c2",
"src1",
1,
Some("sess-A"),
Some(200),
Some("lantern"),
);
let report = list_sessions(store.conn(), &SessionListOptions::default()).unwrap();
assert_eq!(report.entries.len(), 1);
assert_eq!(report.entries[0].project.as_deref(), Some("lantern"));
}
#[test]
fn list_sessions_omits_project_when_chunks_disagree() {
let dir = tempdir().unwrap();
let mut store = Store::initialize(&dir.path().join("store")).unwrap();
seed_source(&mut store, "src1");
seed_chunk_with_project(
&mut store,
"c1",
"src1",
0,
Some("sess-mixed"),
Some(100),
Some("lantern"),
);
seed_chunk_with_project(
&mut store,
"c2",
"src1",
1,
Some("sess-mixed"),
Some(200),
Some("other-repo"),
);
let report = list_sessions(store.conn(), &SessionListOptions::default()).unwrap();
assert_eq!(report.entries.len(), 1);
assert_eq!(report.entries[0].project, None);
}
#[test]
fn list_sessions_omits_project_when_any_chunk_missing_it() {
let dir = tempdir().unwrap();
let mut store = Store::initialize(&dir.path().join("store")).unwrap();
seed_source(&mut store, "src1");
seed_chunk_with_project(
&mut store,
"c1",
"src1",
0,
Some("sess-partial"),
Some(100),
Some("lantern"),
);
seed_chunk_with_project(
&mut store,
"c2",
"src1",
1,
Some("sess-partial"),
Some(200),
None,
);
let report = list_sessions(store.conn(), &SessionListOptions::default()).unwrap();
assert_eq!(report.entries.len(), 1);
assert_eq!(report.entries[0].project, None);
}
#[test]
fn list_sessions_omits_project_when_no_chunks_have_it() {
let dir = tempdir().unwrap();
let mut store = Store::initialize(&dir.path().join("store")).unwrap();
seed_source(&mut store, "src1");
seed_chunk(
&mut store,
"c1",
"src1",
0,
Some("sess-untagged"),
Some(100),
);
seed_chunk(
&mut store,
"c2",
"src1",
1,
Some("sess-untagged"),
Some(200),
);
let report = list_sessions(store.conn(), &SessionListOptions::default()).unwrap();
assert_eq!(report.entries.len(), 1);
assert_eq!(report.entries[0].project, None);
}
#[test]
fn list_sessions_json_includes_project_only_when_present() {
let dir = tempdir().unwrap();
let mut store = Store::initialize(&dir.path().join("store")).unwrap();
seed_source(&mut store, "src1");
seed_chunk_with_project(
&mut store,
"c1",
"src1",
0,
Some("sess-tagged"),
Some(100),
Some("lantern"),
);
seed_chunk(
&mut store,
"c2",
"src1",
1,
Some("sess-untagged"),
Some(200),
);
let report = list_sessions(store.conn(), &SessionListOptions::default()).unwrap();
let json = serde_json::to_value(&report).unwrap();
let entries = json["entries"].as_array().unwrap();
let tagged = entries
.iter()
.find(|e| e["session_id"] == "sess-tagged")
.unwrap();
assert_eq!(tagged["project"], "lantern");
let untagged = entries
.iter()
.find(|e| e["session_id"] == "sess-untagged")
.unwrap();
assert!(
untagged.get("project").is_none(),
"untagged session should omit project field, got: {untagged}"
);
}
#[test]
fn list_sessions_surfaces_user_when_all_chunks_agree() {
let dir = tempdir().unwrap();
let mut store = Store::initialize(&dir.path().join("store")).unwrap();
seed_source(&mut store, "src1");
seed_chunk_with_user(
&mut store,
"c1",
"src1",
0,
Some("sess-A"),
Some(100),
Some("alice"),
);
seed_chunk_with_user(
&mut store,
"c2",
"src1",
1,
Some("sess-A"),
Some(200),
Some("alice"),
);
let report = list_sessions(store.conn(), &SessionListOptions::default()).unwrap();
assert_eq!(report.entries.len(), 1);
assert_eq!(report.entries[0].user.as_deref(), Some("alice"));
}
#[test]
fn list_sessions_omits_user_when_chunks_disagree() {
let dir = tempdir().unwrap();
let mut store = Store::initialize(&dir.path().join("store")).unwrap();
seed_source(&mut store, "src1");
seed_chunk_with_user(
&mut store,
"c1",
"src1",
0,
Some("sess-mixed"),
Some(100),
Some("alice"),
);
seed_chunk_with_user(
&mut store,
"c2",
"src1",
1,
Some("sess-mixed"),
Some(200),
Some("bob"),
);
let report = list_sessions(store.conn(), &SessionListOptions::default()).unwrap();
assert_eq!(report.entries.len(), 1);
assert_eq!(report.entries[0].user, None);
}
#[test]
fn list_sessions_omits_user_when_any_chunk_missing_it() {
let dir = tempdir().unwrap();
let mut store = Store::initialize(&dir.path().join("store")).unwrap();
seed_source(&mut store, "src1");
seed_chunk_with_user(
&mut store,
"c1",
"src1",
0,
Some("sess-partial"),
Some(100),
Some("alice"),
);
seed_chunk_with_user(
&mut store,
"c2",
"src1",
1,
Some("sess-partial"),
Some(200),
None,
);
let report = list_sessions(store.conn(), &SessionListOptions::default()).unwrap();
assert_eq!(report.entries.len(), 1);
assert_eq!(report.entries[0].user, None);
}
#[test]
fn list_sessions_omits_user_when_no_chunks_have_it() {
let dir = tempdir().unwrap();
let mut store = Store::initialize(&dir.path().join("store")).unwrap();
seed_source(&mut store, "src1");
seed_chunk(
&mut store,
"c1",
"src1",
0,
Some("sess-untagged"),
Some(100),
);
seed_chunk(
&mut store,
"c2",
"src1",
1,
Some("sess-untagged"),
Some(200),
);
let report = list_sessions(store.conn(), &SessionListOptions::default()).unwrap();
assert_eq!(report.entries.len(), 1);
assert_eq!(report.entries[0].user, None);
}
#[test]
fn list_sessions_json_includes_user_only_when_present() {
let dir = tempdir().unwrap();
let mut store = Store::initialize(&dir.path().join("store")).unwrap();
seed_source(&mut store, "src1");
seed_chunk_with_user(
&mut store,
"c1",
"src1",
0,
Some("sess-tagged"),
Some(100),
Some("alice"),
);
seed_chunk(
&mut store,
"c2",
"src1",
1,
Some("sess-untagged"),
Some(200),
);
let report = list_sessions(store.conn(), &SessionListOptions::default()).unwrap();
let json = serde_json::to_value(&report).unwrap();
let entries = json["entries"].as_array().unwrap();
let tagged = entries
.iter()
.find(|e| e["session_id"] == "sess-tagged")
.unwrap();
assert_eq!(tagged["user"], "alice");
let untagged = entries
.iter()
.find(|e| e["session_id"] == "sess-untagged")
.unwrap();
assert!(
untagged.get("user").is_none(),
"untagged session should omit user field, got: {untagged}"
);
}
#[test]
fn list_sessions_surfaces_topic_when_all_chunks_agree() {
let dir = tempdir().unwrap();
let mut store = Store::initialize(&dir.path().join("store")).unwrap();
seed_source(&mut store, "src1");
seed_chunk_with_topic(
&mut store,
"c1",
"src1",
0,
Some("sess-A"),
Some(100),
Some("onboarding"),
);
seed_chunk_with_topic(
&mut store,
"c2",
"src1",
1,
Some("sess-A"),
Some(200),
Some("onboarding"),
);
let report = list_sessions(store.conn(), &SessionListOptions::default()).unwrap();
assert_eq!(report.entries.len(), 1);
assert_eq!(report.entries[0].topic.as_deref(), Some("onboarding"));
}
#[test]
fn list_sessions_omits_topic_when_chunks_disagree() {
let dir = tempdir().unwrap();
let mut store = Store::initialize(&dir.path().join("store")).unwrap();
seed_source(&mut store, "src1");
seed_chunk_with_topic(
&mut store,
"c1",
"src1",
0,
Some("sess-mixed"),
Some(100),
Some("onboarding"),
);
seed_chunk_with_topic(
&mut store,
"c2",
"src1",
1,
Some("sess-mixed"),
Some(200),
Some("billing"),
);
let report = list_sessions(store.conn(), &SessionListOptions::default()).unwrap();
assert_eq!(report.entries.len(), 1);
assert_eq!(report.entries[0].topic, None);
}
#[test]
fn list_sessions_omits_topic_when_any_chunk_missing_it() {
let dir = tempdir().unwrap();
let mut store = Store::initialize(&dir.path().join("store")).unwrap();
seed_source(&mut store, "src1");
seed_chunk_with_topic(
&mut store,
"c1",
"src1",
0,
Some("sess-partial"),
Some(100),
Some("onboarding"),
);
seed_chunk_with_topic(
&mut store,
"c2",
"src1",
1,
Some("sess-partial"),
Some(200),
None,
);
let report = list_sessions(store.conn(), &SessionListOptions::default()).unwrap();
assert_eq!(report.entries.len(), 1);
assert_eq!(report.entries[0].topic, None);
}
#[test]
fn list_sessions_omits_topic_when_no_chunks_have_it() {
let dir = tempdir().unwrap();
let mut store = Store::initialize(&dir.path().join("store")).unwrap();
seed_source(&mut store, "src1");
seed_chunk(
&mut store,
"c1",
"src1",
0,
Some("sess-untagged"),
Some(100),
);
seed_chunk(
&mut store,
"c2",
"src1",
1,
Some("sess-untagged"),
Some(200),
);
let report = list_sessions(store.conn(), &SessionListOptions::default()).unwrap();
assert_eq!(report.entries.len(), 1);
assert_eq!(report.entries[0].topic, None);
}
#[test]
fn list_sessions_json_includes_topic_only_when_present() {
let dir = tempdir().unwrap();
let mut store = Store::initialize(&dir.path().join("store")).unwrap();
seed_source(&mut store, "src1");
seed_chunk_with_topic(
&mut store,
"c1",
"src1",
0,
Some("sess-tagged"),
Some(100),
Some("onboarding"),
);
seed_chunk(
&mut store,
"c2",
"src1",
1,
Some("sess-untagged"),
Some(200),
);
let report = list_sessions(store.conn(), &SessionListOptions::default()).unwrap();
let json = serde_json::to_value(&report).unwrap();
let entries = json["entries"].as_array().unwrap();
let tagged = entries
.iter()
.find(|e| e["session_id"] == "sess-tagged")
.unwrap();
assert_eq!(tagged["topic"], "onboarding");
let untagged = entries
.iter()
.find(|e| e["session_id"] == "sess-untagged")
.unwrap();
assert!(
untagged.get("topic").is_none(),
"untagged session should omit topic field, got: {untagged}"
);
}
#[test]
fn list_sessions_surfaces_thread_when_all_chunks_agree() {
let dir = tempdir().unwrap();
let mut store = Store::initialize(&dir.path().join("store")).unwrap();
seed_source(&mut store, "src1");
seed_chunk_with_thread(
&mut store,
"c1",
"src1",
0,
Some("sess-A"),
Some(100),
Some("telegram:ops"),
);
seed_chunk_with_thread(
&mut store,
"c2",
"src1",
1,
Some("sess-A"),
Some(200),
Some("telegram:ops"),
);
let report = list_sessions(store.conn(), &SessionListOptions::default()).unwrap();
assert_eq!(report.entries.len(), 1);
assert_eq!(report.entries[0].thread.as_deref(), Some("telegram:ops"));
}
#[test]
fn list_sessions_omits_thread_when_chunks_disagree() {
let dir = tempdir().unwrap();
let mut store = Store::initialize(&dir.path().join("store")).unwrap();
seed_source(&mut store, "src1");
seed_chunk_with_thread(
&mut store,
"c1",
"src1",
0,
Some("sess-mixed"),
Some(100),
Some("telegram:ops"),
);
seed_chunk_with_thread(
&mut store,
"c2",
"src1",
1,
Some("sess-mixed"),
Some(200),
Some("discord:eng"),
);
let report = list_sessions(store.conn(), &SessionListOptions::default()).unwrap();
assert_eq!(report.entries.len(), 1);
assert_eq!(report.entries[0].thread, None);
}
#[test]
fn list_sessions_omits_thread_when_any_chunk_missing_it() {
let dir = tempdir().unwrap();
let mut store = Store::initialize(&dir.path().join("store")).unwrap();
seed_source(&mut store, "src1");
seed_chunk_with_thread(
&mut store,
"c1",
"src1",
0,
Some("sess-partial"),
Some(100),
Some("telegram:ops"),
);
seed_chunk_with_thread(
&mut store,
"c2",
"src1",
1,
Some("sess-partial"),
Some(200),
None,
);
let report = list_sessions(store.conn(), &SessionListOptions::default()).unwrap();
assert_eq!(report.entries.len(), 1);
assert_eq!(report.entries[0].thread, None);
}
#[test]
fn list_sessions_omits_thread_when_no_chunks_have_it() {
let dir = tempdir().unwrap();
let mut store = Store::initialize(&dir.path().join("store")).unwrap();
seed_source(&mut store, "src1");
seed_chunk(
&mut store,
"c1",
"src1",
0,
Some("sess-untagged"),
Some(100),
);
seed_chunk(
&mut store,
"c2",
"src1",
1,
Some("sess-untagged"),
Some(200),
);
let report = list_sessions(store.conn(), &SessionListOptions::default()).unwrap();
assert_eq!(report.entries.len(), 1);
assert_eq!(report.entries[0].thread, None);
}
#[test]
fn list_sessions_json_includes_thread_only_when_present() {
let dir = tempdir().unwrap();
let mut store = Store::initialize(&dir.path().join("store")).unwrap();
seed_source(&mut store, "src1");
seed_chunk_with_thread(
&mut store,
"c1",
"src1",
0,
Some("sess-tagged"),
Some(100),
Some("telegram:ops"),
);
seed_chunk(
&mut store,
"c2",
"src1",
1,
Some("sess-untagged"),
Some(200),
);
let report = list_sessions(store.conn(), &SessionListOptions::default()).unwrap();
let json = serde_json::to_value(&report).unwrap();
let entries = json["entries"].as_array().unwrap();
let tagged = entries
.iter()
.find(|e| e["session_id"] == "sess-tagged")
.unwrap();
assert_eq!(tagged["thread"], "telegram:ops");
let untagged = entries
.iter()
.find(|e| e["session_id"] == "sess-untagged")
.unwrap();
assert!(
untagged.get("thread").is_none(),
"untagged session should omit thread field, got: {untagged}"
);
}