use pushkin_core::envelope::{CheckResult, Decision, Severity, Violation};
use pushkin_core::events::{EventLog, GateEvent};
fn sample_violation() -> Violation {
Violation {
file: "app/api/users/route.ts".to_owned(),
line: 1,
rule: "contract.boundary.unvalidated_input".to_owned(),
contract: Some("user".to_owned()),
fix_hint: "Parse the request body with UserCreateSchema before use".to_owned(),
suggestions: vec!["contract_show user".to_owned()],
severity: Severity::Error,
}
}
#[test]
fn result_json_matches_spec_8_3_shape() {
let result = CheckResult {
decision: Decision::Block,
violations: vec![sample_violation()],
duration_ms: 38.0,
};
let json = serde_json::to_value(&result).unwrap();
assert_eq!(json["decision"], "block");
assert_eq!(json["violations"][0]["file"], "app/api/users/route.ts");
assert_eq!(json["violations"][0]["line"], 1);
assert_eq!(
json["violations"][0]["rule"],
"contract.boundary.unvalidated_input"
);
assert_eq!(json["violations"][0]["contract"], "user");
assert!(
json["violations"][0]["fixHint"].is_string(),
"camelCase per spec 8.3"
);
assert!(json["violations"][0]["suggestions"].is_array());
assert_eq!(json["violations"][0]["severity"], "error");
assert!(json["durationMs"].is_number(), "camelCase per spec 8.3");
}
#[test]
fn unknown_fields_rejected_on_ingest() {
let bad = r#"{"decision":"block","violations":[],"durationMs":1.0,"extra":true}"#;
let parsed: Result<CheckResult, _> = serde_json::from_str(bad);
assert!(
parsed.is_err(),
"deny_unknown_fields everywhere (charter N2)"
);
}
#[test]
fn event_has_session_seq_ts() {
let dir = tempfile::tempdir().unwrap();
let log = EventLog::open(dir.path().join("events.db")).unwrap();
let session = log.begin_session().unwrap();
let result = CheckResult {
decision: Decision::Allow,
violations: vec![],
duration_ms: 2.0,
};
let event = log.append(&session, &result).unwrap();
assert_eq!(event.seq, 1);
let next = log.append(&session, &result).unwrap();
assert_eq!(next.seq, 2, "seq increments per session");
assert!(
event.ts.ends_with('Z') || event.ts.contains("+00"),
"UTC timestamp: {}",
event.ts
);
}
#[test]
fn events_are_append_only() {
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("events.db");
let log = EventLog::open(&db_path).unwrap();
let session = log.begin_session().unwrap();
let result = CheckResult {
decision: Decision::Block,
violations: vec![sample_violation()],
duration_ms: 1.0,
};
log.append(&session, &result).unwrap();
drop(log);
let conn = rusqlite::Connection::open(&db_path).unwrap();
let update = conn.execute("UPDATE events SET decision = 'allow'", []);
assert!(update.is_err(), "UPDATE on events must fail");
let delete = conn.execute("DELETE FROM events", []);
assert!(delete.is_err(), "DELETE on events must fail");
}
#[test]
fn migration_steps_apply_in_order_idempotently() {
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("events.db");
{
let _first = EventLog::open(&db_path).unwrap();
}
let log = EventLog::open(&db_path).unwrap();
assert!(log.schema_version().unwrap() >= 1);
}
#[test]
fn attempts_counter_query_counts_rule_hits() {
let dir = tempfile::tempdir().unwrap();
let log = EventLog::open(dir.path().join("events.db")).unwrap();
let session = log.begin_session().unwrap();
let block = CheckResult {
decision: Decision::Block,
violations: vec![sample_violation()],
duration_ms: 1.0,
};
log.append(&session, &block).unwrap();
log.append(&session, &block).unwrap();
let attempts = log
.attempts(
&session,
"contract.boundary.unvalidated_input",
"app/api/users/route.ts",
)
.unwrap();
assert_eq!(attempts, 2);
let other = log
.attempts(&session, "pushkin.protected_path", "x.ts")
.unwrap();
assert_eq!(other, 0);
}
#[test]
fn failopen_event_is_recordable() {
let dir = tempfile::tempdir().unwrap();
let log = EventLog::open(dir.path().join("events.db")).unwrap();
let session = log.begin_session().unwrap();
let event = log
.append_failopen(&session, "malformed PreToolUse payload")
.unwrap();
assert_eq!(event.seq, 1);
let count = log.failopen_count(&session).unwrap();
assert_eq!(count, 1);
}
#[test]
fn gate_event_serializes_with_envelope() {
let event = GateEvent {
session: "s-1".to_owned(),
seq: 214,
ts: "2026-08-13T00:00:00Z".to_owned(),
};
let json = serde_json::to_value(&event).unwrap();
assert_eq!(json["session"], "s-1");
assert_eq!(json["seq"], 214);
assert!(json["ts"].is_string());
}