acorn-lib 0.3.2

ACORN library
use super::*;
use crate::io::api::gitlab::{ReportManifest, ReportStatus};
use crate::io::api::webhooks::store::{EnqueueStatus, OperationQueue};
use crate::io::api::webhooks::{self, CallbackResolver, ErrorKind, InboundRequest, WebhookProvider};
use crate::io::workflow::{LogbookProposal, RepositoryChangeSet};
use acorn_schema::research_activity::{ReportingWindow, ResearchActivity};
use acorn_schema::validation::Validate;
use http::{HeaderMap, HeaderValue};
use jiff::Timestamp;
use std::env::temp_dir;
use tower::ServiceExt;

fn config() -> PowerAutomateConfig {
    serde_json::from_value(serde_json::json!({
        "callback_destination": "pilot-flow",
        "variables": {
            "callback_url": "POWER_AUTOMATE_CALLBACK_URL",
            "inbound_secret": "POWER_AUTOMATE_INBOUND_SECRET"
        },
        "gitlab_project": "42",
        "package": "automated-artifacts",
        "projects": [{
            "project_id": "pilot",
            "members": [" Scientist@Example.org "],
            "path": "pilot/index.json",
        }]
    }))
    .expect("valid configuration fixture")
}
fn intake_headers(delivery_id: &str) -> HeaderMap {
    let mut headers = HeaderMap::new();
    headers.insert("x-acorn-delivery-id", HeaderValue::from_str(delivery_id).expect("delivery header"));
    headers.insert("x-acorn-webhook-secret", HeaderValue::from_static("test-secret"));
    headers
}
fn submission() -> FormSubmission {
    serde_json::from_value(serde_json::json!({
        "schemaVersion": 1,
        "submissionId": "123e4567-e89b-12d3-a456-426614174000",
        "projectId": "pilot",
        "submittedAt": "2026-09-18T12:01:00Z",
        "submitter": {"email": "SCIENTIST@example.org"},
        "entry": {
            "timestamp": "2026-09-18T12:00:00Z",
            "category": "software",
            "state": "completed",
            "summary": "Released the pilot",
            "items": ["Tagged v1"]
        }
    }))
    .expect("valid submission fixture")
}

