test: add 31 meaningful unit tests for business logic

Activity receive() tests (src/tests/activities.rs):
- Accept: updates following_status to Accepted with correct user/actor
- Reject: removes following with correct user/actor
- Undo(Follow): removes follower + calls on_actor_removed
- Undo(Like): calls on_unlike for local objects; ignores remote objects
- Undo(Announce): removes announce record + calls on_announce_removed for local;
                  removes record but skips notification for remote objects
- Create: uses object["id"] not activity id; mention fires on_mention + on_create
- Update: uses object["id"]
- Delete(object): calls on_delete; does NOT call on_actor_removed
- Delete(actor): calls on_actor_removed; does NOT call on_delete
- Announce(local): records announce + calls on_announce_received
- Announce(remote): calls on_announce_of_remote; does NOT record announce
- Like(local): calls on_like
- Like(remote): silently ignored
- Add: uses object["id"] not activity id
- Block: removes both following and follower
- Domain block: activity skipped before any processing
- Actor block: Follow skipped before HTTP dereference (SSRF fix)
- Idempotency: duplicate delivery skipped

Actor serialization tests (src/tests/actors.rs):
- actor_type=Service serializes as "Service"
- discoverable=false serializes
- also_known_as serializes as JSON array (all aliases, not just first)
- optional fields omitted when None
- featured URL serialized when set

Visibility addressing tests (src/tests/broadcast.rs):
- Public: to=[AS_PUBLIC], cc=[followers]
- FollowersOnly: to=[followers], cc=[] — AS_PUBLIC absent
- Private: both empty
This commit is contained in:
2026-05-29 02:44:23 +02:00
parent 48fded426f
commit f00514850b
6 changed files with 1374 additions and 4 deletions

View File

