refactor: extract inline test modules to separate files
Some checks failed
lint / lint (push) Has been cancelled
test / unit (push) Has been cancelled
test / integration (push) Has been cancelled

This commit is contained in:
2026-05-16 12:08:38 +02:00
parent 6c685d19e8
commit a0aa3f381e
77 changed files with 4081 additions and 4124 deletions

View File

@@ -1,139 +0,0 @@
use std::collections::HashSet;
/// A hashtag extracted from content.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct Hashtag {
/// Original casing, e.g. "Rust"
pub raw: String,
/// Lowercased, e.g. "rust" — used for DB lookups
pub normalized: String,
/// "tags/rust" — callers prepend base_url
pub url_slug: String,
/// "#rust" — used directly in AP tag array
pub ap_name: String,
}
/// Extract hashtags from content using a char-by-char scan.
///
/// Rules:
/// - Tag starts after a bare `#` followed immediately by an alphanumeric char.
/// - Tag chars: `[A-Za-z0-9_]`.
/// - Deduplicated case-insensitively; first occurrence wins.
/// - Returned in order of first appearance.
pub fn extract(content: &str) -> Vec<Hashtag> {
let mut seen: HashSet<String> = HashSet::new();
let mut tags: Vec<Hashtag> = Vec::new();
let mut chars = content.char_indices().peekable();
while let Some((_, c)) = chars.next() {
if c == '#'
&& chars
.peek()
.map(|(_, nc)| nc.is_alphanumeric())
.unwrap_or(false)
{
let raw: String = chars
.by_ref()
.take_while(|(_, nc)| nc.is_alphanumeric() || *nc == '_')
.map(|(_, nc)| nc)
.collect();
if raw.is_empty() {
continue;
}
let normalized = raw.to_lowercase();
if seen.insert(normalized.clone()) {
tags.push(Hashtag {
url_slug: format!("tags/{}", normalized),
ap_name: format!("#{}", normalized),
raw,
normalized,
});
}
}
}
tags
}
#[cfg(test)]
mod tests {
use super::*;
fn names(tags: &[Hashtag]) -> Vec<&str> {
tags.iter().map(|h| h.normalized.as_str()).collect()
}
#[test]
fn basic() {
let tags = extract("Hello #world and #Rust!");
assert_eq!(names(&tags), ["world", "rust"]);
}
#[test]
fn fields() {
let tags = extract("#Rust");
assert_eq!(tags.len(), 1);
let h = &tags[0];
assert_eq!(h.raw, "Rust");
assert_eq!(h.normalized, "rust");
assert_eq!(h.url_slug, "tags/rust");
assert_eq!(h.ap_name, "#rust");
}
#[test]
fn dedup_case_insensitive() {
let tags = extract("#rust #Rust #RUST");
assert_eq!(names(&tags), ["rust"]);
assert_eq!(tags[0].raw, "rust"); // first occurrence wins
}
#[test]
fn deduplicates_non_adjacent() {
// The old algorithm used Vec::dedup() which only removes adjacent duplicates.
// Using HashSet silently fixed this bug. This test documents the fix.
let tags = extract("#a #b #a");
assert_eq!(tags.len(), 2);
assert_eq!(tags[0].normalized, "a");
assert_eq!(tags[1].normalized, "b");
}
#[test]
fn mid_word_extracted() {
// `text#tag` — `#` not preceded by whitespace is still matched by the
// char-by-char scan (the old algorithm didn't require whitespace before `#`).
// This test documents the authoritative behaviour: mid-word tags ARE extracted.
let tags = extract("text#tag");
assert_eq!(names(&tags), ["tag"]);
}
#[test]
fn hash_only_ignored() {
assert!(extract("# lone hash").is_empty());
}
#[test]
fn trailing_punctuation_excluded() {
// punctuation after tag terminates the tag, not included
let tags = extract("#rust.");
assert_eq!(names(&tags), ["rust"]);
}
#[test]
fn underscore_allowed() {
let tags = extract("#hello_world");
assert_eq!(names(&tags), ["hello_world"]);
}
#[test]
fn empty_content() {
assert!(extract("").is_empty());
}
#[test]
fn order_of_appearance() {
let tags = extract("#b #a #c");
assert_eq!(names(&tags), ["b", "a", "c"]);
}
}

View File

