🐸 Frogue(Fed)

~ololduck/Frogue

Raw

//! The commit diff viewer: shows the changes in a single commit.

mod support;

use std::fs;
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
use std::sync::atomic::{AtomicU64, Ordering};

use support::{Client, Server, free_port};

/// The password the tests register and log in with.
const PASSWORD: &str = "correct horse battery staple";

/// Names the scratch directories, so several tests can run at once.
static COUNTER: AtomicU64 = AtomicU64::new(0);

fn spawn() -> (Server, u16) {
    let port = free_port();
    let mut server = Server::spawn(&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 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, and checks it landed where it should.
fn create(client: &mut Client, owner: &str, slug: &str, visibility: &str) {
    let response = client.post_form(
        &format!("/~{owner}/repositories"),
        &[
            ("slug", slug),
            ("description", "a repository from the commit_diff tests"),
            ("visibility", visibility),
        ],
    );

    assert_eq!(
        response.status, 303,
        "creating `{owner}/{slug}` should redirect\n{}",
        response.body
    );
}

/// A directory of the test's own, removed when it goes out of scope.
struct Scratch {
    dir: PathBuf,
}

impl Scratch {
    fn new(label: &str) -> Self {
        let count = COUNTER.fetch_add(1, Ordering::Relaxed);
        let dir =
            std::env::temp_dir().join(format!("frogue-{label}-{}-{count}", std::process::id()));
        fs::create_dir_all(&dir).expect("the scratch directory should be creatable");
        Scratch { dir }
    }

    fn path(&self, name: &str) -> PathBuf {
        self.dir.join(name)
    }
}

impl Drop for Scratch {
    fn drop(&mut self) {
        let _ = fs::remove_dir_all(&self.dir);
    }
}

/// Runs `git` in `dir` with the test's own identity, with `env` set for this
/// call - the dated commits the listing order is asserted against.
fn git(dir: &Path, env: &[(&str, &str)], args: &[&str]) -> Output {
    let mut command = Command::new("git");
    command
        .current_dir(dir)
        .args(["-c", "user.name=Test", "-c", "user.email=test@example.org"])
        .args(args)
        .env("GIT_TERMINAL_PROMPT", "0")
        .env("GIT_CONFIG_GLOBAL", "/dev/null")
        .env("GIT_CONFIG_SYSTEM", "/dev/null");
    for (name, value) in env {
        command.env(name, value);
    }

    command.output().expect("git should run")
}

/// Fails with `git`'s own words when it did not succeed.
fn assert_ok(output: &Output, what: &str) {
    assert!(
        output.status.success(),
        "{what} should succeed, but git exited with {:?}\nstdout:\n{}\nstderr:\n{}",
        output.status.code(),
        String::from_utf8_lossy(&output.stdout),
        String::from_utf8_lossy(&output.stderr),
    );
}

/// Fills `owner/slug`'s bare repository with commits that have actual file changes.
fn populate_with_diffs(server: &Server, scratch: &Scratch, owner: &str, slug: &str) {
    let bare = server.repo_root().join(owner).join(format!("{slug}.git"));

    let work = scratch.path("work");
    fs::create_dir_all(&work).expect("the working tree should be creatable");
    assert_ok(
        &git(&work, &[], &["init", "-q", "-b", "main"]),
        "initialising",
    );

    // First commit: add a file
    fs::write(work.join("readme.txt"), "hello\nworld\n").expect("writing readme.txt");
    assert_ok(
        &git(&work, &[], &["add", "readme.txt"]),
        "adding readme.txt",
    );
    assert_ok(
        &git(&work, &[], &["commit", "-q", "-m", "Add readme"]),
        "committing readme",
    );

    // Second commit: modify the file
    fs::write(work.join("readme.txt"), "hello\nworld\nnew line\n").expect("modifying readme.txt");
    assert_ok(
        &git(&work, &[], &["add", "readme.txt"]),
        "adding modified readme.txt",
    );
    assert_ok(
        &git(&work, &[], &["commit", "-q", "-m", "Add a line"]),
        "committing modification",
    );

    // Third commit: add another file
    fs::write(work.join("notes.txt"), "some notes\n").expect("writing notes.txt");
    assert_ok(&git(&work, &[], &["add", "notes.txt"]), "adding notes.txt");
    assert_ok(
        &git(&work, &[], &["commit", "-q", "-m", "Add notes"]),
        "committing notes",
    );

    assert_ok(
        &git(&work, &[], &["push", "-q", "--all", bare.to_str().unwrap()]),
        "pushing",
    );
}

#[tokio::test]
async fn a_commit_diff_shows_the_changes() -> miette::Result<()> {
    let (server, port) = spawn();
    let scratch = Scratch::new("commit-diff-basic");
    let mut alice = Client::new(port);
    register(&mut alice, "alice");
    create(&mut alice, "alice", "test-repo", "public");
    populate_with_diffs(&server, &scratch, "alice", "test-repo");

    // Get the full hash of HEAD (the third commit)
    let work = scratch.path("work");
    let output = git(&work, &[], &["rev-parse", "HEAD"]);
    assert_ok(&output, "reading the full hash");
    let hash = String::from_utf8_lossy(&output.stdout).trim().to_string();

    // View the commit diff
    let response = alice.get(&format!("/~alice/test-repo/commit/{hash}"));
    assert_eq!(response.status, 200, "commit diff page");
    let body = &response.body;

    // Should show the commit message
    assert!(
        body.contains("Add notes"),
        "the commit message should be shown, got:\n{body}"
    );

    // Should show the file that was changed
    assert!(
        body.contains("notes.txt"),
        "the changed file should be shown, got:\n{body}"
    );

    // Should show the added line
    assert!(
        body.contains("+some notes"),
        "the added line should be shown, got:\n{body}"
    );

    Ok(())
}

#[tokio::test]
async fn a_commit_diff_links_to_its_tree() -> miette::Result<()> {
    let (server, port) = spawn();
    let scratch = Scratch::new("commit-diff-tree-link");
    let mut alice = Client::new(port);
    register(&mut alice, "alice");
    create(&mut alice, "alice", "test-repo", "public");
    populate_with_diffs(&server, &scratch, "alice", "test-repo");

    // Get the full hash of HEAD
    let work = scratch.path("work");
    let output = git(&work, &[], &["rev-parse", "HEAD"]);
    assert_ok(&output, "reading the full hash");
    let hash = String::from_utf8_lossy(&output.stdout).trim().to_string();

    let response = alice.get(&format!("/~alice/test-repo/commit/{hash}"));
    assert_eq!(response.status, 200, "commit diff page");
    let body = &response.body;

    // Should have a link to the tree view
    assert!(
        body.contains(&format!("/~alice/test-repo/tree/{hash}")),
        "should link to the tree at this commit, got:\n{body}"
    );

    Ok(())
}

#[tokio::test]
async fn a_commit_diff_shows_stats() -> miette::Result<()> {
    let (server, port) = spawn();
    let scratch = Scratch::new("commit-diff-stats");
    let mut alice = Client::new(port);
    register(&mut alice, "alice");
    create(&mut alice, "alice", "test-repo", "public");
    populate_with_diffs(&server, &scratch, "alice", "test-repo");

    // Get the full hash of the second commit (which modified readme.txt)
    let work = scratch.path("work");
    let output = git(&work, &[], &["rev-parse", "HEAD~1"]);
    assert_ok(&output, "reading the full hash");
    let hash = String::from_utf8_lossy(&output.stdout).trim().to_string();

    let response = alice.get(&format!("/~alice/test-repo/commit/{hash}"));
    assert_eq!(response.status, 200, "commit diff page");
    let body = &response.body;

    // Should show stats (1 file changed, 1 insertion, 0 deletions)
    assert!(
        body.contains("1 file") || body.contains("1 files"),
        "should show files changed, got:\n{body}"
    );
    assert!(body.contains("+1"), "should show insertions, got:\n{body}");

    Ok(())
}

#[tokio::test]
async fn an_unknown_commit_is_not_found() -> miette::Result<()> {
    let (server, port) = spawn();
    let scratch = Scratch::new("commit-diff-404");
    let mut alice = Client::new(port);
    register(&mut alice, "alice");
    create(&mut alice, "alice", "test-repo", "public");
    populate_with_diffs(&server, &scratch, "alice", "test-repo");

    let response = alice.get("/~alice/test-repo/commit/deadbeef12345678");
    assert_eq!(response.status, 404, "unknown commit should be 404");

    Ok(())
}

#[tokio::test]
async fn a_commit_starting_with_dash_is_refused() -> miette::Result<()> {
    let (server, port) = spawn();
    let scratch = Scratch::new("commit-diff-dash");
    let mut alice = Client::new(port);
    register(&mut alice, "alice");
    create(&mut alice, "alice", "test-repo", "public");
    populate_with_diffs(&server, &scratch, "alice", "test-repo");

    let response = alice.get("/~alice/test-repo/commit/-HEAD");
    assert_eq!(
        response.status, 404,
        "commit starting with - should be refused"
    );

    Ok(())
}

#[tokio::test]
async fn a_private_repository_is_not_shown_to_anonymous_users() -> miette::Result<()> {
    let (server, port) = spawn();
    let scratch = Scratch::new("commit-diff-private");
    let mut alice = Client::new(port);
    register(&mut alice, "alice");
    create(&mut alice, "alice", "test-repo", "private");
    populate_with_diffs(&server, &scratch, "alice", "test-repo");

    // Get the hash while we're authenticated
    let work = scratch.path("work");
    let output = git(&work, &[], &["rev-parse", "HEAD"]);
    assert_ok(&output, "reading the full hash");
    let hash = String::from_utf8_lossy(&output.stdout).trim().to_string();

    // Anonymous client should get 404
    let mut anonymous = Client::new(port);
    let response = anonymous.get(&format!("/~alice/test-repo/commit/{hash}"));
    assert_eq!(
        response.status, 404,
        "anonymous should get 404 for a private repo"
    );

    Ok(())
}

#[tokio::test]
async fn the_commit_list_shows_stats() -> miette::Result<()> {
    let (server, port) = spawn();
    let scratch = Scratch::new("commit-list-stats");
    let mut alice = Client::new(port);
    register(&mut alice, "alice");
    create(&mut alice, "alice", "test-repo", "public");
    populate_with_diffs(&server, &scratch, "alice", "test-repo");

    let response = alice.get("/~alice/test-repo/commits/main");
    assert_eq!(response.status, 200, "commits page");
    let body = &response.body;

    // Should show stats for each commit
    // First commit: +2 lines (readme.txt)
    // Second commit: +1 line (modification to readme.txt)
    // Third commit: +1 line (notes.txt)
    assert!(
        body.contains("+2") || body.contains("+1"),
        "should show insertions count, got:\n{body}"
    );

    Ok(())
}

#[tokio::test]
async fn commit_hashes_link_to_the_diff_page() -> miette::Result<()> {
    let (server, port) = spawn();
    let scratch = Scratch::new("commit-hashes-to-diff");
    let mut alice = Client::new(port);
    register(&mut alice, "alice");
    create(&mut alice, "alice", "test-repo", "public");
    populate_with_diffs(&server, &scratch, "alice", "test-repo");

    // Get the full hash of HEAD
    let work = scratch.path("work");
    let output = git(&work, &[], &["rev-parse", "HEAD"]);
    assert_ok(&output, "reading the full hash");
    let full = String::from_utf8_lossy(&output.stdout).trim().to_string();

    let response = alice.get("/~alice/test-repo/commits/main");
    assert_eq!(response.status, 200, "commits page");
    let body = &response.body;

    // Hash should link to /commit/{hash}, not /tree/{hash}
    assert!(
        body.contains(&format!("/~alice/test-repo/commit/{full}")),
        "the hash should link to the diff page, got:\n{body}"
    );

    Ok(())
}