🐸 Frogue(Fed)

~ololduck/Frogue

Raw

//! Tests for ForgeFed patch proposal federation.
//!
//! These tests verify that patch proposal activities (Offer, Add, Resolve,
//! Push) are correctly emitted to repository followers.

mod support;

use sea_orm::{ActiveModelTrait, ColumnTrait, Database, EntityTrait, QueryFilter, Set};
use support::ap::RemoteActor;
use support::{Server, free_port};
use uuid::Uuid;

const PASSWORD: &str = "correct horse battery staple";

fn spawn() -> (Server, u16) {
    let port = free_port();
    let mut server = Server::spawn_federated(&format!("127.0.0.1:{port}"));
    server.wait_for_connection(port);
    (server, port)
}

/// Registers `name` through the form, which logs them in.
fn register(client: &mut support::Client, name: &str) {
    let email = format!("{name}@example.org");
    let response = client.post_form(
        "/register",
        &[("name", name), ("email", &email), ("password", PASSWORD)],
    );
    assert_eq!(
        response.status, 303,
        "registering should log in\n{}",
        response.body
    );
}

/// Creates a repository through the form.
fn create_repository(client: &mut support::Client, owner: &str, slug: &str, visibility: &str) {
    let response = client.post_form(
        &format!("/~{owner}/repositories"),
        &[
            ("slug", slug),
            (
                "description",
                "a repository from the patch federation tests",
            ),
            ("visibility", visibility),
        ],
    );
    assert_eq!(
        response.status, 303,
        "creating a repository should redirect\n{}",
        response.body
    );
}

/// Creates a patch proposal directly in the database for testing.
async fn create_patch(
    server: &Server,
    repository_id: i64,
    creator_id: i64,
    number: i64,
    summary: &str,
    status: &str,
) -> i64 {
    use chrono::Utc;
    use frogue::entities::patch_proposals::{ActiveModel, MergePolicy, Status};

    let db = Database::connect(server.database_url())
        .await
        .expect("connecting to test database");

    let status = match status {
        "draft" => Status::Draft,
        "open" => Status::Open,
        "closed" => Status::Closed,
        "merged" => Status::Merged,
        _ => panic!("unknown status"),
    };

    let now = Utc::now().fixed_offset();
    let pp = ActiveModel {
        uuid: Set(Uuid::new_v4()),
        repository_id: Set(repository_id),
        creator_id: Set(Some(creator_id)),
        number: Set(number),
        summary: Set(summary.to_string()),
        description: Set(Some("Test description".to_string())),
        source_ref: Set(format!("patches/{number}")),
        target_branch: Set("main".to_string()),
        status: Set(status),
        merge_policy: Set(MergePolicy::PreferFastForward),
        created_at: Set(now),
        updated_at: Set(now),
        ..Default::default()
    };

    let pp = pp.insert(&db).await.expect("inserting patch");
    pp.id
}

/// Gets the repository ID for a given owner/slug
async fn get_repository_id(server: &Server, _owner: &str, slug: &str) -> i64 {
    use frogue::entities::repositories::{Column, Entity as Repositories};

    let db = Database::connect(server.database_url())
        .await
        .expect("connecting to test database");

    let repo = Repositories::find()
        .filter(Column::Slug.eq(slug))
        .one(&db)
        .await
        .expect("querying repository")
        .expect("repository not found");

    repo.id
}

/// Gets the user ID for a given username
async fn get_user_id(server: &Server, name: &str) -> i64 {
    use frogue::entities::users::{Column, Entity as Users};

    let db = Database::connect(server.database_url())
        .await
        .expect("connecting to test database");

    let user = Users::find()
        .filter(Column::Name.eq(name))
        .one(&db)
        .await
        .expect("querying user")
        .expect("user not found");

    user.id
}

