🐸 Frogue(Fed)

~ololduck/Frogue

Raw

//! Phase 4 tests: Logged-in home feed with remote notes

mod support;

use std::time::{Duration, Instant};

use frogue::views::index::FEED_PAGE_SIZE;
use support::ap::RemoteActor;
use support::{Client, Server, free_port};

/// Register a user through the web form.
fn register(client: &mut Client, name: &str) {
    let email = format!("{name}@example.org");
    let response = client.post_form(
        "/register",
        &[
            ("name", name),
            ("email", &email),
            ("password", "correct horse battery staple"),
        ],
    );
    assert_eq!(
        response.status, 303,
        "registering should log in\n{}",
        response.body
    );
}

/// Helper to follow a remote actor and wait for Accept
fn follow_and_accept(
    alice_client: &mut Client,
    port: u16,
    recorder: &RemoteActor,
    recorder_port: u16,
) -> String {
    let handle = format!("remote@127.0.0.1:{recorder_port}");
    // Use the new one-step flow: POST to /follow with handle
    alice_client.post_form("/~alice/follow", &[("handle", &handle)]);

    // Wait for Follow delivery
    let deadline = Instant::now() + Duration::from_secs(5);
    let mut follow_activity_id = None;
    while Instant::now() < deadline {
        std::thread::sleep(Duration::from_millis(50));
        let deliveries = recorder.recorded_deliveries();
        for delivery in &deliveries {
            if let Ok(activity) = serde_json::from_str::<serde_json::Value>(&delivery.body) {
                if activity.get("type").and_then(|t| t.as_str()) == Some("Follow") {
                    follow_activity_id = activity
                        .get("id")
                        .and_then(|v| v.as_str())
                        .map(|s| s.to_string());
                    break;
                }
            }
        }
        if follow_activity_id.is_some() {
            break;
        }
    }
    let follow_id = follow_activity_id.expect("should have received Follow activity");

    // Send Accept(Follow) to mark the follow as accepted
    let accept_json = serde_json::json!({
        "@context": "https://www.w3.org/ns/activitystreams",
        "id": format!("{}/accept/1", recorder.actor_url),
        "type": "Accept",
        "actor": recorder.actor_url,
        "object": {
            "id": follow_id,
            "type": "Follow",
            "actor": format!("http://127.0.0.1:{port}/~alice"),
            "object": recorder.actor_url
        }
    });
    let body = serde_json::to_string(&accept_json).unwrap();
    let (signature, date, digest) = support::ap::signed_post(
        &recorder.private_key_pem,
        &recorder.key_id,
        &format!("127.0.0.1:{port}"),
        "/~alice/inbox",
        &body,
    );
    alice_client.post_json_with_headers(
        "/~alice/inbox",
        &body,
        &[
            ("Signature", &signature),
            ("Date", &date),
            ("Digest", &digest),
            ("Content-Type", "application/activity+json"),
        ],
    );

    follow_id
}

/// Test: Logged-in user sees remote notes from followees in their feed
#[tokio::test]
async fn logged_in_user_sees_remote_notes_in_feed() -> miette::Result<()> {
    let port = free_port();
    let mut server = Server::spawn_federated(&format!("127.0.0.1:{port}"));
    server.wait_for_connection(port);

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

    // Start a remote actor
    let recorder_port = free_port();
    let recorder = RemoteActor::serve(recorder_port);

    // Follow the remote actor
    follow_and_accept(&mut alice_client, port, &recorder, recorder_port);

    // Send a Create(Note) activity from the remote actor
    let note_json = serde_json::json!({
        "@context": "https://www.w3.org/ns/activitystreams",
        "id": format!("{}/activities/note1", recorder.actor_url),
        "type": "Create",
        "actor": recorder.actor_url,
        "object": {
            "id": format!("{}/notes/note1", recorder.actor_url),
            "type": "Note",
            "attributedTo": recorder.actor_url,
            "content": "<p>Hello from remote!</p>",
            "published": "2026-11-01T12:00:00Z"
        }
    });
    let body = serde_json::to_string(&note_json).unwrap();
    let (signature, date, digest) = support::ap::signed_post(
        &recorder.private_key_pem,
        &recorder.key_id,
        &format!("127.0.0.1:{port}"),
        "/~alice/inbox",
        &body,
    );
    alice_client.post_json_with_headers(
        "/~alice/inbox",
        &body,
        &[
            ("Signature", &signature),
            ("Date", &date),
            ("Digest", &digest),
            ("Content-Type", "application/activity+json"),
        ],
    );

    // Give it a moment to process
    std::thread::sleep(Duration::from_millis(100));

    // Visit the home page - should see the feed
    let response = alice_client.get("/");
    assert_eq!(response.status, 200, "home page should load");
    assert!(
        response.body.contains("Hello from remote!"),
        "feed should contain the remote note content. Body:\n{}",
        response.body
    );
    assert!(
        response.body.contains("remote@127.0.0.1:"),
        "feed should show the remote actor's handle. Body:\n{}",
        response.body
    );

    Ok(())
}