#[test]
fn test_callback_resolution_is_allowlisted_and_attempt_time_only() {
    let resolver = PowerAutomateCallbackResolver::new(config());
    assert_eq!(
        resolver.resolve("other").err().expect("destination allowlist").kind(),
        ErrorKind::InvalidTarget
    );
    temp_env::with_var("POWER_AUTOMATE_CALLBACK_URL", Some("https://example.test/hooks/secret"), || {
        assert!(resolver.resolve("pilot-flow").is_ok());
    });
}
#[test]
fn test_configuration_normalizes_allowlists_and_rejects_unsafe_paths() {
    let config = config();
    assert_eq!(
        config
            .projects
            .first()
            .and_then(|project| project.members.first())
            .expect("configured project member")
            .as_str(),
        "scientist@example.org"
    );
    assert_eq!(config.timezone(), "UTC");
    assert!(config.validate().is_ok());
    let mut invalid = config;
    invalid.projects.first_mut().expect("configured project").path = "../pilot/index.json".to_string();
    assert!(invalid.validate().is_err());
    invalid.projects.first_mut().expect("configured project").path = "*/index.json".to_string();
    assert!(invalid.validate().is_err());
    invalid.projects.first_mut().expect("configured project").path = "pilot/index.json".to_string();
    invalid.timezone = Some("America/New_York".to_string());
    assert!(invalid.validate().is_err());
}
#[test]
fn test_flow_is_authenticated_durable_duplicate_safe_and_reportable() {
    let fixture = include_str!(concat!(
        env!("CARGO_MANIFEST_DIR"),
        "/../../tests/fixtures/automation/powerautomate/form.json"
    ));
    let body = fixture.as_bytes();
    let now = "2026-09-18T12:02:00Z".parse::<Timestamp>().expect("test timestamp");
    let provider = PowerAutomateProvider::new(config(), "test-secret").expect("provider");
    let headers = intake_headers("delivery-1");
    let prepared = webhooks::prepare(
        &provider,
        InboundRequest {
            headers: &headers,
            raw_body: body,
        },
        now,
    )
    .expect("authenticated intake");
    let queue = OperationQueue::from(temp_dir().join(format!("acorn-powerautomate-{}.db", nanoid::nanoid!())));
    assert_eq!(queue.enqueue_prepared(&prepared).expect("durable enqueue"), [EnqueueStatus::Inserted]);

    let duplicate_headers = intake_headers("delivery-2");
    let duplicate = webhooks::prepare(
        &provider,
        InboundRequest {
            headers: &duplicate_headers,
            raw_body: body,
        },
        now,
    )
    .expect("duplicate authenticated intake");
    assert_eq!(
        queue.enqueue_prepared(&duplicate).expect("duplicate enqueue"),
        [EnqueueStatus::DuplicateOperation]
    );
    assert_eq!(queue.counts().expect("queue counts").queued, 1);

    let form = serde_json::from_str::<FormSubmission>(fixture).expect("form fixture");
    let entry = form.entry(provider.config()).expect("normalized entry");
    let content = serde_json::to_string_pretty(&ResearchActivity::default()).expect("activity fixture");
    let changes = RepositoryChangeSet::try_from(LogbookProposal {
        base_revision: "0123456789abcdef".to_string(),
        content,
        entry,
        path: "pilot/index.json".to_string(),
    })
    .expect("logbook proposal");
    let activity = serde_json::from_str::<ResearchActivity>(&changes.files.first().expect("updated file").content).expect("updated activity");
    let window = ReportingWindow::new(None, "2026-10-01T00:00:00Z").expect("reporting window");
    let updates = activity.updates_within(&window).expect("bounded updates");
    let manifest = ReportManifest::new(
        "automated-artifacts",
        "pilot",
        "0123456789abcdef",
        &window,
        updates.entries.iter().map(|entry| entry.identifier.clone()).collect(),
        "100",
        "200",
        Some(b"%PDF-1.7 report"),
        "https://gitlab.example/package",
    )
    .expect("report manifest");
    assert_eq!(manifest.entry_identifiers, ["form-123-e-4567-e-89-b-12-d-3-a-456-426614174000"]);
    assert_eq!(manifest.status, ReportStatus::Published);
}
#[test]
fn test_form_conversion_forces_effect_fields_and_rejects_late_entries() {
    let submission = submission();
    let entry = submission.entry(&config()).expect("authorized form");
    assert_eq!(entry.identifier, "form-123-e-4567-e-89-b-12-d-3-a-456-426614174000");
    assert_eq!(entry.origin, acorn_schema::research_activity::LogbookEntryOrigin::Manual);
    assert!(!entry.archived);
    assert!(entry.discovery.is_none());
    assert!(submission.entry_after(&config(), Some("2026-09-18T12:00:01Z")).is_err());
}
#[test]
fn test_form_payload_is_strict_and_authorized() {
    assert!(submission().submitter.validate().is_ok());
    let extra = serde_json::json!({
        "schemaVersion": 1,
        "submissionId": "123e4567-e89b-12d3-a456-426614174000",
        "projectId": "pilot",
        "submittedAt": "2026-09-18T12:01:00Z",
        "submitter": {"email": "scientist@example.org"},
        "entry": {
            "timestamp": "2026-09-18T12:00:00Z",
            "category": "software",
            "state": "completed",
            "summary": "Released the pilot",
            "archived": true
        }
    });
    assert!(serde_json::from_value::<FormSubmission>(extra).is_err());
    let mut unauthorized = submission();
    unauthorized.submitter.email = serde_json::from_value(serde_json::json!("other@example.org")).expect("email");
    assert!(unauthorized.entry(&config()).is_err());
}
#[test]
fn test_provider_classifies_one_stable_operation() {
    let provider = PowerAutomateProvider::new(config(), "test-secret").expect("provider");
    let delivery = crate::io::api::webhooks::Delivery {
        delivery_id: "delivery-1".to_string(),
        event: submission(),
    };
    let operation = provider.classify(&delivery).expect("classification");
    assert_eq!(operation.len(), 1);
    assert_eq!(
        operation.first().expect("classified operation").idempotency_key,
        "form:v1:123e4567-e89b-12d3-a456-426614174000"
    );
}
#[test]
fn test_provider_rejects_unauthenticated_form() {
    let provider = PowerAutomateProvider::new(config(), "test-secret").expect("provider");
    let body = serde_json::to_vec(&submission()).expect("form JSON");
    let error = provider
        .receive(
            InboundRequest {
                headers: &HeaderMap::new(),
                raw_body: &body,
            },
            Timestamp::now(),
        )
        .expect_err("unauthenticated form must fail");
    assert_eq!(error.kind(), crate::io::api::webhooks::ErrorKind::Unauthorized);
}
#[tokio::test]
async fn test_router_accepts_authenticated_form_and_returns_canonical_receipt() {
    let body = include_str!(concat!(
        env!("CARGO_MANIFEST_DIR"),
        "/../../tests/fixtures/automation/powerautomate/form.json"
    ))
    .as_bytes()
    .to_vec();
    let headers = intake_headers("delivery-router");
    let provider = PowerAutomateProvider::new(config(), "test-secret").expect("provider");
    let queue = OperationQueue::from(temp_dir().join(format!("acorn-powerautomate-router-{}.db", nanoid::nanoid!())));
    let router = provider.router(queue.clone(), crate::io::api::webhooks::WebhookRuntime::default());
    let mut request = axum::http::Request::builder()
        .method("POST")
        .uri("/webhooks/powerautomate/forms")
        .body(axum::body::Body::from(body))
        .expect("form request");
    *request.headers_mut() = headers;
    let response = router.oneshot(request).await.expect("form response");
    assert_eq!(response.status(), axum::http::StatusCode::ACCEPTED);
    let bytes = axum::body::to_bytes(response.into_body(), 64 * 1024).await.expect("receipt body");
    let receipt = serde_json::from_slice::<serde_json::Value>(&bytes).expect("form receipt");
    assert_eq!(receipt.get("disposition"), Some(&serde_json::json!("inserted")));
    assert_eq!(
        receipt.get("operationId"),
        Some(&serde_json::json!("powerautomate:form:v1:123e4567-e89b-12d3-a456-426614174000"))
    );
    assert_eq!(queue.counts().expect("queue counts").queued, 1);
}