/// Seeds the bare git repository with commits so merges can succeed.
/// Creates a commit on main and a descendant commit on patches/1.
fn seed_git_repo(server: &Server, owner: &str, slug: &str) {
    use std::process::Command;

    let bare_path = server.repo_root().join(owner).join(format!("{slug}.git"));

    // Create a temporary working directory
    let temp_dir = std::env::temp_dir().join(format!("frogue-test-git-{}", uuid::Uuid::new_v4()));
    std::fs::create_dir_all(&temp_dir).expect("create temp dir");

    // Initialize a git repo
    let output = Command::new("git")
        .args(["init", "-q", "-b", "main"])
        .current_dir(&temp_dir)
        .output()
        .expect("git init");
    assert!(output.status.success(), "git init failed: {:?}", output);

    // Configure git user
    Command::new("git")
        .args(["config", "user.email", "test@example.org"])
        .current_dir(&temp_dir)
        .output()
        .expect("git config email");
    Command::new("git")
        .args(["config", "user.name", "Test User"])
        .current_dir(&temp_dir)
        .output()
        .expect("git config name");

    // Create initial commit on main
    std::fs::write(temp_dir.join("README.md"), "# Test\n").expect("write README");
    Command::new("git")
        .args(["add", "README.md"])
        .current_dir(&temp_dir)
        .output()
        .expect("git add");
    let output = Command::new("git")
        .args(["commit", "-q", "-m", "Initial commit"])
        .current_dir(&temp_dir)
        .output()
        .expect("git commit");
    assert!(output.status.success(), "git commit failed: {:?}", output);

    // Create a branch for patches/1
    let output = Command::new("git")
        .args(["checkout", "-q", "-b", "patches/1"])
        .current_dir(&temp_dir)
        .output()
        .expect("git checkout");
    assert!(output.status.success(), "git checkout failed: {:?}", output);

    // Create a commit on patches/1
    std::fs::write(temp_dir.join("patch.txt"), "Patch content\n").expect("write patch file");
    Command::new("git")
        .args(["add", "patch.txt"])
        .current_dir(&temp_dir)
        .output()
        .expect("git add patch");
    let output = Command::new("git")
        .args(["commit", "-q", "-m", "Add patch"])
        .current_dir(&temp_dir)
        .output()
        .expect("git commit patch");
    assert!(
        output.status.success(),
        "git commit patch failed: {:?}",
        output
    );

    // Push main to bare repo
    let output = Command::new("git")
        .args([
            "push",
            "-q",
            bare_path.to_str().unwrap(),
            "main:refs/heads/main",
        ])
        .current_dir(&temp_dir)
        .output()
        .expect("git push main");
    assert!(
        output.status.success(),
        "git push main failed: {:?}",
        output
    );

    // Push patches/1 to bare repo
    let output = Command::new("git")
        .args([
            "push",
            "-q",
            bare_path.to_str().unwrap(),
            "patches/1:refs/patches/1",
        ])
        .current_dir(&temp_dir)
        .output()
        .expect("git push patches/1");
    assert!(
        output.status.success(),
        "git push patches/1 failed: {:?}",
        output
    );

    // Clean up
    std::fs::remove_dir_all(&temp_dir).ok();
}

/// Test that opening a draft patch emits Offer and Accept activities.
#[actix_web::test]
async fn opening_draft_patch_emits_offer_accept() {
    let (server, port) = spawn();
    let mut client = support::Client::new(port);

    // Register Alice and create her repository
    register(&mut client, "alice");
    create_repository(&mut client, "alice", "testrepo", "public");

    // Register Bob (this logs in Bob)
    register(&mut client, "bob");

    // Set up remote follower
    let remote_actor = RemoteActor::serve(free_port());
    let repo_url = format!("http://127.0.0.1:{}/~alice/testrepo", port);
    remote_actor.follow(repo_url);

    // Wait for follow acceptance
    let accept_delivery = remote_actor.wait_for_delivery(10);
    assert!(
        accept_delivery.is_some(),
        "should receive delivery for Follow"
    );
    let accept: serde_json::Value =
        serde_json::from_str(&accept_delivery.unwrap().body).expect("parse Accept");
    assert_eq!(
        accept.get("type").and_then(|t| t.as_str()),
        Some("Accept"),
        "should receive Accept for Follow"
    );

    // Get IDs
    let repo_id = get_repository_id(&server, "alice", "testrepo").await;
    let bob_id = get_user_id(&server, "bob").await;

    // Create a draft patch as Bob
    let _patch_id = create_patch(&server, repo_id, bob_id, 1, "Test patch", "draft").await;

    // Bob opens the draft via web UI
    let response = client.post_form(
        "/~alice/testrepo/patches/1",
        &[
            ("summary", "Test patch"),
            ("description", "Test description"),
            ("target_branch", "main"),
            ("status", "open"),
        ],
    );
    assert_eq!(response.status, 303, "editing patch should redirect");

    // Wait for Accept activity (Offer is stored but not delivered)
    let mut found_accept = false;
    for _ in 0..20 {
        std::thread::sleep(std::time::Duration::from_millis(100));
        let deliveries = remote_actor.recorded_deliveries();
        for delivery in deliveries {
            if let Ok(activity) = serde_json::from_str::<serde_json::Value>(&delivery.body)
                && activity.get("type").and_then(|t| t.as_str()) == Some("Accept")
                && let Some(actor) = activity.get("actor").and_then(|a| a.as_str())
                && actor.contains("/~alice/testrepo")
            {
                found_accept = true;
                break;
            }
        }
        if found_accept {
            break;
        }
    }
    assert!(
        found_accept,
        "should receive Accept for Offer when opening draft"
    );
}