/// Test: Logged-in user sees their own notes in the feed
#[tokio::test]
async fn logged_in_user_sees_own_notes_in_feed() -> miette::Result<()> {
    let port = free_port();
    let mut server = Server::spawn_federated(&format!("127.0.0.1:{port}"));
    server.wait_for_connection(port);

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

    // Post a note
    let response = alice_client.post_form("/~alice/notes", &[("content", "My own note")]);
    assert_eq!(
        response.status, 303,
        "posting a note should redirect. Body:\n{}",
        response.body
    );

    // Visit the home page - should see the feed
    let response = alice_client.get("/");
    assert_eq!(response.status, 200, "home page should load");
    assert!(
        response.body.contains("My own note"),
        "feed should contain the user's own note. Body:\n{}",
        response.body
    );

    Ok(())
}

/// Test: Anonymous user sees empty home page
#[tokio::test]
async fn anonymous_user_sees_empty_home_page() -> miette::Result<()> {
    let port = free_port();
    let mut server = Server::spawn_federated(&format!("127.0.0.1:{port}"));
    server.wait_for_connection(port);

    // Anonymous client (not logged in)
    let mut anon_client = Client::new(port);

    let response = anon_client.get("/");
    assert_eq!(response.status, 200, "home page should load for anonymous");
    // Should not contain feed-related markup
    // (We can't check the absence of everything, but we can check it doesn't crash)

    Ok(())
}

/// Test: Feed shows notes ordered newest-first
#[tokio::test]
async fn feed_orders_notes_newest_first() -> miette::Result<()> {
    let port = free_port();
    let mut server = Server::spawn_federated(&format!("127.0.0.1:{port}"));
    server.wait_for_connection(port);

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

    // Start two remote actors
    let recorder1_port = free_port();
    let recorder1 = RemoteActor::serve(recorder1_port);
    let recorder2_port = free_port();
    let recorder2 = RemoteActor::serve(recorder2_port);

    // Follow both
    follow_and_accept(&mut alice_client, port, &recorder1, recorder1_port);
    follow_and_accept(&mut alice_client, port, &recorder2, recorder2_port);

    // Send notes with different timestamps
    let note1_json = serde_json::json!({
        "@context": "https://www.w3.org/ns/activitystreams",
        "id": format!("{}/activities/note1", recorder1.actor_url),
        "type": "Create",
        "actor": recorder1.actor_url,
        "object": {
            "id": format!("{}/notes/note1", recorder1.actor_url),
            "type": "Note",
            "attributedTo": recorder1.actor_url,
            "content": "<p>Older note</p>",
            "published": "2026-11-01T10:00:00Z"
        }
    });
    let body1 = serde_json::to_string(&note1_json).unwrap();
    let (signature1, date1, digest1) = support::ap::signed_post(
        &recorder1.private_key_pem,
        &recorder1.key_id,
        &format!("127.0.0.1:{port}"),
        "/~alice/inbox",
        &body1,
    );
    alice_client.post_json_with_headers(
        "/~alice/inbox",
        &body1,
        &[
            ("Signature", &signature1),
            ("Date", &date1),
            ("Digest", &digest1),
            ("Content-Type", "application/activity+json"),
        ],
    );

    let note2_json = serde_json::json!({
        "@context": "https://www.w3.org/ns/activitystreams",
        "id": format!("{}/activities/note2", recorder2.actor_url),
        "type": "Create",
        "actor": recorder2.actor_url,
        "object": {
            "id": format!("{}/notes/note2", recorder2.actor_url),
            "type": "Note",
            "attributedTo": recorder2.actor_url,
            "content": "<p>Newer note</p>",
            "published": "2026-11-01T14:00:00Z"
        }
    });
    let body2 = serde_json::to_string(&note2_json).unwrap();
    let (signature2, date2, digest2) = support::ap::signed_post(
        &recorder2.private_key_pem,
        &recorder2.key_id,
        &format!("127.0.0.1:{port}"),
        "/~alice/inbox",
        &body2,
    );
    alice_client.post_json_with_headers(
        "/~alice/inbox",
        &body2,
        &[
            ("Signature", &signature2),
            ("Date", &date2),
            ("Digest", &digest2),
            ("Content-Type", "application/activity+json"),
        ],
    );

    // Give it a moment to process
    std::thread::sleep(Duration::from_millis(100));

    // Visit the home page
    let response = alice_client.get("/");
    assert_eq!(response.status, 200, "home page should load");

    // Both notes should be present
    assert!(
        response.body.contains("Older note"),
        "feed should contain older note. Body:\n{}",
        response.body
    );
    assert!(
        response.body.contains("Newer note"),
        "feed should contain newer note. Body:\n{}",
        response.body
    );

    // Newer note should appear before older note (check positions)
    let newer_pos = response.body.find("Newer note").unwrap_or(usize::MAX);
    let older_pos = response.body.find("Older note").unwrap_or(usize::MAX);
    assert!(
        newer_pos < older_pos,
        "newer note should appear before older note. newer_pos={}, older_pos={}",
        newer_pos,
        older_pos
    );

    Ok(())
}

