use std::sync::{Arc, Mutex};
use uuid::Uuid;
use super::{ApiError, ApiResult};
use super::sample_data::{create_test_users, create_sample_posts, create_sample_conversations};
use fido_types::*;
use fido_types::enums::{ColorScheme, SortOrder};
#[derive(Clone)]
pub struct MockBackend {
data: Arc<Mutex<MockData>>,
current_user: Option<User>,
session_token: Option<String>,
}
struct MockData {
users: Vec<User>,
posts: Vec<Post>,
messages: Vec<DirectMessage>,
votes: Vec<(Uuid, Uuid, String)>, followed_hashtags: Vec<(Uuid, String)>, configs: Vec<(Uuid, UserConfig)>,
}
enum VoteAction {
Remove(usize, String), Change(usize, String, String), Add(String), }
impl MockBackend {
pub fn new() -> Self {
Self {
data: Arc::new(Mutex::new(MockData::with_sample_data())),
current_user: None,
session_token: None,
}
}
pub async fn get_test_users(&self) -> ApiResult<Vec<User>> {
let data = self.data.lock().unwrap();
Ok(data.users.clone())
}
pub async fn login(&mut self, username: String) -> ApiResult<LoginResponse> {
let data = self.data.lock().unwrap();
let user = data.users.iter()
.find(|u| u.username == username)
.cloned()
.ok_or_else(|| ApiError::NotFound(format!("User '{}' not found", username)))?;
let token = format!("demo-token-{}", Uuid::new_v4());
drop(data); self.current_user = Some(user.clone());
self.session_token = Some(token.clone());
Ok(LoginResponse {
user,
session_token: token,
})
}
fn require_auth(&self) -> ApiResult<&User> {
self.current_user.as_ref()
.ok_or_else(|| ApiError::Unauthorized("Please log in to continue".to_string()))
}
pub async fn get_posts(
&self,
limit: Option<usize>,
sort: Option<String>,
hashtag: Option<String>,
username: Option<String>,
) -> ApiResult<Vec<Post>> {
let data = self.data.lock().unwrap();
let mut posts: Vec<Post> = data.posts.iter()
.filter(|p| p.parent_post_id.is_none())
.cloned()
.collect();
if let Some(ref tag) = hashtag {
let tag_lower = tag.to_lowercase();
posts.retain(|p| {
p.hashtags.iter().any(|h| h.to_lowercase() == tag_lower)
});
}
if let Some(ref user) = username {
posts.retain(|p| p.author_username == *user);
}
if let Some(ref current_user) = self.current_user {
for post in &mut posts {
if let Some((_, _, direction)) = data.votes.iter()
.find(|(uid, pid, _)| uid == ¤t_user.id && pid == &post.id)
{
post.user_vote = Some(direction.clone());
}
}
}
let sort_order = sort.as_deref().unwrap_or("newest");
match sort_order {
"newest" => {
posts.sort_by(|a, b| b.created_at.cmp(&a.created_at));
}
"oldest" => {
posts.sort_by(|a, b| a.created_at.cmp(&b.created_at));
}
"top" => {
posts.sort_by(|a, b| {
let score_a = a.upvotes - a.downvotes;
let score_b = b.upvotes - b.downvotes;
score_b.cmp(&score_a)
});
}
_ => {
posts.sort_by(|a, b| b.created_at.cmp(&a.created_at));
}
}
if let Some(lim) = limit {
posts.truncate(lim);
}
Ok(posts)
}
pub async fn create_post(&mut self, content: String) -> ApiResult<Post> {
let current_user = self.require_auth()?.clone();
if content.trim().is_empty() {
return Err(ApiError::BadRequest("Post content cannot be empty".to_string()));
}
let hashtags = extract_hashtags(&content);
let post = Post {
id: Uuid::new_v4(),
author_id: current_user.id,
author_username: current_user.username.clone(),
content,
created_at: chrono::Utc::now(),
upvotes: 0,
downvotes: 0,
hashtags,
user_vote: None,
parent_post_id: None,
reply_count: 0,
reply_to_user_id: None,
reply_to_username: None,
};
let mut data = self.data.lock().unwrap();
data.posts.push(post.clone());
Ok(post)
}
pub async fn vote_on_post(&mut self, post_id: Uuid, direction: String) -> ApiResult<()> {
let current_user = self.require_auth()?.clone();
if direction != "up" && direction != "down" {
return Err(ApiError::BadRequest(
"Vote direction must be 'up' or 'down'".to_string()
));
}
let mut data = self.data.lock().unwrap();
if !data.posts.iter().any(|p| p.id == post_id) {
return Err(ApiError::NotFound("Post not found".to_string()));
}
let vote_action = if let Some(existing_vote_idx) = data.votes.iter()
.position(|(uid, pid, _)| uid == ¤t_user.id && pid == &post_id)
{
let (_, _, existing_direction) = &data.votes[existing_vote_idx];
if existing_direction == &direction {
VoteAction::Remove(existing_vote_idx, direction.clone())
} else {
VoteAction::Change(existing_vote_idx, existing_direction.clone(), direction.clone())
}
} else {
VoteAction::Add(direction.clone())
};
match vote_action {
VoteAction::Remove(idx, dir) => {
data.votes.remove(idx);
if let Some(post) = data.posts.iter_mut().find(|p| p.id == post_id) {
if dir == "up" {
post.upvotes -= 1;
} else {
post.downvotes -= 1;
}
}
}
VoteAction::Change(idx, _old_dir, new_dir) => {
data.votes[idx].2 = new_dir.clone();
if let Some(post) = data.posts.iter_mut().find(|p| p.id == post_id) {
if new_dir == "up" {
post.downvotes -= 1;
post.upvotes += 1;
} else {
post.upvotes -= 1;
post.downvotes += 1;
}
}
}
VoteAction::Add(dir) => {
data.votes.push((current_user.id, post_id, dir.clone()));
if let Some(post) = data.posts.iter_mut().find(|p| p.id == post_id) {
if dir == "up" {
post.upvotes += 1;
} else {
post.downvotes += 1;
}
}
}
}
Ok(())
}
pub async fn get_replies(&self, post_id: Uuid) -> ApiResult<Vec<Post>> {
let data = self.data.lock().unwrap();
let mut replies: Vec<Post> = data.posts.iter()
.filter(|p| p.parent_post_id == Some(post_id))
.cloned()
.collect();
if let Some(ref current_user) = self.current_user {
for reply in &mut replies {
if let Some((_, _, direction)) = data.votes.iter()
.find(|(uid, pid, _)| uid == ¤t_user.id && pid == &reply.id)
{
reply.user_vote = Some(direction.clone());
}
}
}
replies.sort_by(|a, b| a.created_at.cmp(&b.created_at));
Ok(replies)
}
pub async fn create_reply(&mut self, parent_post_id: Uuid, content: String) -> ApiResult<Post> {
let current_user = self.require_auth()?.clone();
if content.trim().is_empty() {
return Err(ApiError::BadRequest("Reply content cannot be empty".to_string()));
}
let mut data = self.data.lock().unwrap();
let parent_post = data.posts.iter()
.find(|p| p.id == parent_post_id)
.ok_or_else(|| ApiError::NotFound("Parent post not found".to_string()))?;
let reply_to_user_id = parent_post.author_id;
let reply_to_username = parent_post.author_username.clone();
let hashtags = extract_hashtags(&content);
let reply = Post {
id: Uuid::new_v4(),
author_id: current_user.id,
author_username: current_user.username.clone(),
content,
created_at: chrono::Utc::now(),
upvotes: 0,
downvotes: 0,
hashtags,
user_vote: None,
parent_post_id: Some(parent_post_id),
reply_count: 0,
reply_to_user_id: Some(reply_to_user_id),
reply_to_username: Some(reply_to_username),
};
data.posts.push(reply.clone());
if let Some(parent) = data.posts.iter_mut().find(|p| p.id == parent_post_id) {
parent.reply_count += 1;
}
Ok(reply)
}
pub async fn get_conversations(&self) -> ApiResult<Vec<serde_json::Value>> {
let current_user = self.require_auth()?;
let current_user_id = current_user.id;
let data = self.data.lock().unwrap();
use std::collections::HashMap;
let mut conversations: HashMap<Uuid, Vec<&DirectMessage>> = HashMap::new();
for message in &data.messages {
let other_user_id = if message.from_user_id == current_user_id {
message.to_user_id
} else if message.to_user_id == current_user_id {
message.from_user_id
} else {
continue;
};
conversations.entry(other_user_id)
.or_insert_with(Vec::new)
.push(message);
}
let mut result = Vec::new();
for (other_user_id, messages) in conversations {
let other_user = data.users.iter()
.find(|u| u.id == other_user_id)
.ok_or_else(|| ApiError::NotFound("User not found".to_string()))?;
let last_message = messages.iter()
.max_by_key(|m| m.created_at)
.ok_or_else(|| ApiError::NotFound("No messages found".to_string()))?;
let unread_count = messages.iter()
.filter(|m| m.to_user_id == current_user_id && !m.is_read)
.count();
let conversation = serde_json::json!({
"other_user_id": other_user_id,
"other_username": other_user.username,
"last_message": last_message.content,
"last_message_time": last_message.created_at,
"unread_count": unread_count,
});
result.push(conversation);
}
result.sort_by(|a, b| {
let time_a = a.get("last_message_time")
.and_then(|v| v.as_str())
.and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
.map(|dt| dt.with_timezone(&chrono::Utc));
let time_b = b.get("last_message_time")
.and_then(|v| v.as_str())
.and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
.map(|dt| dt.with_timezone(&chrono::Utc));
time_b.cmp(&time_a) });
Ok(result)
}
pub async fn get_conversation(&self, user_id: Uuid) -> ApiResult<Vec<DirectMessage>> {
let current_user = self.require_auth()?;
let current_user_id = current_user.id;
let data = self.data.lock().unwrap();
if !data.users.iter().any(|u| u.id == user_id) {
return Err(ApiError::NotFound("User not found".to_string()));
}
let mut messages: Vec<DirectMessage> = data.messages.iter()
.filter(|m| {
(m.from_user_id == current_user_id && m.to_user_id == user_id) ||
(m.from_user_id == user_id && m.to_user_id == current_user_id)
})
.cloned()
.collect();
messages.sort_by(|a, b| a.created_at.cmp(&b.created_at));
Ok(messages)
}
pub async fn send_message(&mut self, to_username: String, content: String) -> ApiResult<DirectMessage> {
let current_user = self.require_auth()?.clone();
if content.trim().is_empty() {
return Err(ApiError::BadRequest("Message content cannot be empty".to_string()));
}
let mut data = self.data.lock().unwrap();
let to_user = data.users.iter()
.find(|u| u.username == to_username)
.ok_or_else(|| ApiError::NotFound(format!("User '{}' not found", to_username)))?;
let to_user_id = to_user.id;
let to_user_username = to_user.username.clone();
let message = DirectMessage {
id: Uuid::new_v4(),
from_user_id: current_user.id,
from_username: current_user.username.clone(),
to_user_id: to_user_id,
to_username: to_user_username,
content,
created_at: chrono::Utc::now(),
is_read: false,
};
data.messages.push(message.clone());
Ok(message)
}
pub async fn get_profile(&self, user_id: Uuid) -> ApiResult<UserProfile> {
let data = self.data.lock().unwrap();
let user = data.users.iter()
.find(|u| u.id == user_id)
.ok_or_else(|| ApiError::NotFound("User not found".to_string()))?;
let karma: i32 = data.posts.iter()
.filter(|p| p.author_id == user_id)
.map(|p| p.upvotes - p.downvotes)
.sum();
let post_count = data.posts.iter()
.filter(|p| p.author_id == user_id && p.parent_post_id.is_none())
.count() as i32;
let mut recent_hashtags: Vec<String> = Vec::new();
let mut user_posts: Vec<&Post> = data.posts.iter()
.filter(|p| p.author_id == user_id)
.collect();
user_posts.sort_by(|a, b| b.created_at.cmp(&a.created_at));
for post in user_posts {
for hashtag in &post.hashtags {
if !recent_hashtags.contains(hashtag) {
recent_hashtags.push(hashtag.clone());
if recent_hashtags.len() >= 10 {
break;
}
}
}
if recent_hashtags.len() >= 10 {
break;
}
}
Ok(UserProfile {
user_id: user.id,
username: user.username.clone(),
bio: user.bio.clone(),
karma,
post_count,
join_date: user.join_date,
recent_hashtags,
})
}
pub async fn update_bio(&mut self, bio: String) -> ApiResult<()> {
let current_user = self.require_auth()?.clone();
let mut data = self.data.lock().unwrap();
if let Some(user) = data.users.iter_mut().find(|u| u.id == current_user.id) {
user.bio = Some(bio.clone());
drop(data);
if let Some(ref mut cu) = self.current_user {
cu.bio = Some(bio);
}
Ok(())
} else {
Err(ApiError::NotFound("User not found".to_string()))
}
}
pub async fn get_config(&self) -> ApiResult<UserConfig> {
let current_user = self.require_auth()?;
let user_id = current_user.id;
let data = self.data.lock().unwrap();
if let Some((_, config)) = data.configs.iter().find(|(uid, _)| uid == &user_id) {
Ok(config.clone())
} else {
Ok(UserConfig {
user_id,
..UserConfig::default()
})
}
}
pub async fn update_config(
&mut self,
color_scheme: Option<String>,
sort_order: Option<String>,
max_posts_display: Option<i32>,
emoji_enabled: Option<bool>,
) -> ApiResult<UserConfig> {
let current_user = self.require_auth()?;
let user_id = current_user.id;
let mut data = self.data.lock().unwrap();
let mut config = data.configs.iter()
.find(|(uid, _)| uid == &user_id)
.map(|(_, c)| c.clone())
.unwrap_or_else(|| UserConfig {
user_id,
..UserConfig::default()
});
if let Some(cs) = color_scheme {
config.color_scheme = ColorScheme::parse(&cs)
.ok_or_else(|| ApiError::BadRequest(format!("Invalid color scheme: {}", cs)))?;
}
if let Some(so) = sort_order {
config.sort_order = SortOrder::parse(&so)
.ok_or_else(|| ApiError::BadRequest(format!("Invalid sort order: {}", so)))?;
}
if let Some(mpd) = max_posts_display {
if mpd < 1 || mpd > 100 {
return Err(ApiError::BadRequest(
"max_posts_display must be between 1 and 100".to_string()
));
}
config.max_posts_display = mpd;
}
if let Some(ee) = emoji_enabled {
config.emoji_enabled = ee;
}
if let Some(pos) = data.configs.iter().position(|(uid, _)| uid == &user_id) {
data.configs[pos] = (user_id, config.clone());
} else {
data.configs.push((user_id, config.clone()));
}
Ok(config)
}
pub async fn get_followed_hashtags(&self) -> ApiResult<Vec<String>> {
let current_user = self.require_auth()?;
let user_id = current_user.id;
let data = self.data.lock().unwrap();
let hashtags: Vec<String> = data.followed_hashtags.iter()
.filter(|(uid, _)| uid == &user_id)
.map(|(_, tag)| tag.clone())
.collect();
Ok(hashtags)
}
pub async fn follow_hashtag(&mut self, hashtag: String) -> ApiResult<()> {
let current_user = self.require_auth()?;
let user_id = current_user.id;
let normalized_tag = hashtag.trim_start_matches('#').to_lowercase();
if normalized_tag.is_empty() {
return Err(ApiError::BadRequest("Hashtag cannot be empty".to_string()));
}
let mut data = self.data.lock().unwrap();
if data.followed_hashtags.iter().any(|(uid, tag)| uid == &user_id && tag == &normalized_tag) {
return Err(ApiError::BadRequest("Already following this hashtag".to_string()));
}
data.followed_hashtags.push((user_id, normalized_tag));
Ok(())
}
pub async fn unfollow_hashtag(&mut self, hashtag: String) -> ApiResult<()> {
let current_user = self.require_auth()?;
let user_id = current_user.id;
let normalized_tag = hashtag.trim_start_matches('#').to_lowercase();
let mut data = self.data.lock().unwrap();
if let Some(pos) = data.followed_hashtags.iter()
.position(|(uid, tag)| uid == &user_id && tag == &normalized_tag)
{
data.followed_hashtags.remove(pos);
Ok(())
} else {
Err(ApiError::NotFound("Not following this hashtag".to_string()))
}
}
}
impl MockData {
fn with_sample_data() -> Self {
let users = create_test_users();
let posts = create_sample_posts(&users);
let messages = create_sample_conversations(&users);
Self {
users,
posts,
messages,
votes: Vec::new(),
followed_hashtags: Vec::new(),
configs: Vec::new(),
}
}
}
fn extract_hashtags(content: &str) -> Vec<String> {
content
.split_whitespace()
.filter_map(|word| {
if word.starts_with('#') {
let tag = word[1..].trim_end_matches(|c: char| !c.is_alphanumeric());
if !tag.is_empty() {
Some(tag.to_lowercase())
} else {
None
}
} else {
None
}
})
.collect()
}