/// Test that merging a patch emits Resolve and Push activities.
#[actix_web::test]
async fn merging_patch_emits_resolve_push() {
    let (server, port) = spawn();
    let mut client = support::Client::new(port);

    // Register Alice and create her repository
    register(&mut client, "alice");
    create_repository(&mut client, "alice", "testrepo", "public");

    // Set up remote follower
    let remote_actor = RemoteActor::serve(free_port());
    let repo_url = format!("http://127.0.0.1:{}/~alice/testrepo", port);
    remote_actor.follow(repo_url);

    // Wait for follow acceptance
    let accept_delivery = remote_actor.wait_for_delivery(10);
    assert!(
        accept_delivery.is_some(),
        "should receive delivery for Follow"
    );
    let accept: serde_json::Value =
        serde_json::from_str(&accept_delivery.unwrap().body).expect("parse Accept");
    assert_eq!(
        accept.get("type").and_then(|t| t.as_str()),
        Some("Accept"),
        "should receive Accept for Follow"
    );

    // Get IDs
    let repo_id = get_repository_id(&server, "alice", "testrepo").await;
    let alice_id = get_user_id(&server, "alice").await;

    // Seed the git repo with commits so merge can succeed
    seed_git_repo(&server, "alice", "testrepo");

    // Create an open patch as Alice (so Alice can merge it)
    create_patch(&server, repo_id, alice_id, 1, "Test patch", "open").await;

    // Alice merges the patch
    let response = client.post_form("/~alice/testrepo/patches/1/merge", &[]);
    assert_eq!(response.status, 303, "merging patch should redirect");

    // Wait for Resolve activity
    let mut found_resolve = false;
    for _ in 0..20 {
        std::thread::sleep(std::time::Duration::from_millis(100));
        let deliveries = remote_actor.recorded_deliveries();
        for delivery in deliveries {
            if let Ok(activity) = serde_json::from_str::<serde_json::Value>(&delivery.body)
                && activity.get("type").and_then(|t| t.as_str()) == Some("Resolve")
            {
                found_resolve = true;
                assert_eq!(
                    activity["actor"].as_str().unwrap(),
                    format!("http://127.0.0.1:{}/~alice/testrepo", port),
                    "Resolve actor should be repository"
                );
                break;
            }
        }
        if found_resolve {
            break;
        }
    }
    assert!(found_resolve, "should receive Resolve when merging");

    // Wait for Push activity
    let mut found_push = false;
    for _ in 0..20 {
        std::thread::sleep(std::time::Duration::from_millis(100));
        let deliveries = remote_actor.recorded_deliveries();
        for delivery in deliveries {
            if let Ok(activity) = serde_json::from_str::<serde_json::Value>(&delivery.body)
                && activity.get("type").and_then(|t| t.as_str()) == Some("Push")
            {
                found_push = true;
                break;
            }
        }
        if found_push {
            break;
        }
    }
    assert!(found_push, "should receive Push when merging");
}