/// Test: Feed does not show notes from non-followed actors
#[tokio::test]
async fn feed_does_not_show_notes_from_non_followed_actors() -> miette::Result<()> {
    let port = free_port();
    let mut server = Server::spawn_federated(&format!("127.0.0.1:{port}"));
    server.wait_for_connection(port);

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

    // Start a remote actor but don't follow
    let recorder_port = free_port();
    let recorder = RemoteActor::serve(recorder_port);

    // Send a Create(Note) activity (should be dropped)
    let note_json = serde_json::json!({
        "@context": "https://www.w3.org/ns/activitystreams",
        "id": format!("{}/activities/note1", recorder.actor_url),
        "type": "Create",
        "actor": recorder.actor_url,
        "object": {
            "id": format!("{}/notes/note1", recorder.actor_url),
            "type": "Note",
            "attributedTo": recorder.actor_url,
            "content": "<p>This should not appear</p>",
            "published": "2026-11-01T12:00:00Z"
        }
    });
    let body = serde_json::to_string(&note_json).unwrap();
    let (signature, date, digest) = support::ap::signed_post(
        &recorder.private_key_pem,
        &recorder.key_id,
        &format!("127.0.0.1:{port}"),
        "/~alice/inbox",
        &body,
    );
    alice_client.post_json_with_headers(
        "/~alice/inbox",
        &body,
        &[
            ("Signature", &signature),
            ("Date", &date),
            ("Digest", &digest),
            ("Content-Type", "application/activity+json"),
        ],
    );

    // Give it a moment to process
    std::thread::sleep(Duration::from_millis(100));

    // Visit the home page - should NOT see the note
    let response = alice_client.get("/");
    assert_eq!(response.status, 200, "home page should load");
    assert!(
        !response.body.contains("This should not appear"),
        "feed should NOT contain note from non-followed actor. Body:\n{}",
        response.body
    );

    Ok(())
}

