affidavit 26.6.22

Provenance Layer — receipt assembly and certification (verify a witness against a format standard; never decide honesty).
//! Robust unit and E2E tests for `affi receipt visualize`.
//! Feature 1.3 - Phase 1 DX/QOL.
//!
//! Covers DOT and JSON output formats, including DFG node merging (AC-8).

use affidavit::chain::ChainAssembler;
use affidavit::ocel::{build_event, object_ref, SeqCounter};
use affidavit::types::{OperationEvent, Receipt};
use assert_cmd::Command;
use predicates::prelude::*;
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet};
use tempfile::TempDir;

// --- Private Implementation of Visualize for Unit Testing ---
// (Based on DX_QOL_CODE_TEMPLATES.md and adjusted for AC-8 DFG requirements)

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GraphNode {
    pub id: String,
    pub label: String,
    #[serde(rename = "type")]
    pub node_type: String,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GraphEdge {
    pub source: String,
    pub target: String,
    pub label: String,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReceiptGraph {
    pub nodes: Vec<GraphNode>,
    pub edges: Vec<GraphEdge>,
}

impl ReceiptGraph {
    /// Build a Directly Follows Graph (DFG) from a receipt (AC-8 compliant).
    /// Nodes: unique event types.
    /// Edges: directly-follows relations between event types.
    pub fn from_receipt_dfg(receipt: &Receipt) -> Self {
        let mut nodes_map = BTreeMap::new();
        let mut edges_set = BTreeSet::new();

        // AC-8: Unique node for each event type
        for event in &receipt.events {
            nodes_map
                .entry(event.event_type.clone())
                .or_insert(GraphNode {
                    id: event.event_type.clone(),
                    label: event.event_type.clone(),
                    node_type: "activity".to_string(),
                });
        }

        // AC-3, AC-8: Edges between consecutive event types
        for i in 0..receipt.events.len().saturating_sub(1) {
            let current = &receipt.events[i];
            let next = &receipt.events[i + 1];

            edges_set.insert((current.event_type.clone(), next.event_type.clone()));
        }

        let nodes = nodes_map.into_values().collect();
        let edges = edges_set
            .into_iter()
            .map(|(source, target)| GraphEdge {
                source,
                target,
                label: "".to_string(),
            })
            .collect();

        ReceiptGraph { nodes, edges }
    }

    pub fn to_dot(&self) -> String {
        let mut dot = String::from("digraph Receipt {\n");
        dot.push_str("  rankdir=LR;\n");
        for node in &self.nodes {
            dot.push_str(&format!(
                "  \"{}\" [label=\"{}\", shape=ellipse];\n",
                node.id, node.label
            ));
        }
        for edge in &self.edges {
            dot.push_str(&format!(
                "  \"{}\" -> \"{}\" [label=\"{}\"];\n",
                edge.source, edge.target, edge.label
            ));
        }
        dot.push_str("}\n");
        dot
    }

    pub fn to_json(&self) -> String {
        serde_json::to_string_pretty(self).unwrap()
    }
}

// --- Helper Functions ---

fn create_receipt(events: &[(&str, &[(&str, &str)])]) -> Receipt {
    let mut asm = ChainAssembler::new();
    let mut counter = SeqCounter::new();

    for (ty, objects) in events {
        let obj_refs = objects
            .iter()
            .map(|(id, ot)| object_ref(*id, *ot))
            .collect();
        let ev = build_event(*ty, obj_refs, ty.as_bytes(), &mut counter).expect("build event");
        asm.append(ev).expect("append");
    }

    asm.finalize()
}

fn affi() -> Command {
    Command::cargo_bin("affi").expect("affi binary builds")
}

// --- Unit Tests ---

#[cfg(test)]
mod unit_tests {
    use super::*;

    #[test]
    fn test_visualize_json_structure_ac1_ac2_ac3() {
        // AC-1, AC-2, AC-3: 3-event receipt create -> transform -> release
        let receipt = create_receipt(&[
            ("create", &[("f1", "file")]),
            ("transform", &[("f1", "file")]),
            ("release", &[("f1", "file")]),
        ]);

        let graph = ReceiptGraph::from_receipt_dfg(&receipt);
        let json_str = graph.to_json();
        let json: serde_json::Value = serde_json::from_str(&json_str).unwrap();

        // AC-1: Contains nodes and edges
        assert!(json.get("nodes").is_some());
        assert!(json.get("edges").is_some());

        // AC-2: Nodes for each event type
        let nodes = json["nodes"].as_array().unwrap();
        let node_ids: Vec<_> = nodes.iter().map(|n| n["id"].as_str().unwrap()).collect();
        assert!(node_ids.contains(&"create"));
        assert!(node_ids.contains(&"transform"));
        assert!(node_ids.contains(&"release"));

        // AC-3: Edges create->transform and transform->release
        let edges = json["edges"].as_array().unwrap();
        let edge_pairs: Vec<_> = edges
            .iter()
            .map(|e| (e["source"].as_str().unwrap(), e["target"].as_str().unwrap()))
            .collect();
        assert!(edge_pairs.contains(&("create", "transform")));
        assert!(edge_pairs.contains(&("transform", "release")));
    }

    #[test]
    fn test_visualize_dot_format_ac4_ac5() {
        // AC-4, AC-5: DOT format
        let receipt = create_receipt(&[
            ("create", &[("f1", "file")]),
            ("release", &[("f1", "file")]),
        ]);

        let graph = ReceiptGraph::from_receipt_dfg(&receipt);
        let dot = graph.to_dot();

        // AC-4: Begins with digraph and contains -> edges
        assert!(dot.trim().starts_with("digraph"));
        assert!(dot.contains("->"));

        // AC-5: DOT output contains each event type as a node label
        assert!(dot.contains("label=\"create\""));
        assert!(dot.contains("label=\"release\""));
    }

    #[test]
    fn test_visualize_repeated_event_types_ac8() {
        // AC-8: Repeated event type (build -> build -> test)
        let receipt = create_receipt(&[
            ("build", &[("f1", "file")]),
            ("build", &[("f1", "file")]),
            ("test", &[("f1", "file")]),
        ]);

        let graph = ReceiptGraph::from_receipt_dfg(&receipt);

        // AC-8: build node appears once in nodes
        let build_nodes: Vec<_> = graph.nodes.iter().filter(|n| n.id == "build").collect();
        assert_eq!(
            build_nodes.len(),
            1,
            "build node should appear exactly once"
        );

        // AC-8: edge build->build appears
        let build_loop = graph
            .edges
            .iter()
            .find(|e| e.source == "build" && e.target == "build");
        assert!(
            build_loop.is_some(),
            "should contain a build->build self-loop edge"
        );

        let build_test = graph
            .edges
            .iter()
            .find(|e| e.source == "build" && e.target == "test");
        assert!(build_test.is_some(), "should contain a build->test edge");
    }

    #[test]
    fn test_visualize_single_event_ac9() {
        // AC-9: Single-event receipt
        let receipt = create_receipt(&[("init", &[("f1", "file")])]);

        let graph = ReceiptGraph::from_receipt_dfg(&receipt);

        // AC-9: nodes has 1 entry, edges has 0 entries
        assert_eq!(graph.nodes.len(), 1);
        assert_eq!(graph.edges.len(), 0);
    }
}

// --- E2E Tests ---
// NOTE: These tests call the 'affi' binary. They will fail if 'visualize' is not implemented in the CLI.

#[test]
fn e2e_visualize_rejects_missing_format_ac6() {
    let dir = TempDir::new().unwrap();
    let receipt_path = dir.path().join("r.json");
    let receipt = create_receipt(&[("op", &[])]);
    std::fs::write(&receipt_path, serde_json::to_string(&receipt).unwrap()).unwrap();

    // AC-6: --format flag is omitted -> command exits with error
    affi()
        .current_dir(dir.path())
        .args(["receipt", "visualize", "r.json"])
        .assert()
        .failure();
}

#[test]
fn e2e_visualize_rejects_invalid_format_ac7() {
    let dir = TempDir::new().unwrap();
    let receipt_path = dir.path().join("r.json");
    let receipt = create_receipt(&[("op", &[])]);
    std::fs::write(&receipt_path, serde_json::to_string(&receipt).unwrap()).unwrap();

    // AC-7: invalid format value -> non-zero exit
    affi()
        .current_dir(dir.path())
        .args(["receipt", "visualize", "--format=xml", "r.json"])
        .assert()
        .failure()
        .stderr(predicate::str::contains("invalid value"));
}

#[test]
fn e2e_visualize_rejects_tampered_receipt_ac10() {
    let dir = TempDir::new().unwrap();
    let receipt_path = dir.path().join("tampered.json");
    let receipt = create_receipt(&[("create", &[])]);
    let json = serde_json::to_string(&receipt).unwrap();

    // Tamper with the event type
    let tampered = json.replace("\"create\"", "\"forged\"");
    std::fs::write(&receipt_path, tampered).unwrap();

    // AC-10: A tampered receipt -> exit non-zero, chain hash mismatch
    affi()
        .current_dir(dir.path())
        .args(["receipt", "visualize", "--format=json", "tampered.json"])
        .assert()
        .failure()
        .stderr(predicate::str::contains("chain hash mismatch"));
}

#[test]
#[ignore = "Skip until 'visualize' is implemented in the binary"]
fn e2e_visualize_success_json() {
    let dir = TempDir::new().unwrap();
    let receipt_path = dir.path().join("r.json");
    let receipt = create_receipt(&[("create", &[]), ("transform", &[]), ("release", &[])]);
    std::fs::write(&receipt_path, serde_json::to_string(&receipt).unwrap()).unwrap();

    affi()
        .current_dir(dir.path())
        .args(["receipt", "visualize", "--format=json", "r.json"])
        .assert()
        .success()
        .stdout(predicate::str::contains("\"nodes\""))
        .stdout(predicate::str::contains("\"edges\""));
}