@@ -0,0 +1,61 @@
use std::collections::HashSet;
/// A hashtag extracted from content.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct Hashtag {
/// Original casing, e.g. "Rust"
pub raw: String,
/// Lowercased, e.g. "rust" — used for DB lookups
pub normalized: String,
/// "tags/rust" — callers prepend base_url
pub url_slug: String,
/// "#rust" — used directly in AP tag array
pub ap_name: String,
}
/// Extract hashtags from content using a char-by-char scan.
///
/// Rules:
/// - Tag starts after a bare `#` followed immediately by an alphanumeric char.
/// - Tag chars: `[A-Za-z0-9_]`.
/// - Deduplicated case-insensitively; first occurrence wins.
/// - Returned in order of first appearance.
pub fn extract(content: &str) -> Vec<Hashtag> {
let mut seen: HashSet<String> = HashSet::new();
let mut tags: Vec<Hashtag> = Vec::new();
let mut chars = content.char_indices().peekable();
while let Some((_, c)) = chars.next() {
if c == '#'
&& chars
.peek()
.map(|(_, nc)| nc.is_alphanumeric())
.unwrap_or(false)
{
let raw: String = chars
.by_ref()
.take_while(|(_, nc)| nc.is_alphanumeric() || *nc == '_')
.map(|(_, nc)| nc)
.collect();
if raw.is_empty() {
continue;
}
let normalized = raw.to_lowercase();
if seen.insert(normalized.clone()) {
tags.push(Hashtag {
url_slug: format!("tags/{}", normalized),
ap_name: format!("#{}", normalized),
raw,
normalized,
});
}
}
}
tags
}
#[cfg(test)]
mod tests;

View File

@@ -0,0 +1,71 @@
use super::*;
fn names(tags: &[Hashtag]) -> Vec<&str> {
tags.iter().map(|h| h.normalized.as_str()).collect()
}
#[test]
fn basic() {
let tags = extract("Hello #world and #Rust!");
assert_eq!(names(&tags), ["world", "rust"]);
}
#[test]
fn fields() {
let tags = extract("#Rust");
assert_eq!(tags.len(), 1);
let h = &tags[0];
assert_eq!(h.raw, "Rust");
assert_eq!(h.normalized, "rust");
assert_eq!(h.url_slug, "tags/rust");
assert_eq!(h.ap_name, "#rust");
}
#[test]
fn dedup_case_insensitive() {
let tags = extract("#rust #Rust #RUST");
assert_eq!(names(&tags), ["rust"]);
assert_eq!(tags[0].raw, "rust"); // first occurrence wins
}
#[test]
fn deduplicates_non_adjacent() {
let tags = extract("#a #b #a");
assert_eq!(tags.len(), 2);
assert_eq!(tags[0].normalized, "a");
assert_eq!(tags[1].normalized, "b");
}
#[test]
fn mid_word_extracted() {
let tags = extract("text#tag");
assert_eq!(names(&tags), ["tag"]);
}
#[test]
fn hash_only_ignored() {
assert!(extract("# lone hash").is_empty());
}
#[test]
fn trailing_punctuation_excluded() {
let tags = extract("#rust.");
assert_eq!(names(&tags), ["rust"]);
}
#[test]
fn underscore_allowed() {
let tags = extract("#hello_world");
assert_eq!(names(&tags), ["hello_world"]);
}
#[test]
fn empty_content() {
assert!(extract("").is_empty());
}
#[test]
fn order_of_appearance() {
let tags = extract("#b #a #c");
assert_eq!(names(&tags), ["b", "a", "c"]);
}

View File