/// Test: Feed shows correct reaction state for remote notes
#[tokio::test]
async fn feed_shows_react_button_before_reacting() -> miette::Result<()> {
    let port = free_port();
    let mut server = Server::spawn_federated(&format!("127.0.0.1:{port}"));
    server.wait_for_connection(port);

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

    // Start a remote actor
    let recorder_port = free_port();
    let recorder = RemoteActor::serve(recorder_port);

    // Follow the remote actor
    follow_and_accept(&mut alice_client, port, &recorder, recorder_port);

    // Send a Create(Note) activity from the remote actor
    let note_json = serde_json::json!({
        "@context": "https://www.w3.org/ns/activitystreams",
        "id": format!("{}/activities/note_react_test", recorder.actor_url),
        "type": "Create",
        "actor": recorder.actor_url,
        "object": {
            "id": format!("{}/notes/note_react_test", recorder.actor_url),
            "type": "Note",
            "attributedTo": recorder.actor_url,
            "content": "<p>Test note for reaction</p>",
            "published": "2026-11-01T12:00:00Z"
        }
    });
    let body = serde_json::to_string(&note_json).unwrap();
    let (signature, date, digest) = support::ap::signed_post(
        &recorder.private_key_pem,
        &recorder.key_id,
        &format!("127.0.0.1:{port}"),
        "/~alice/inbox",
        &body,
    );
    alice_client.post_json_with_headers(
        "/~alice/inbox",
        &body,
        &[
            ("Signature", &signature),
            ("Date", &date),
            ("Digest", &digest),
            ("Content-Type", "application/activity+json"),
        ],
    );

    // Give it a moment to process
    std::thread::sleep(Duration::from_millis(100));

    // Visit the home page - should see React button (not yet reacted)
    let response = alice_client.get("/");
    assert_eq!(response.status, 200, "home page should load");
    assert!(
        response.body.contains("Test note for reaction"),
        "feed should contain the note. Body:\n{}",
        response.body
    );
    assert!(
        response.body.contains("❤️ React") && !response.body.contains("❤️ Unreact"),
        "feed should show React button before reacting. Body:\n{}",
        response.body
    );

    Ok(())
}

/// Test: Feed shows Unreact button after reacting to a remote note
#[tokio::test]
async fn feed_shows_unreact_button_after_reacting() -> miette::Result<()> {
    let port = free_port();
    let mut server = Server::spawn_federated(&format!("127.0.0.1:{port}"));
    server.wait_for_connection(port);

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

    // Start a remote actor
    let recorder_port = free_port();
    let recorder = RemoteActor::serve(recorder_port);

    // Follow the remote actor
    follow_and_accept(&mut alice_client, port, &recorder, recorder_port);

    // Send a Create(Note) activity from the remote actor
    let note_json = serde_json::json!({
        "@context": "https://www.w3.org/ns/activitystreams",
        "id": format!("{}/activities/note_unreact_test", recorder.actor_url),
        "type": "Create",
        "actor": recorder.actor_url,
        "object": {
            "id": format!("{}/notes/note_unreact_test", recorder.actor_url),
            "type": "Note",
            "attributedTo": recorder.actor_url,
            "content": "<p>Test note for unreact</p>",
            "published": "2026-11-01T12:00:00Z"
        }
    });
    let body = serde_json::to_string(&note_json).unwrap();
    let (signature, date, digest) = support::ap::signed_post(
        &recorder.private_key_pem,
        &recorder.key_id,
        &format!("127.0.0.1:{port}"),
        "/~alice/inbox",
        &body,
    );
    alice_client.post_json_with_headers(
        "/~alice/inbox",
        &body,
        &[
            ("Signature", &signature),
            ("Date", &date),
            ("Digest", &digest),
            ("Content-Type", "application/activity+json"),
        ],
    );

    // Give it a moment to process
    std::thread::sleep(Duration::from_millis(100));

    // Find the remote note ID from the feed
    let feed_response = alice_client.get("/");
    let remote_note_id =
        extract_remote_note_id_from_feed(&feed_response.body, "/notes/note_unreact_test")
            .expect("should find remote note in feed");

    // React to the remote note
    let react_response =
        alice_client.post_form(&format!("/remote-notes/{remote_note_id}/react"), &[]);
    assert_eq!(react_response.status, 303, "reacting should redirect");

    // Visit the home page again - should see Unreact button (already reacted)
    let response = alice_client.get("/");
    assert_eq!(response.status, 200, "home page should load");
    assert!(
        response.body.contains("Test note for unreact"),
        "feed should contain the note. Body:\n{}",
        response.body
    );
    assert!(
        response.body.contains("❤️ Unreact"),
        "feed should show Unreact button after reacting. Body:\n{}",
        response.body
    );

    Ok(())
}

