//! 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(¬e_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(¬e1_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(¬e2_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(¬e_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(¬e_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(¬e_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(¬e_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(¬e_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(¬e_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(())
}