use std::collections::HashSet; use ed25519_dalek::SigningKey; use ontoref_types::{ operation::{OpBody, OpPayload, Operation}, Hlc, OpId, PublicKey, }; use ontoref_oplog::{Error, OpLog}; fn signing_key() -> SigningKey { SigningKey::from_bytes(&[42u8; 32]) } fn make_op(parents: Vec, timestamp: Hlc, label: &str) -> Operation { let key = signing_key(); let body = OpBody { actor: PublicKey::from_signing_key(&key), parents, payload: OpPayload::Noop { label: label.into(), }, timestamp, }; Operation::sign(body, &key) } #[test] fn replay_yields_same_heads_for_linear_chain() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("oplog.redb"); let original_heads: Vec = { let log = OpLog::open(&path).unwrap(); let mut prev_id = None; let mut prev_hlc = Hlc::genesis(); for i in 0..100 { prev_hlc = Hlc::tick(prev_hlc, 0); let parents = prev_id.map(|id| vec![id]).unwrap_or_default(); let op = make_op(parents, prev_hlc, &format!("op-{i}")); prev_id = Some(log.append(&op).unwrap()); } assert_eq!(log.len().unwrap(), 100); log.heads().unwrap() }; // Drop the OpLog (closes the redb handle), reopen the file, re-read heads. let reopened_heads = OpLog::open(&path).unwrap().heads().unwrap(); assert_eq!(original_heads, reopened_heads); assert_eq!(original_heads.len(), 1, "a linear chain has exactly one head"); } #[test] fn replay_yields_same_heads_for_branching_dag() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("oplog.redb"); let original_heads: HashSet = { let log = OpLog::open(&path).unwrap(); let mut clock = Hlc::genesis(); // Root op. clock = Hlc::tick(clock, 0); let root = log.append(&make_op(vec![], clock, "root")).unwrap(); // Two children sharing the same root. clock = Hlc::tick(clock, 0); let left = log.append(&make_op(vec![root], clock, "left")).unwrap(); clock = Hlc::tick(clock, 0); let right = log.append(&make_op(vec![root], clock, "right")).unwrap(); // Merge: a child of both left and right. clock = Hlc::tick(clock, 0); let merged = log .append(&make_op(vec![left, right], clock, "merged")) .unwrap(); // Extra branch from the root that stays a head. clock = Hlc::tick(clock, 0); let stray = log.append(&make_op(vec![root], clock, "stray")).unwrap(); // Expected heads: {merged, stray}. let expected: HashSet = [merged, stray].into_iter().collect(); assert_eq!( log.heads().unwrap().into_iter().collect::>(), expected ); expected }; let reopened: HashSet = OpLog::open(&path) .unwrap() .heads() .unwrap() .into_iter() .collect(); assert_eq!(reopened, original_heads); } #[test] fn parents_validation_rejects_unknown_parent() { let dir = tempfile::tempdir().unwrap(); let log = OpLog::open(dir.path().join("oplog.redb")).unwrap(); let bogus = OpId([0xAA; 32]); let op = make_op(vec![bogus], Hlc::new(1, 0), "orphan"); match log.append(&op) { Err(Error::UnknownParent(parent)) if parent == bogus => {} other => panic!("expected UnknownParent({bogus:?}), got {other:?}"), } assert_eq!(log.len().unwrap(), 0, "rejected op must not be stored"); } #[test] fn parents_validation_accepts_known_parent() { let dir = tempfile::tempdir().unwrap(); let log = OpLog::open(dir.path().join("oplog.redb")).unwrap(); let root = log .append(&make_op(vec![], Hlc::new(1, 0), "root")) .unwrap(); let child = log .append(&make_op(vec![root], Hlc::new(2, 0), "child")) .unwrap(); assert!(log.get(&root).unwrap().is_some()); assert!(log.get(&child).unwrap().is_some()); assert_eq!(log.heads().unwrap(), vec![child]); } #[test] fn append_is_idempotent() { let dir = tempfile::tempdir().unwrap(); let log = OpLog::open(dir.path().join("oplog.redb")).unwrap(); let op = make_op(vec![], Hlc::new(1, 0), "same"); let id1 = log.append(&op).unwrap(); let id2 = log.append(&op).unwrap(); assert_eq!(id1, id2); assert_eq!(log.len().unwrap(), 1); } #[test] fn append_rejects_bad_signature() { let dir = tempfile::tempdir().unwrap(); let log = OpLog::open(dir.path().join("oplog.redb")).unwrap(); let mut op = make_op(vec![], Hlc::new(1, 0), "tampered"); // Flip a bit in the signature so it no longer verifies. op.signature.0[0] ^= 0x01; match log.append(&op) { Err(Error::BadSignature(_)) => {} other => panic!("expected BadSignature, got {other:?}"), } } #[test] fn append_rejects_non_monotonic_timestamp() { let dir = tempfile::tempdir().unwrap(); let log = OpLog::open(dir.path().join("oplog.redb")).unwrap(); let root = log .append(&make_op(vec![], Hlc::new(5, 0), "root")) .unwrap(); // Child with same timestamp as the parent — must be rejected. let bad_child = make_op(vec![root], Hlc::new(5, 0), "child-same-time"); match log.append(&bad_child) { Err(Error::NonMonotonicTimestamp(_)) => {} other => panic!("expected NonMonotonicTimestamp, got {other:?}"), } }