/// Test: Feed shows correct boost state for remote notes
#[tokio::test]
async fn feed_shows_boost_button_before_boosting() -> miette::Result<()> {
    let port = free_port();
    let mut server = Server::spawn_federated(&format!("127.0.0.1:{port}"));
    server.wait_for_connection(port);

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

    // Start a remote actor
    let recorder_port = free_port();
    let recorder = RemoteActor::serve(recorder_port);

    // Follow the remote actor
    follow_and_accept(&mut alice_client, port, &recorder, recorder_port);

    // Send a Create(Note) activity from the remote actor
    let note_json = serde_json::json!({
        "@context": "https://www.w3.org/ns/activitystreams",
        "id": format!("{}/activities/note_boost_test", recorder.actor_url),
        "type": "Create",
        "actor": recorder.actor_url,
        "object": {
            "id": format!("{}/notes/note_boost_test", recorder.actor_url),
            "type": "Note",
            "attributedTo": recorder.actor_url,
            "content": "<p>Test note for boost</p>",
            "published": "2026-11-01T12:00:00Z"
        }
    });
    let body = serde_json::to_string(&note_json).unwrap();
    let (signature, date, digest) = support::ap::signed_post(
        &recorder.private_key_pem,
        &recorder.key_id,
        &format!("127.0.0.1:{port}"),
        "/~alice/inbox",
        &body,
    );
    alice_client.post_json_with_headers(
        "/~alice/inbox",
        &body,
        &[
            ("Signature", &signature),
            ("Date", &date),
            ("Digest", &digest),
            ("Content-Type", "application/activity+json"),
        ],
    );

    // Give it a moment to process
    std::thread::sleep(Duration::from_millis(100));

    // Visit the home page - should see Boost button (not yet boosted)
    let response = alice_client.get("/");
    assert_eq!(response.status, 200, "home page should load");
    assert!(
        response.body.contains("Test note for boost"),
        "feed should contain the note. Body:\n{}",
        response.body
    );
    assert!(
        response.body.contains("🔁 Boost") && !response.body.contains("🔁 Unboost"),
        "feed should show Boost button before boosting. Body:\n{}",
        response.body
    );

    Ok(())
}

/// Test: Feed shows Unboost button after boosting a remote note
#[tokio::test]
async fn feed_shows_unboost_button_after_boosting() -> miette::Result<()> {
    let port = free_port();
    let mut server = Server::spawn_federated(&format!("127.0.0.1:{port}"));
    server.wait_for_connection(port);

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

    // Start a remote actor
    let recorder_port = free_port();
    let recorder = RemoteActor::serve(recorder_port);

    // Follow the remote actor
    follow_and_accept(&mut alice_client, port, &recorder, recorder_port);

    // Send a Create(Note) activity from the remote actor
    let note_json = serde_json::json!({
        "@context": "https://www.w3.org/ns/activitystreams",
        "id": format!("{}/activities/note_unboost_test", recorder.actor_url),
        "type": "Create",
        "actor": recorder.actor_url,
        "object": {
            "id": format!("{}/notes/note_unboost_test", recorder.actor_url),
            "type": "Note",
            "attributedTo": recorder.actor_url,
            "content": "<p>Test note for unboost</p>",
            "published": "2026-11-01T12:00:00Z"
        }
    });
    let body = serde_json::to_string(&note_json).unwrap();
    let (signature, date, digest) = support::ap::signed_post(
        &recorder.private_key_pem,
        &recorder.key_id,
        &format!("127.0.0.1:{port}"),
        "/~alice/inbox",
        &body,
    );
    alice_client.post_json_with_headers(
        "/~alice/inbox",
        &body,
        &[
            ("Signature", &signature),
            ("Date", &date),
            ("Digest", &digest),
            ("Content-Type", "application/activity+json"),
        ],
    );

    // Give it a moment to process
    std::thread::sleep(Duration::from_millis(100));

    // Find the remote note ID from the feed
    let feed_response = alice_client.get("/");
    let remote_note_id =
        extract_remote_note_id_from_feed(&feed_response.body, "/notes/note_unboost_test")
            .expect("should find remote note in feed");

    // Boost the remote note
    let boost_response =
        alice_client.post_form(&format!("/remote-notes/{remote_note_id}/announce"), &[]);
    assert_eq!(boost_response.status, 303, "boosting should redirect");

    // Visit the home page again - should see Unboost button (already boosted)
    let response = alice_client.get("/");
    assert_eq!(response.status, 200, "home page should load");
    assert!(
        response.body.contains("Test note for unboost"),
        "feed should contain the note. Body:\n{}",
        response.body
    );
    assert!(
        response.body.contains("🔁 Unboost"),
        "feed should show Unboost button after boosting. Body:\n{}",
        response.body
    );

    Ok(())
}

