//! The tickets of a repository: the list and its label filters, the search
//! over it, and what may be done to a ticket and by whom.
//!
//! Everything goes through the forms the forge itself serves, and every page
//! is read back out of the HTML a browser would be handed.

mod support;

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

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

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 tickets tests"),
            ("visibility", visibility),
        ],
    );

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

/// Opens a ticket with the state and labels given, and returns the answer to
/// read the address it landed on out of.
fn open(
    client: &mut Client,
    owner: &str,
    slug: &str,
    summary: &str,
    description: &str,
    state: &str,
    labels: &[&str],
) -> Response {
    let mut fields: Vec<(&str, &str)> = vec![
        ("summary", summary),
        ("description", description),
        ("state", state),
    ];
    for label in labels {
        fields.push(("labels", label));
    }

    client.post_form(&format!("/~{owner}/{slug}/tickets"), &fields)
}

/// How one ticket is addressed on a page, quote and all, so that a match is
/// the ticket's own link rather than a number the page happens to contain.
fn address(owner: &str, slug: &str, number: i64) -> String {
    format!("/~{owner}/{slug}/tickets/{number}\"")
}

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

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

    // The tab is one of the repository's own pages, and says so.
    assert!(
        body.contains(r#"<a href="/~alice/test-repo/tickets" aria-current="page">Tickets</a>"#),
        "the tickets page should be marked in the tab bar, got:\n{body}"
    );
    // And the repository's page leads to it, without marking it as where the
    // reader already is.
    let landing = alice.get("/~alice/test-repo");
    assert_eq!(landing.status, 200, "the repository page");
    assert!(
        landing
            .body
            .contains(r#"<a href="/~alice/test-repo/tickets">Tickets</a>"#),
        "the repository page should lead to its tickets, got:\n{}",
        landing.body
    );

    // The state tabs: Open and Closed
    for state in ["open", "closed"] {
        assert!(
            body.contains(&format!("?state={state}")),
            "`{state}` should be offered as a state filter, got:\n{body}"
        );
    }

    // The labels a repository starts with, each one a way to filter by it.
    for label in ["bug", "improvement"] {
        assert!(
            body.contains(&format!("?state=open&amp;labels={label}")),
            "`{label}` should be offered as a filter, got:\n{body}"
        );
    }
    // ... and the way to drop the filter again, which the default one needs.
    assert!(
        body.contains(r#"All labels"#),
        "the list should offer a way to show every label, got:\n{body}"
    );

    assert!(
        body.contains(r#"form class="ticket-search""#),
        "the list should offer a search, got:\n{body}"
    );
    assert!(
        body.contains("Nothing here yet."),
        "a repository without tickets should say so, got:\n{body}"
    );

    Ok(())
}

#[tokio::test]
async fn a_ticket_is_numbered_within_its_repository_and_shown_as_markdown() -> miette::Result<()> {
    let (server, port) = spawn();
    let mut alice = Client::new(port);
    register(&mut alice, "alice");
    create(&mut alice, "alice", "test-repo", "public");
    create(&mut alice, "alice", "other", "public");

    let first = open(
        &mut alice,
        "alice",
        "test-repo",
        "Cranberry sauce",
        "A **bold** sauce\n\n<script>alert(1)</script>",
        "open",
        &["bug"],
    );
    assert_eq!(first.status, 303, "opening a ticket\n{}", first.body);
    assert_eq!(
        first.location.as_deref(),
        Some("/~alice/test-repo/tickets/1"),
        "the first ticket should be number one"
    );

    let second = open(
        &mut alice,
        "alice",
        "test-repo",
        "Binary blobs",
        "second",
        "open",
        &[],
    );
    assert_eq!(second.status, 303, "opening a ticket\n{}", second.body);
    assert_eq!(
        second.location.as_deref(),
        Some("/~alice/test-repo/tickets/2"),
        "the next ticket should be number two"
    );

    // Another repository counts from one again: the number names a ticket of
    // one repository, not of the forge.
    let elsewhere = open(&mut alice, "alice", "other", "Elsewhere", "", "open", &[]);
    assert_eq!(
        elsewhere.location.as_deref(),
        Some("/~alice/other/tickets/1"),
        "another repository's first ticket should be number one\n{}",
        elsewhere.body
    );

    let detail = alice.get("/~alice/test-repo/tickets/1");
    assert_eq!(detail.status, 200, "the ticket");
    let body = &detail.body;
    assert!(
        body.contains("<strong>bold</strong>"),
        "a ticket's description is markdown, got:\n{body}"
    );
    assert!(
        !body.contains("<script"),
        "a ticket's description is not to be trusted as HTML, got:\n{body}"
    );
    assert!(
        body.contains("opened by"),
        "the ticket should say who opened it, got:\n{body}"
    );
    assert!(
        body.contains(r#"ticket-label">bug</span>"#),
        "the ticket should show its labels, got:\n{body}"
    );

    // Newest first, which for these numbers is the opposite of counting up.
    let list = alice.get("/~alice/test-repo/tickets");
    assert_eq!(list.status, 200, "the list");
    let two = list
        .body
        .find(&address("alice", "test-repo", 2))
        .expect("the second ticket should be listed");
    let one = list
        .body
        .find(&address("alice", "test-repo", 1))
        .expect("the first ticket should be listed");
    assert!(two < one, "tickets should be listed newest first");

    Ok(())
}

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

    for (summary, state, labels) in [
        ("first", "open", vec!["bug"]),
        ("second", "open", vec![]),
        ("third", "closed", vec!["bug"]),
    ] {
        let answer = open(
            &mut alice,
            "alice",
            "test-repo",
            summary,
            "",
            state,
            &labels,
        );
        assert_eq!(
            answer.status, 303,
            "opening `{summary}` should redirect\n{}",
            answer.body
        );
    }

    // The list as it opens: the `open` tickets, which is where every ticket
    // starts.
    let defaulting = alice.get("/~alice/test-repo/tickets");
    assert_eq!(defaulting.status, 200, "the list");
    assert!(
        defaulting.body.contains(&address("alice", "test-repo", 1)),
        "an open ticket should be listed by default, got:\n{}",
        defaulting.body
    );
    assert!(
        defaulting.body.contains(&address("alice", "test-repo", 2)),
        "an open ticket should be listed by default, got:\n{}",
        defaulting.body
    );
    assert!(
        !defaulting.body.contains(&address("alice", "test-repo", 3)),
        "a closed ticket should not be listed by default, got:\n{}",
        defaulting.body
    );

    // The filter dropped: every ticket, closed ones included.
    let every = alice.get("/~alice/test-repo/tickets?state=&labels=");
    assert_eq!(every.status, 200, "the unfiltered list");
    for number in [1, 2, 3] {
        assert!(
            every.body.contains(&address("alice", "test-repo", number)),
            "ticket {number} should be listed, got:\n{}",
            every.body
        );
    }

    // State and label combined: a ticket has to match both.
    let bug = alice.get("/~alice/test-repo/tickets?state=open&labels=bug");
    assert_eq!(bug.status, 200, "the filtered list");
    assert!(
        bug.body.contains(&address("alice", "test-repo", 1)),
        "a ticket matching both state and label should be listed, got:\n{}",
        bug.body
    );
    for number in [2, 3] {
        assert!(
            !bug.body.contains(&address("alice", "test-repo", number)),
            "ticket {number} should not be listed, got:\n{}",
            bug.body
        );
    }

    let closed = alice.get("/~alice/test-repo/tickets?state=closed&labels=bug");
    assert_eq!(closed.status, 200, "the filtered list");
    assert!(
        closed.body.contains(&address("alice", "test-repo", 3)),
        "a ticket matching both state and label should be listed, got:\n{}",
        closed.body
    );
    for number in [1, 2] {
        assert!(
            !closed.body.contains(&address("alice", "test-repo", number)),
            "ticket {number} should not be listed, got:\n{}",
            closed.body
        );
    }

    // A label nobody has applied matches nothing, rather than falling back to
    // the default view.
    let none = alice.get("/~alice/test-repo/tickets?labels=improvement");
    assert_eq!(none.status, 200, "the filtered list");
    assert!(
        none.body.contains("Nothing here yet."),
        "a label no ticket carries should list nothing, got:\n{}",
        none.body
    );

    Ok(())
}

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

    open(
        &mut alice,
        "alice",
        "test-repo",
        "Cranberry sauce",
        "fruity and **rich**",
        "open",
        &[],
    );
    open(
        &mut alice,
        "alice",
        "test-repo",
        "Binary blobs",
        "all about buffers",
        "open",
        &[],
    );

    // The summary and the description are both searched.
    for query in ["sauce", "fruity"] {
        let found = alice.get(&format!("/~alice/test-repo/tickets?q={query}"));
        assert_eq!(found.status, 200, "the search");
        assert!(
            found.body.contains(&address("alice", "test-repo", 1)),
            "`{query}` should find the first ticket, got:\n{}",
            found.body
        );
        assert!(
            !found.body.contains(&address("alice", "test-repo", 2)),
            "`{query}` should not find the second ticket, got:\n{}",
            found.body
        );
    }

    let nothing = alice.get("/~alice/test-repo/tickets?q=nosuchterm");
    assert_eq!(nothing.status, 200, "the search");
    assert!(
        nothing.body.contains("Nothing here yet."),
        "a search for nothing should list nothing, got:\n{}",
        nothing.body
    );

    // The search and the label filter are one view, not two: a ticket the
    // filter leaves out is not found by a search either.
    let filtered = alice.get("/~alice/test-repo/tickets?q=sauce&labels=closed");
    assert_eq!(filtered.status, 200, "the search");
    assert!(
        !filtered.body.contains(&address("alice", "test-repo", 1)),
        "a ticket the filter leaves out should stay out, got:\n{}",
        filtered.body
    );

    // Words a search engine has opinions of its own about, and a stray quote:
    // all of them are a search for those characters, and none of them a fault.
    for query in ["%22", "AND%20OR", "NEAR%2F(", "*"] {
        let answer = alice.get(&format!("/~alice/test-repo/tickets?q={query}"));
        assert_eq!(
            answer.status, 200,
            "`{query}` should be answered rather than refused"
        );
    }

    Ok(())
}

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

    open(
        &mut alice,
        "alice",
        "test-repo",
        "Before",
        "the old words",
        "open",
        &["bug"],
    );

    // The form comes back filled in with the ticket as it stands, labels
    // included - what is checked is what a save without touching them keeps.
    let form = alice.get("/~alice/test-repo/tickets/1/edit");
    assert_eq!(form.status, 200, "the edit form");
    assert!(
        form.body.contains(r#"value="Before""#),
        "the form should carry the summary, got:\n{}",
        form.body
    );
    assert!(
        form.body.contains("the old words"),
        "the form should carry the description, got:\n{}",
        form.body
    );
    assert!(
        form.body.contains(r#"value="open" checked"#),
        "the labels a ticket carries should come back checked, got:\n{}",
        form.body
    );
    assert!(
        !form.body.contains(r#"value="closed" checked"#),
        "the labels a ticket does not carry should come back unchecked, got:\n{}",
        form.body
    );

    let saved = alice.post_form(
        "/~alice/test-repo/tickets/1",
        &[
            ("summary", "After"),
            ("description", "the new words"),
            ("state", "closed"),
            ("labels", "bug"),
        ],
    );
    assert_eq!(
        saved.status, 303,
        "saving an edit should redirect\n{}",
        saved.body
    );
    assert_eq!(
        saved.location.as_deref(),
        Some("/~alice/test-repo/tickets/1"),
        "saving should lead back to the ticket"
    );

    let detail = alice.get("/~alice/test-repo/tickets/1");
    assert_eq!(detail.status, 200, "the ticket");
    assert!(
        detail.body.contains("After") && detail.body.contains("the new words"),
        "the ticket should show what it was edited to, got:\n{}",
        detail.body
    );
    assert!(
        detail.body.contains(r#"State: closed"#)
            && detail.body.contains(r#"ticket-label">bug</span>"#),
        "the ticket should show its new state and labels, got:\n{}",
        detail.body
    );

    // And the edit moved it: out of the list as it opens, into the closed one.
    let defaulting = alice.get("/~alice/test-repo/tickets");
    assert!(
        !defaulting.body.contains(&address("alice", "test-repo", 1)),
        "an edited-away ticket should leave the default list, got:\n{}",
        defaulting.body
    );
    let closed = alice.get("/~alice/test-repo/tickets?state=closed");
    assert!(
        closed.body.contains(&address("alice", "test-repo", 1)),
        "a closed ticket should be listed under `closed`, got:\n{}",
        closed.body
    );

    Ok(())
}

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

    open(&mut alice, "alice", "test-repo", "first", "", "open", &[]);
    open(&mut alice, "alice", "test-repo", "second", "", "open", &[]);

    let deleted = alice.post_form("/~alice/test-repo/tickets/1/delete", &[]);
    assert_eq!(
        deleted.status, 303,
        "deleting should redirect\n{}",
        deleted.body
    );
    assert_eq!(
        deleted.location.as_deref(),
        Some("/~alice/test-repo/tickets"),
        "deleting should lead back to the list"
    );
    assert_eq!(
        alice.get("/~alice/test-repo/tickets/1").status,
        404,
        "a deleted ticket should not be served"
    );

    let list = alice.get("/~alice/test-repo/tickets");
    assert!(
        !list.body.contains(&address("alice", "test-repo", 1)),
        "a deleted ticket should leave the list, got:\n{}",
        list.body
    );
    assert!(
        list.body.contains(&address("alice", "test-repo", 2)),
        "the ticket that was not deleted should stay, got:\n{}",
        list.body
    );

    // Numbering moves on rather than back: a ticket that is gone is still
    // talked about by its number, so the number cannot come to mean another
    // ticket.
    let third = open(&mut alice, "alice", "test-repo", "third", "", "open", &[]);
    assert_eq!(
        third.location.as_deref(),
        Some("/~alice/test-repo/tickets/3"),
        "the next ticket should not be given the deleted number\n{}",
        third.body
    );

    Ok(())
}

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

    let mut bob = Client::new(port);
    register(&mut bob, "bob");
    let opened = open(&mut bob, "alice", "test-repo", "from bob", "", "open", &[]);
    assert_eq!(
        opened.status, 303,
        "anyone signed in should be able to open a ticket\n{}",
        opened.body
    );

    let mut carol = Client::new(port);
    register(&mut carol, "carol");

    // The owner and the creator both may edit; somebody who is neither may
    // not even be handed the form.
    assert_eq!(
        alice.get("/~alice/test-repo/tickets/1/edit").status,
        200,
        "the owner should reach the edit form"
    );
    assert_eq!(
        bob.get("/~alice/test-repo/tickets/1/edit").status,
        200,
        "the creator should reach the edit form"
    );
    assert_eq!(
        carol.get("/~alice/test-repo/tickets/1/edit").status,
        403,
        "somebody else should not reach the edit form"
    );
    assert_eq!(
        carol
            .post_form(
                "/~alice/test-repo/tickets/1",
                &[("summary", "not yours"), ("description", "")]
            )
            .status,
        403,
        "somebody else should not be able to edit"
    );
    assert_eq!(
        carol
            .post_form("/~alice/test-repo/tickets/1/delete", &[])
            .status,
        403,
        "somebody else should not be able to delete"
    );

    // Labels are the repository's, so only the owner makes them.
    assert_eq!(
        alice
            .post_form(
                "/~alice/test-repo/settings/labels",
                &[("name", "help wanted")]
            )
            .status,
        303,
        "the owner should be able to make a label"
    );
    assert_eq!(
        carol
            .post_form("/~alice/test-repo/settings/labels", &[("name", "nope")])
            .status,
        403,
        "somebody else should not be able to make a label"
    );

    let labelled = open(
        &mut alice,
        "alice",
        "test-repo",
        "needs hands",
        "",
        "open",
        &["help wanted"],
    );
    assert_eq!(
        labelled.status, 303,
        "a ticket should be able to carry a new label\n{}",
        labelled.body
    );
    let filtered = alice.get("/~alice/test-repo/tickets?labels=help%20wanted");
    assert_eq!(filtered.status, 200, "the list");
    assert!(
        filtered.body.contains(&address("alice", "test-repo", 2)),
        "a ticket carrying the label should be listed under it, got:\n{}",
        filtered.body
    );

    // The creator may take their own ticket down again.
    assert_eq!(
        bob.post_form("/~alice/test-repo/tickets/1/delete", &[])
            .status,
        303,
        "the creator should be able to delete their own ticket"
    );

    Ok(())
}

#[tokio::test]
async fn tickets_need_a_signed_in_visitor_and_a_repository_that_can_be_read() -> miette::Result<()>
{
    let (server, port) = spawn();
    let mut alice = Client::new(port);
    register(&mut alice, "alice");
    create(&mut alice, "alice", "test-repo", "public");
    create(&mut alice, "alice", "secret", "private");
    open(&mut alice, "alice", "secret", "hidden", "", "open", &[]);

    // Reading a public repository's tickets is what reading it means, so it
    // needs nobody signed in - but there is nothing for a stranger to write
    // with, and the page does not offer them what they cannot use.
    let mut anonymous = Client::new(port);
    let seen = anonymous.get("/~alice/test-repo/tickets");
    assert_eq!(
        seen.status, 200,
        "a stranger should be able to read a public repository's tickets"
    );
    assert!(
        !seen.body.contains("/tickets/new"),
        "a stranger should not be offered the way to open one, got:\n{}",
        seen.body
    );

    let mut stranger = Client::new(port);
    assert_eq!(
        stranger.get("/~alice/test-repo/tickets/new").status,
        401,
        "a stranger should not reach the form"
    );
    assert_eq!(
        stranger
            .post_form(
                "/~alice/test-repo/tickets",
                &[("summary", "mine"), ("description", "")]
            )
            .status,
        401,
        "a stranger should not be able to open a ticket"
    );
    assert_eq!(
        stranger
            .post_form("/~alice/test-repo/tickets/1/delete", &[])
            .status,
        401,
        "a stranger should not be able to delete"
    );
    assert_eq!(
        stranger
            .post_form("/~alice/test-repo/settings/labels", &[("name", "mine")])
            .status,
        401,
        "a stranger should not be able to make a label"
    );

    // A private repository is answered as though it were not there, its
    // tickets no more reachable than the rest of it.
    register(&mut stranger, "bob");
    assert_eq!(
        stranger.get("/~alice/secret/tickets").status,
        404,
        "another user should not reach a private repository's tickets"
    );
    assert_eq!(
        stranger.get("/~alice/secret/tickets/1").status,
        404,
        "another user should not reach a private ticket"
    );
    assert_eq!(
        alice.get("/~alice/secret/tickets").status,
        200,
        "the owner should reach a private repository's tickets"
    );

    // The same answers a repository's own page gives for what does not exist.
    assert_eq!(
        alice.get("/~alice/nothing/tickets").status,
        404,
        "a repository that does not exist"
    );
    assert_eq!(
        alice.get("/~alice/test-repo/tickets/9").status,
        404,
        "a ticket that does not exist"
    );

    Ok(())
}

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

    // The summary is the one thing a ticket cannot do without, and it is asked
    // for in the words the column holds rather than in bytes of SQLite.
    let blank = open(&mut alice, "alice", "test-repo", "", "", "open", &[]);
    assert_eq!(
        blank.status, 200,
        "a ticket without a summary should be refused\n{}",
        blank.body
    );
    assert!(
        blank.body.contains("needs a summary"),
        "the refusal should say what is missing, got:\n{}",
        blank.body
    );

    let padded = open(&mut alice, "alice", "test-repo", "   ", "", "open", &[]);
    assert_eq!(
        padded.status, 200,
        "a summary of nothing but space should be refused\n{}",
        padded.body
    );

    let long = open(
        &mut alice,
        "alice",
        "test-repo",
        &"x".repeat(256),
        "",
        "open",
        &[],
    );
    assert_eq!(
        long.status, 200,
        "a summary too long for the column should be refused\n{}",
        long.body
    );
    assert!(
        long.body.contains("255"),
        "the refusal should say how long a summary may be, got:\n{}",
        long.body
    );

    // A label of another moulding is not applied: the ticket would then be
    // filed under a filter that cannot be reached from the page.
    let unknown = open(
        &mut alice,
        "alice",
        "test-repo",
        "fine",
        "",
        "open",
        &["nope"],
    );
    assert_eq!(
        unknown.status, 200,
        "a label this repository does not have should be refused\n{}",
        unknown.body
    );
    assert!(
        unknown.body.contains("`nope` is not a label"),
        "the refusal should name the label, got:\n{}",
        unknown.body
    );

    // And none of that opened anything: the first ticket is still the first.
    let good = open(&mut alice, "alice", "test-repo", "fine", "", "open", &[]);
    assert_eq!(
        good.location.as_deref(),
        Some("/~alice/test-repo/tickets/1"),
        "the refused attempts should have created nothing\n{}",
        good.body
    );

    Ok(())
}

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

    let blank = alice.post_form("/~alice/test-repo/settings/labels", &[("name", "  ")]);
    assert_eq!(
        blank.status, 200,
        "a label without a name should be refused\n{}",
        blank.body
    );
    assert!(
        blank.body.contains("needs a name"),
        "the refusal should say what is missing, got:\n{}",
        blank.body
    );

    // A comma is the one thing the filter cannot carry, since that is what
    // the labels of one filter are joined by.
    let comma = alice.post_form("/~alice/test-repo/settings/labels", &[("name", "one,two")]);
    assert_eq!(
        comma.status, 200,
        "a label holding a comma should be refused\n{}",
        comma.body
    );
    assert!(
        comma.body.contains("comma"),
        "the refusal should say why, got:\n{}",
        comma.body
    );

    let made = alice.post_form("/~alice/test-repo/settings/labels", &[("name", "urgent")]);
    assert_eq!(
        made.status, 303,
        "the owner should be able to make a label\n{}",
        made.body
    );
    assert_eq!(
        made.location.as_deref(),
        Some("/~alice/test-repo/settings"),
        "making a label should lead back to the settings page"
    );

    let list = alice.get("/~alice/test-repo/tickets");
    assert!(
        list.body.contains("?state=open&amp;labels=urgent\""),
        "a new label should be offered as a filter, got:\n{}",
        list.body
    );

    // A label of the same name again is not a second label: the filter would
    // not be able to tell them apart.
    let again = alice.post_form("/~alice/test-repo/settings/labels", &[("name", "urgent")]);
    assert_eq!(
        again.status, 200,
        "the same label twice should be refused\n{}",
        again.body
    );
    assert!(
        again.body.contains("`urgent`"),
        "the refusal should name the label, got:\n{}",
        again.body
    );

    Ok(())
}

#[tokio::test]
async fn an_attachment_can_be_uploaded_and_downloaded() -> miette::Result<()> {
    let (server, port) = spawn();
    let mut alice = Client::new(port);
    register(&mut alice, "alice");
    create(&mut alice, "alice", "test-repo", "public");
    let response = open(
        &mut alice,
        "alice",
        "test-repo",
        "something broke",
        "the sauce is gone",
        "open",
        &[],
    );
    assert_eq!(
        response.status, 303,
        "opening a ticket should redirect\n{}",
        response.body
    );

    // Upload an attachment
    let filename = "sauce.txt";
    let content_type = "text/plain";
    let data = b"The sauce was here";
    let response = alice.post_multipart_file(
        "/~alice/test-repo/tickets/1/attachments",
        "attachment",
        filename,
        content_type,
        data,
    );
    assert_eq!(
        response.status, 303,
        "uploading an attachment should redirect\n{}",
        response.body
    );

    // View the ticket to see the attachment listed
    let response = alice.get("/~alice/test-repo/tickets/1");
    assert_eq!(response.status, 200, "viewing the ticket should succeed");
    assert!(
        response.body.contains(filename),
        "the ticket page should show the attachment, got:\n{}",
        response.body
    );

    // Find the attachment UUID from the page
    let uuid_start = response
        .body
        .find("/~alice/test-repo/tickets/1/attachments/")
        .unwrap();
    let uuid_end = response.body[uuid_start + 40..].find('"').unwrap() + uuid_start + 40;
    let uuid = &response.body[uuid_start + 40..uuid_end];

    // Download the attachment
    let response = alice.get(&format!("/~alice/test-repo/tickets/1/attachments/{uuid}"));
    assert_eq!(
        response.status, 200,
        "downloading the attachment should succeed\n{}",
        response.body
    );
    assert_eq!(
        response.body.as_bytes(),
        data,
        "the downloaded content should match"
    );

    Ok(())
}

#[tokio::test]
async fn an_attachment_larger_than_the_limit_is_refused() -> miette::Result<()> {
    let (_server, port) = spawn();
    let mut alice = Client::new(port);
    register(&mut alice, "alice");
    create(&mut alice, "alice", "test-repo", "public");
    let response = open(
        &mut alice,
        "alice",
        "test-repo",
        "something broke",
        "the sauce is gone",
        "open",
        &[],
    );
    assert_eq!(response.status, 303);

    // Try to upload a file larger than 2MB
    let large_data = vec![0u8; 2 * 1024 * 1024 + 1];
    let response = alice.post_multipart_file(
        "/~alice/test-repo/tickets/1/attachments",
        "attachment",
        "large.bin",
        "application/octet-stream",
        &large_data,
    );
    assert_eq!(
        response.status, 200,
        "a file larger than 2MB should re-render the page with an error\n{}",
        response.body
    );
    assert!(
        response.body.contains("too large"),
        "the refusal should mention the size, got:\n{}",
        response.body
    );

    Ok(())
}

#[tokio::test]
async fn a_ticket_cannot_have_more_than_the_limit_of_attachments() -> miette::Result<()> {
    let (_server, port) = spawn();
    let mut alice = Client::new(port);
    register(&mut alice, "alice");
    create(&mut alice, "alice", "test-repo", "public");
    let response = open(
        &mut alice,
        "alice",
        "test-repo",
        "something broke",
        "the sauce is gone",
        "open",
        &[],
    );
    assert_eq!(response.status, 303);

    // Upload 4 attachments (the default limit)
    for i in 1..=4 {
        let response = alice.post_multipart_file(
            "/~alice/test-repo/tickets/1/attachments",
            "attachment",
            &format!("file{i}.txt"),
            "text/plain",
            format!("content {i}").as_bytes(),
        );
        assert_eq!(
            response.status, 303,
            "uploading attachment {i} should succeed\n{}",
            response.body
        );
    }

    // Try to upload a 5th attachment
    let response = alice.post_multipart_file(
        "/~alice/test-repo/tickets/1/attachments",
        "attachment",
        "file5.txt",
        "text/plain",
        b"content 5",
    );
    assert_eq!(
        response.status, 400,
        "a 5th attachment should be refused\n{}",
        response.body
    );
    assert!(
        response.body.contains("maximum"),
        "the refusal should mention the limit, got:\n{}",
        response.body
    );

    Ok(())
}

#[tokio::test]
async fn an_attachment_can_be_deleted() -> miette::Result<()> {
    let (_server, port) = spawn();
    let mut alice = Client::new(port);
    register(&mut alice, "alice");
    create(&mut alice, "alice", "test-repo", "public");
    let response = open(
        &mut alice,
        "alice",
        "test-repo",
        "something broke",
        "the sauce is gone",
        "open",
        &[],
    );
    assert_eq!(response.status, 303);

    // Upload an attachment
    let response = alice.post_multipart_file(
        "/~alice/test-repo/tickets/1/attachments",
        "attachment",
        "sauce.txt",
        "text/plain",
        b"The sauce was here",
    );
    assert_eq!(response.status, 303);

    // View the ticket to get the UUID
    let response = alice.get("/~alice/test-repo/tickets/1");
    let uuid_start = response
        .body
        .find("/~alice/test-repo/tickets/1/attachments/")
        .unwrap();
    let uuid_end = response.body[uuid_start + 40..].find('"').unwrap() + uuid_start + 40;
    let uuid = &response.body[uuid_start + 40..uuid_end];

    // Delete the attachment
    let response = alice.post_form(
        &format!("/~alice/test-repo/tickets/1/attachments/{uuid}/delete"),
        &[],
    );
    assert_eq!(
        response.status, 303,
        "deleting an attachment should redirect\n{}",
        response.body
    );

    // Verify it's gone
    let response = alice.get(&format!("/~alice/test-repo/tickets/1/attachments/{uuid}"));
    assert_eq!(
        response.status, 404,
        "the deleted attachment should not be found\n{}",
        response.body
    );

    Ok(())
}

#[tokio::test]
async fn attachments_require_permission() -> miette::Result<()> {
    let (_server, port) = spawn();
    let mut alice = Client::new(port);
    register(&mut alice, "alice");
    create(&mut alice, "alice", "test-repo", "public");
    let response = open(
        &mut alice,
        "alice",
        "test-repo",
        "something broke",
        "the sauce is gone",
        "open",
        &[],
    );
    assert_eq!(response.status, 303);

    // Bob tries to upload an attachment
    let mut bob = Client::new(port);
    register(&mut bob, "bob");
    let response = bob.post_multipart_file(
        "/~alice/test-repo/tickets/1/attachments",
        "attachment",
        "bob.txt",
        "text/plain",
        b"Bob's file",
    );
    assert_eq!(
        response.status, 403,
        "a non-owner should not be able to upload attachments\n{}",
        response.body
    );

    // Anonymous user tries to upload
    let mut anonymous = Client::new(port);
    let response = anonymous.post_multipart_file(
        "/~alice/test-repo/tickets/1/attachments",
        "attachment",
        "anon.txt",
        "text/plain",
        b"Anonymous file",
    );
    assert_eq!(
        response.status, 401,
        "an anonymous user should not be able to upload attachments"
    );

    // Alice (the ticket creator) can upload
    let response = alice.post_multipart_file(
        "/~alice/test-repo/tickets/1/attachments",
        "attachment",
        "alice.txt",
        "text/plain",
        b"Alice's file",
    );
    assert_eq!(
        response.status, 303,
        "the ticket creator should be able to upload attachments\n{}",
        response.body
    );

    Ok(())
}