@@ -802,7 +802,6 @@ impl SearchPort for TestStore {
}
}
#[async_trait]
impl FederationSchedulerPort for TestStore {
async fn schedule_actor_posts_fetch(&self, _: &str, _: &str) -> Result<(), DomainError> {
@@ -863,102 +862,4 @@ impl OutboxWriter for NoOpOutboxWriter {
}
#[cfg(test)]
mod federation_port_tests {
use super::*;
use crate::value_objects::UserId;
fn uid() -> UserId {
UserId::new()
}
#[tokio::test]
async fn test_store_lookup_returns_not_found() {
let store = TestStore::default();
let err = store.lookup_actor("@alice@example.com").await.unwrap_err();
assert!(matches!(err, DomainError::NotFound));
}
#[tokio::test]
async fn test_store_follow_remote_is_noop_ok() {
let store = TestStore::default();
store
.follow_remote(&uid(), "@alice@example.com")
.await
.unwrap();
}
#[tokio::test]
async fn test_store_actor_json_returns_not_found() {
let store = TestStore::default();
let err = store.actor_json(&UserId::new()).await.unwrap_err();
assert!(matches!(err, DomainError::NotFound));
}
#[tokio::test]
async fn test_store_fetch_outbox_returns_empty() {
let store = TestStore::default();
let notes = store
.fetch_outbox_page("https://example.com/outbox", 1)
.await
.unwrap();
assert!(notes.is_empty());
}
#[tokio::test]
async fn test_store_resolve_actor_profiles_returns_empty() {
let store = TestStore::default();
let result = store
.resolve_actor_profiles(vec!["https://example.com/users/alice".into()])
.await;
assert!(result.is_empty());
}
#[tokio::test]
async fn test_store_fetch_collection_urls_returns_empty() {
let store = TestStore::default();
let urls = store
.fetch_actor_urls_from_collection("https://example.com/users/alice/followers")
.await
.unwrap();
assert!(urls.is_empty());
}
}
#[cfg(test)]
mod search_tests {
use super::*;
use crate::models::feed::PageParams;
#[tokio::test]
async fn test_store_search_thoughts_returns_empty() {
let store = TestStore::default();
let result = store
.search_thoughts(
"hello",
&PageParams {
page: 1,
per_page: 20,
},
None,
)
.await
.unwrap();
assert_eq!(result.total, 0);
}
#[tokio::test]
async fn test_store_search_users_returns_empty() {
let store = TestStore::default();
let result = store
.search_users(
"alice",
&PageParams {
page: 1,
per_page: 20,
},
)
.await
.unwrap();
assert_eq!(result.total, 0);
}
}
mod tests;

View File

@@ -0,0 +1,98 @@
mod federation_port_tests {
use super::super::*;
use crate::value_objects::UserId;
fn uid() -> UserId {
UserId::new()
}
#[tokio::test]
async fn test_store_lookup_returns_not_found() {
let store = TestStore::default();
let err = store.lookup_actor("@alice@example.com").await.unwrap_err();
assert!(matches!(err, DomainError::NotFound));
}
#[tokio::test]
async fn test_store_follow_remote_is_noop_ok() {
let store = TestStore::default();
store
.follow_remote(&uid(), "@alice@example.com")
.await
.unwrap();
}
#[tokio::test]
async fn test_store_actor_json_returns_not_found() {
let store = TestStore::default();
let err = store.actor_json(&UserId::new()).await.unwrap_err();
assert!(matches!(err, DomainError::NotFound));
}
#[tokio::test]
async fn test_store_fetch_outbox_returns_empty() {
let store = TestStore::default();
let notes = store
.fetch_outbox_page("https://example.com/outbox", 1)
.await
.unwrap();
assert!(notes.is_empty());
}
#[tokio::test]
async fn test_store_resolve_actor_profiles_returns_empty() {
let store = TestStore::default();
let result = store
.resolve_actor_profiles(vec!["https://example.com/users/alice".into()])
.await;
assert!(result.is_empty());
}
#[tokio::test]
async fn test_store_fetch_collection_urls_returns_empty() {
let store = TestStore::default();
let urls = store
.fetch_actor_urls_from_collection("https://example.com/users/alice/followers")
.await
.unwrap();
assert!(urls.is_empty());
}
}
mod search_tests {
use super::super::*;
use crate::models::feed::PageParams;
#[tokio::test]
async fn test_store_search_thoughts_returns_empty() {
let store = TestStore::default();
let result = store
.search_thoughts(
"hello",
&PageParams {
page: 1,
per_page: 20,
},
None,
)
.await
.unwrap();
assert_eq!(result.total, 0);
}
#[tokio::test]
async fn test_store_search_users_returns_empty() {
let store = TestStore::default();
let result = store
.search_users(
"alice",
&PageParams {
page: 1,
per_page: 20,
},
)
.await
.unwrap();
assert_eq!(result.total, 0);
}
}

View File

@@ -116,35 +116,4 @@ impl std::fmt::Display for Content {
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn username_rejects_empty() {
assert!(Username::new("").is_err());
}
#[test]
fn username_rejects_too_long() {
assert!(Username::new("a".repeat(33)).is_err());
}
#[test]
fn username_rejects_invalid_chars() {
assert!(Username::new("hello world").is_err());
}
#[test]
fn username_accepts_valid() {
assert!(Username::new("hello_123").is_ok());
}
#[test]
fn content_local_rejects_over_128() {
assert!(Content::new_local("a".repeat(129)).is_err());
}
#[test]
fn content_local_accepts_128() {
assert!(Content::new_local("a".repeat(128)).is_ok());
}
#[test]
fn email_rejects_no_at() {
assert!(Email::new("notanemail").is_err());
}
}
mod tests;

View File

@@ -0,0 +1,30 @@
use super::*;
#[test]
fn username_rejects_empty() {
assert!(Username::new("").is_err());
}
#[test]
fn username_rejects_too_long() {
assert!(Username::new("a".repeat(33)).is_err());
}
#[test]
fn username_rejects_invalid_chars() {
assert!(Username::new("hello world").is_err());
}
#[test]
fn username_accepts_valid() {
assert!(Username::new("hello_123").is_ok());
}
#[test]
fn content_local_rejects_over_128() {
assert!(Content::new_local("a".repeat(129)).is_err());
}
#[test]
fn content_local_accepts_128() {
assert!(Content::new_local("a".repeat(128)).is_ok());
}
#[test]
fn email_rejects_no_at() {
assert!(Email::new("notanemail").is_err());
}