/// Test that closing a patch emits Resolve activity.
#[actix_web::test]
async fn closing_patch_emits_resolve() {
    let (server, port) = spawn();
    let mut client = support::Client::new(port);

    // Register Alice and create her repository
    register(&mut client, "alice");
    create_repository(&mut client, "alice", "testrepo", "public");

    // Register Bob (this logs in Bob)
    register(&mut client, "bob");

    // Set up remote follower
    let remote_actor = RemoteActor::serve(free_port());
    let repo_url = format!("http://127.0.0.1:{}/~alice/testrepo", port);
    remote_actor.follow(repo_url);

    // Wait for follow acceptance
    let accept_delivery = remote_actor.wait_for_delivery(10);
    assert!(
        accept_delivery.is_some(),
        "should receive delivery for Follow"
    );
    let accept: serde_json::Value =
        serde_json::from_str(&accept_delivery.unwrap().body).expect("parse Accept");
    assert_eq!(
        accept.get("type").and_then(|t| t.as_str()),
        Some("Accept"),
        "should receive Accept for Follow"
    );

    // Get IDs
    let repo_id = get_repository_id(&server, "alice", "testrepo").await;
    let bob_id = get_user_id(&server, "bob").await;

    // Create an open patch as Bob
    create_patch(&server, repo_id, bob_id, 1, "Test patch", "open").await;

    // Bob closes the patch
    let response = client.post_form(
        "/~alice/testrepo/patches/1",
        &[
            ("summary", "Test patch"),
            ("description", "Test description"),
            ("target_branch", "main"),
            ("status", "closed"),
        ],
    );
    assert_eq!(response.status, 303, "closing patch should redirect");

    // Wait for Resolve activity
    let mut found_resolve = false;
    for _ in 0..20 {
        std::thread::sleep(std::time::Duration::from_millis(100));
        let deliveries = remote_actor.recorded_deliveries();
        for delivery in deliveries {
            if let Ok(activity) = serde_json::from_str::<serde_json::Value>(&delivery.body)
                && activity.get("type").and_then(|t| t.as_str()) == Some("Resolve")
            {
                found_resolve = true;
                break;
            }
        }
        if found_resolve {
            break;
        }
    }
    assert!(found_resolve, "should receive Resolve when closing");
}

/// Test that reopening a closed patch emits Undo(Resolve) activity.
#[actix_web::test]
async fn reopening_patch_emits_undo_resolve() {
    let (server, port) = spawn();
    let mut client = support::Client::new(port);

    // Register Alice and create her repository
    register(&mut client, "alice");
    create_repository(&mut client, "alice", "testrepo", "public");

    // Register Bob (this logs in Bob)
    register(&mut client, "bob");

    // Set up remote follower
    let remote_actor = RemoteActor::serve(free_port());
    let repo_url = format!("http://127.0.0.1:{}/~alice/testrepo", port);
    remote_actor.follow(repo_url);

    // Wait for follow acceptance
    let accept_delivery = remote_actor.wait_for_delivery(10);
    assert!(
        accept_delivery.is_some(),
        "should receive delivery for Follow"
    );
    let accept: serde_json::Value =
        serde_json::from_str(&accept_delivery.unwrap().body).expect("parse Accept");
    assert_eq!(
        accept.get("type").and_then(|t| t.as_str()),
        Some("Accept"),
        "should receive Accept for Follow"
    );

    // Get IDs
    let repo_id = get_repository_id(&server, "alice", "testrepo").await;
    let bob_id = get_user_id(&server, "bob").await;

    // Create an open patch as Bob
    create_patch(&server, repo_id, bob_id, 1, "Test patch", "open").await;

    // Bob closes the patch (this sets resolve_activity_url)
    let response = client.post_form(
        "/~alice/testrepo/patches/1",
        &[
            ("summary", "Test patch"),
            ("description", "Test description"),
            ("target_branch", "main"),
            ("status", "closed"),
        ],
    );
    assert_eq!(response.status, 303, "closing patch should redirect");

    // Wait a bit for the Resolve to be emitted
    std::thread::sleep(std::time::Duration::from_millis(500));

    // Clear deliveries before reopening
    remote_actor.drain_deliveries();

    // Bob reopens the patch (this should emit Undo(Resolve))
    let response = client.post_form(
        "/~alice/testrepo/patches/1",
        &[
            ("summary", "Test patch"),
            ("description", "Test description"),
            ("target_branch", "main"),
            ("status", "open"),
        ],
    );
    assert_eq!(response.status, 303, "reopening patch should redirect");

    // Wait for Undo(Resolve) activity
    let mut found_undo = false;
    for _ in 0..20 {
        std::thread::sleep(std::time::Duration::from_millis(100));
        let deliveries = remote_actor.recorded_deliveries();
        for delivery in deliveries {
            if let Ok(activity) = serde_json::from_str::<serde_json::Value>(&delivery.body)
                && activity.get("type").and_then(|t| t.as_str()) == Some("Undo")
            {
                let object = activity.get("object").expect("Undo should have object");
                if object.get("type").and_then(|t| t.as_str()) == Some("Resolve") {
                    found_undo = true;
                    break;
                }
            }
        }
        if found_undo {
            break;
        }
    }
    assert!(found_undo, "should receive Undo(Resolve) when reopening");
}