use std::{collections::BTreeMap, sync::Arc};
use chrono::{TimeZone, Utc};
use kcode_commit_session::{CommitRequest, ErrorKind, PlannedNode, commit_session};
use kcode_kweb_db::{Config, KwebDb, NoopGossip, ObjectId, Owner, WriterId};
use rusqlite::{Connection, params};
use tempfile::TempDir;
struct Fixture {
_root: TempDir,
database: KwebDb,
receipts: std::path::PathBuf,
}
impl Fixture {
fn new() -> Self {
let root = tempfile::tempdir().unwrap();
let signing_key = [7; 32];
let database = KwebDb::open(
root.path().join("kweb"),
Config {
signing_key,
writers_by_priority: vec![WriterId::from_signing_key(&signing_key)],
gossip: Arc::new(NoopGossip),
},
)
.unwrap();
Self {
receipts: root.path().join("identity.sqlite3"),
database,
_root: root,
}
}
}
fn node(owner: &str) -> PlannedNode {
PlannedNode {
short_name: "Node referring to pending:1".into(),
short_description: "Object pending:1.".into(),
long_description: "xpending:1 pending:1_suffix pending:1".into(),
owner: owner.into(),
fixed_connections: Vec::new(),
recent_connections: Vec::new(),
objects: vec!["pending:1".into()],
attach_session_archive: true,
}
}
fn request() -> CommitRequest {
CommitRequest {
idempotency_key: "session-1".into(),
author: "Kennedy".into(),
source_created_at: Utc.with_ymd_and_hms(2026, 7, 27, 1, 2, 3).unwrap(),
archive: b"archive object pending:1".to_vec(),
objects: BTreeMap::from([("pending:1".into(), b"encoded file".to_vec())]),
creates: BTreeMap::from([
("pending:2".into(), node("pending:3")),
("pending:3".into(), node("pending:2")),
]),
updates: BTreeMap::new(),
}
}
#[test]
fn one_commit_resolves_objects_circular_nodes_and_archive_attachment() {
let fixture = Fixture::new();
let receipt = commit_session(&fixture.database, &fixture.receipts, request()).unwrap();
assert!(receipt.transaction_id.is_some());
assert_eq!(
fixture
.database
.get_object(receipt.object_ids["pending:1"])
.unwrap(),
b"encoded file"
);
assert_eq!(
fixture
.database
.get_object(receipt.session_object_id)
.unwrap(),
format!("archive object {}", receipt.object_ids["pending:1"]).as_bytes()
);
let first = fixture
.database
.get_node(receipt.node_ids["pending:2"])
.unwrap();
let second = fixture
.database
.get_node(receipt.node_ids["pending:3"])
.unwrap();
assert_eq!(first.data.owner, Owner::Node(receipt.node_ids["pending:3"]));
assert_eq!(
second.data.owner,
Owner::Node(receipt.node_ids["pending:2"])
);
assert!(first.data.objects.contains(&receipt.session_object_id));
assert!(
first
.data
.objects
.contains(&receipt.object_ids["pending:1"])
);
assert!(
first
.data
.short_name
.contains(&receipt.object_ids["pending:1"].to_string())
);
assert!(first.data.long_description.contains("pending:1_suffix"));
}
#[test]
fn an_identical_replay_returns_the_same_receipt_and_changed_input_conflicts() {
let fixture = Fixture::new();
let first = commit_session(&fixture.database, &fixture.receipts, request()).unwrap();
let replay = commit_session(&fixture.database, &fixture.receipts, request()).unwrap();
assert_eq!(replay, first);
let mut changed = request();
changed.archive = b"different".to_vec();
let error = commit_session(&fixture.database, &fixture.receipts, changed).unwrap_err();
assert_eq!(error.kind(), ErrorKind::Conflict);
}
#[test]
fn a_prepared_receipt_recovers_after_the_kweb_transaction_became_visible() {
let fixture = Fixture::new();
let first = commit_session(&fixture.database, &fixture.receipts, request()).unwrap();
let connection = Connection::open(&fixture.receipts).unwrap();
connection
.execute(
"UPDATE kmap_session_commit_receipts
SET result_json=NULL,committed_at=NULL
WHERE session_id=?1",
params!["session-1"],
)
.unwrap();
drop(connection);
let recovered = commit_session(&fixture.database, &fixture.receipts, request()).unwrap();
assert_eq!(recovered.transaction_id, None);
assert_eq!(recovered.session_object_id, first.session_object_id);
assert_eq!(recovered.node_ids, first.node_ids);
assert_eq!(recovered.object_ids, first.object_ids);
}
#[test]
fn legacy_kennedy_receipts_are_migrated_and_remain_replayable() {
let fixture = Fixture::new();
let session_object_id = ObjectId::from_bytes([128, 1, 2, 3, 4, 5]).unwrap();
let legacy_receipt = serde_json::json!({
"transactionId": null,
"sessionObjectId": session_object_id.to_string(),
"nodeIds": {},
"objectIds": {},
})
.to_string();
let connection = Connection::open(&fixture.receipts).unwrap();
connection
.execute_batch(
"CREATE TABLE kmap_session_commit_receipts (
session_id TEXT PRIMARY KEY,
request_sha256 BLOB NOT NULL CHECK(length(request_sha256)=32),
result_json TEXT,
started_at TEXT NOT NULL,
committed_at TEXT,
CHECK((result_json IS NULL) = (committed_at IS NULL))
);",
)
.unwrap();
connection
.execute(
"INSERT INTO kmap_session_commit_receipts(
session_id,request_sha256,result_json,started_at,committed_at
) VALUES(?1,?2,?3,?4,?5)",
params![
"session-1",
[9_u8; 32].as_slice(),
legacy_receipt,
"2026-07-27T01:02:03Z",
"2026-07-27T01:02:04Z",
],
)
.unwrap();
drop(connection);
let replay = commit_session(&fixture.database, &fixture.receipts, request()).unwrap();
assert_eq!(replay.session_object_id, session_object_id);
let connection = Connection::open(&fixture.receipts).unwrap();
assert_eq!(
connection
.query_row(
"SELECT digest_version FROM kmap_session_commit_receipts
WHERE session_id='session-1'",
[],
|row| row.get::<_, i64>(0),
)
.unwrap(),
1
);
}
#[test]
fn unresolved_references_are_rejected_before_receipt_creation() {
let fixture = Fixture::new();
let mut input = request();
input.creates.get_mut("pending:2").unwrap().owner = "pending:99".into();
let error = commit_session(&fixture.database, &fixture.receipts, input).unwrap_err();
assert_eq!(error.kind(), ErrorKind::InvalidInput);
assert!(!fixture.receipts.exists());
}