@@ -34,3 +34,11 @@ pub use user::{
#[cfg(test)] #[cfg(test)]
#[path = "tests/integration.rs"] #[path = "tests/integration.rs"]
mod integration_tests; mod integration_tests;
#[cfg(test)]
#[path = "tests/activities.rs"]
mod activity_tests;
#[cfg(test)]
#[path = "tests/broadcast.rs"]
mod broadcast_tests;

View File

@@ -419,7 +419,7 @@ impl ActivityPubService {
/// Returns `(to, cc)` addressing for the given visibility. /// Returns `(to, cc)` addressing for the given visibility.
/// `Private` is handled before calling this (early return in broadcast methods). /// `Private` is handled before calling this (early return in broadcast methods).
pub(super) fn visibility_addressing( pub(crate) fn visibility_addressing(
visibility: ApVisibility, visibility: ApVisibility,
followers_url: &Url, followers_url: &Url,
) -> (Vec<String>, Vec<String>) { ) -> (Vec<String>, Vec<String>) {

View File

@@ -23,7 +23,7 @@ use crate::{
}; };
mod backfill; mod backfill;
mod broadcast; pub(crate) mod broadcast;
pub(super) mod delivery; pub(super) mod delivery;
mod follow; mod follow;

1213
src/tests/activities.rs Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,7 @@
use super::*; use super::*;
// ── Person AP JSON serialization ──────────────────────────────────────────────
#[test] #[test]
fn person_serializes_with_enriched_fields() { fn person_serializes_with_enriched_fields() {
let person = Person { let person = Person {
@@ -13,7 +15,7 @@ fn person_serializes_with_enriched_fields() {
outbox: Some("https://example.com/users/1/outbox".parse().unwrap()), outbox: Some("https://example.com/users/1/outbox".parse().unwrap()),
followers: Some("https://example.com/users/1/followers".parse().unwrap()), followers: Some("https://example.com/users/1/followers".parse().unwrap()),
following: Some("https://example.com/users/1/following".parse().unwrap()), following: Some("https://example.com/users/1/following".parse().unwrap()),
public_key: PublicKey { public_key: activitypub_federation::protocol::public_key::PublicKey {
id: "https://example.com/users/1#main-key".to_string(), id: "https://example.com/users/1#main-key".to_string(),
owner: "https://example.com/users/1".parse().unwrap(), owner: "https://example.com/users/1".parse().unwrap(),
public_key_pem: "pem".to_string(), public_key_pem: "pem".to_string(),
@@ -27,7 +29,7 @@ fn person_serializes_with_enriched_fields() {
url: Some("https://example.com/u/alice".parse().unwrap()), url: Some("https://example.com/u/alice".parse().unwrap()),
discoverable: Some(true), discoverable: Some(true),
manually_approves_followers: true, manually_approves_followers: true,
updated: Some(Utc::now()), updated: Some(chrono::Utc::now()),
endpoints: Some(Endpoints { endpoints: Some(Endpoints {
shared_inbox: "https://example.com/inbox".parse().unwrap(), shared_inbox: "https://example.com/inbox".parse().unwrap(),
}), }),
@@ -47,4 +49,94 @@ fn person_serializes_with_enriched_fields() {
json["endpoints"]["sharedInbox"], json["endpoints"]["sharedInbox"],
"https://example.com/inbox" "https://example.com/inbox"
); );
assert_eq!(json["featured"], "https://example.com/users/1/featured");
}
#[test]
fn person_actor_type_service_serializes_correctly() {
let mut person = minimal_person();
person.kind = crate::user::ApActorType::Service;
let json = serde_json::to_value(&person).unwrap();
assert_eq!(json["type"], "Service");
}
#[test]
fn person_discoverable_false_serializes() {
let mut person = minimal_person();
person.discoverable = Some(false);
let json = serde_json::to_value(&person).unwrap();
assert_eq!(json["discoverable"], false);
}
#[test]
fn person_also_known_as_serializes_as_array() {
let mut person = minimal_person();
person.also_known_as = vec![
"https://old.example/users/alice".to_string(),
"https://other.example/users/alice".to_string(),
];
let json = serde_json::to_value(&person).unwrap();
assert!(
json["alsoKnownAs"].is_array(),
"alsoKnownAs must serialize as a JSON array"
);
assert_eq!(json["alsoKnownAs"].as_array().unwrap().len(), 2);
}
#[test]
fn person_omits_optional_fields_when_none() {
let person = minimal_person();
let json = serde_json::to_value(&person).unwrap();
assert!(
json.get("summary").is_none(),
"null summary should be omitted"
);
assert!(json.get("icon").is_none(), "null icon should be omitted");
assert!(
json.get("featured").is_none(),
"null featured should be omitted"
);
assert!(json.get("url").is_none(), "null url should be omitted");
}
#[test]
fn person_featured_omitted_when_none() {
let mut person = minimal_person();
person.featured = None;
let json = serde_json::to_value(&person).unwrap();
assert!(json.get("featured").is_none());
}
// ── helper ────────────────────────────────────────────────────────────────────
fn minimal_person() -> Person {
Person {
kind: Default::default(),
id: "https://example.com/users/1"
.parse::<url::Url>()
.unwrap()
.into(),
preferred_username: "alice".to_string(),
inbox: "https://example.com/users/1/inbox".parse().unwrap(),
outbox: None,
followers: None,
following: None,
public_key: activitypub_federation::protocol::public_key::PublicKey {
id: "https://example.com/users/1#main-key".to_string(),
owner: "https://example.com/users/1".parse().unwrap(),
public_key_pem: "pem".to_string(),
},
name: None,
summary: None,
icon: None,
url: None,
discoverable: None,
manually_approves_followers: false,
updated: None,
endpoints: None,
image: None,
also_known_as: vec![],
attachment: vec![],
featured: None,
}
} }

57
src/tests/broadcast.rs Normal file
View File

@@ -0,0 +1,57 @@
/// Tests for broadcast addressing logic (visibility → to/cc fields).
use url::Url;
use crate::service::broadcast::visibility_addressing;
use crate::urls::AS_PUBLIC;
use crate::user::ApVisibility;
fn followers_url() -> Url {
"https://example.com/users/alice/followers".parse().unwrap()
}
#[test]
fn public_visibility_addresses_public_and_followers() {
let (to, cc) = visibility_addressing(ApVisibility::Public, &followers_url());
assert_eq!(to, vec![AS_PUBLIC.to_string()]);
assert_eq!(cc, vec![followers_url().to_string()]);
}
#[test]
fn followers_only_visibility_addresses_followers_only() {
let (to, cc) = visibility_addressing(ApVisibility::FollowersOnly, &followers_url());
assert_eq!(to, vec![followers_url().to_string()]);
assert!(
cc.is_empty(),
"FollowersOnly must not include AS_PUBLIC in cc"
);
}
#[test]
fn followers_only_excludes_as_public() {
let (to, cc) = visibility_addressing(ApVisibility::FollowersOnly, &followers_url());
assert!(
!to.contains(&AS_PUBLIC.to_string()),
"FollowersOnly must not include AS_PUBLIC in to"
);
assert!(
!cc.contains(&AS_PUBLIC.to_string()),
"FollowersOnly must not include AS_PUBLIC in cc"
);
}
#[test]
fn private_visibility_produces_empty_addressing() {
let (to, cc) = visibility_addressing(ApVisibility::Private, &followers_url());
assert!(to.is_empty());
assert!(cc.is_empty());
}
#[test]
fn public_and_followers_only_differ_in_to() {
let (pub_to, _) = visibility_addressing(ApVisibility::Public, &followers_url());
let (fo_to, _) = visibility_addressing(ApVisibility::FollowersOnly, &followers_url());
assert_ne!(
pub_to, fo_to,
"Public and FollowersOnly must produce different to fields"
);
}