/// Helper to extract remote note ID from feed HTML
fn extract_remote_note_id_from_feed(html: &str, _object_url_suffix: &str) -> Option<i64> {
    // Look for the react form with the remote note ID
    // Pattern: action="/remote-notes/{id}/react"
    let search_pattern = "action=\"/remote-notes/";
    if let Some(start) = html.find(search_pattern) {
        let after_pattern = &html[start + search_pattern.len()..];
        if let Some(end) = after_pattern.find('/').or_else(|| after_pattern.find('"')) {
            let id_str = &after_pattern[..end];
            return id_str.parse().ok();
        }
    }
    None
}

/// Test: Feed pagination - 21 notes shows 20 on page 0 with next link, 21st on page 1 with prev link
#[tokio::test]
async fn feed_pagination_works() -> miette::Result<()> {
    let port = free_port();
    let mut server = Server::spawn_federated(&format!("127.0.0.1:{port}"));
    server.wait_for_connection(port);

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

    // Start a remote actor
    let recorder_port = free_port();
    let recorder = RemoteActor::serve(recorder_port);

    // Follow the remote actor
    follow_and_accept(&mut alice_client, port, &recorder, recorder_port);

    // Send FEED_PAGE_SIZE+1 notes with sequential timestamps
    for i in 1..=FEED_PAGE_SIZE + 1 {
        let note_json = serde_json::json!({
            "@context": "https://www.w3.org/ns/activitystreams",
            "id": format!("{}/activities/note{}", recorder.actor_url, i),
            "type": "Create",
            "actor": recorder.actor_url,
            "object": {
                "id": format!("{}/notes/note{}", recorder.actor_url, i),
                "type": "Note",
                "attributedTo": recorder.actor_url,
                "content": format!("<p>Note number {}</p>", i),
                "published": format!("2026-11-{:02}T12:00:00Z", i)
            }
        });
        let body = serde_json::to_string(&note_json).unwrap();
        let (signature, date, digest) = support::ap::signed_post(
            &recorder.private_key_pem,
            &recorder.key_id,
            &format!("127.0.0.1:{port}"),
            "/~alice/inbox",
            &body,
        );
        alice_client.post_json_with_headers(
            "/~alice/inbox",
            &body,
            &[
                ("Signature", &signature),
                ("Date", &date),
                ("Digest", &digest),
                ("Content-Type", "application/activity+json"),
            ],
        );
    }

    // Give it a moment to process
    std::thread::sleep(Duration::from_millis(200));

    // Visit page 0 - should show 20 notes and a next link
    let response = alice_client.get("/");
    assert_eq!(response.status, 200, "home page should load");

    // Should show notes 2-21 (newest 20), not note 1
    for i in 2..=21 {
        assert!(
            response.body.contains(&format!("<p>Note number {}</p>", i)),
            "page 0 should contain note {}. Body:\n{}",
            i,
            response.body
        );
    }
    assert!(
        !response.body.contains("<p>Note number 1</p>"),
        "page 0 should NOT contain note 1 (oldest). Body:\n{}",
        response.body
    );

    // Should have a next link
    assert!(
        response.body.contains("Next")
            || response.body.contains("next")
            || response.body.contains("?page=1"),
        "page 0 should have a next link. Body:\n{}",
        response.body
    );

    // Visit page 1 - should show note 1 and a prev link
    let response = alice_client.get("/?page=1");
    assert_eq!(response.status, 200, "page 1 should load");

    // Should show note 1
    assert!(
        response.body.contains("<p>Note number 1</p>"),
        "page 1 should contain note 1. Body:\n{}",
        response.body
    );

    // Should NOT show notes 2-21
    for i in 2..=FEED_PAGE_SIZE + 1 {
        assert!(
            !response.body.contains(&format!("<p>Note number {}</p>", i)),
            "page 1 should NOT contain note {}. Body:\n{}",
            i,
            response.body
        );
    }

    // Should have a prev link
    assert!(
        response.body.contains("Previous")
            || response.body.contains("previous")
            || response.body.contains("?page=0"),
        "page 1 should have a prev link. Body:\n{}",
        response.body
    );

    Ok(())
}