Compare commits
2 Commits
b83b7acf1c
...
728bf0e231
Author | SHA1 | Date | |
---|---|---|---|
728bf0e231 | |||
508f218fc0 |
2
thoughts-backend/Cargo.lock
generated
2
thoughts-backend/Cargo.lock
generated
@@ -357,7 +357,9 @@ name = "app"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"bcrypt",
|
||||
"chrono",
|
||||
"models",
|
||||
"rand 0.8.5",
|
||||
"sea-orm",
|
||||
"validator",
|
||||
]
|
||||
|
@@ -8,7 +8,7 @@ use once_cell::sync::Lazy;
|
||||
use sea_orm::prelude::Uuid;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use app::state::AppState;
|
||||
use app::{persistence::api_key, state::AppState};
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct Claims {
|
||||
@@ -28,14 +28,24 @@ impl FromRequestParts<AppState> for AuthUser {
|
||||
|
||||
async fn from_request_parts(
|
||||
parts: &mut Parts,
|
||||
_state: &AppState,
|
||||
state: &AppState,
|
||||
) -> Result<Self, Self::Rejection> {
|
||||
// --- Test User ID (Keep for testing) ---
|
||||
if let Some(user_id_header) = parts.headers.get("x-test-user-id") {
|
||||
let user_id_str = user_id_header.to_str().unwrap_or("0");
|
||||
let user_id = user_id_str.parse::<Uuid>().unwrap_or(Uuid::nil());
|
||||
return Ok(AuthUser { id: user_id });
|
||||
}
|
||||
|
||||
// --- API Key Authentication ---
|
||||
if let Some(api_key) = get_api_key_from_header(&parts.headers) {
|
||||
return match api_key::validate_api_key(&state.conn, &api_key).await {
|
||||
Ok(user) => Ok(AuthUser { id: user.id }),
|
||||
Err(_) => Err((StatusCode::UNAUTHORIZED, "Invalid API Key")),
|
||||
};
|
||||
}
|
||||
|
||||
// --- JWT Authentication (Fallback) ---
|
||||
let token = get_token_from_header(&parts.headers)
|
||||
.ok_or((StatusCode::UNAUTHORIZED, "Missing or invalid token"))?;
|
||||
|
||||
@@ -56,3 +66,11 @@ fn get_token_from_header(headers: &HeaderMap) -> Option<String> {
|
||||
.and_then(|header| header.strip_prefix("Bearer "))
|
||||
.map(|token| token.to_owned())
|
||||
}
|
||||
|
||||
fn get_api_key_from_header(headers: &HeaderMap) -> Option<String> {
|
||||
headers
|
||||
.get("Authorization")
|
||||
.and_then(|header| header.to_str().ok())
|
||||
.and_then(|header| header.strip_prefix("ApiKey "))
|
||||
.map(|key| key.to_owned())
|
||||
}
|
||||
|
93
thoughts-backend/api/src/routers/api_key.rs
Normal file
93
thoughts-backend/api/src/routers/api_key.rs
Normal file
@@ -0,0 +1,93 @@
|
||||
use crate::{
|
||||
error::ApiError,
|
||||
extractor::{AuthUser, Json},
|
||||
models::ApiErrorResponse,
|
||||
};
|
||||
use app::{persistence::api_key, state::AppState};
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
response::IntoResponse,
|
||||
routing::{delete, get},
|
||||
Router,
|
||||
};
|
||||
use models::schemas::api_key::{ApiKeyListSchema, ApiKeyRequest, ApiKeyResponse};
|
||||
use sea_orm::prelude::Uuid;
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/me/api-keys",
|
||||
responses(
|
||||
(status = 200, description = "List of API keys", body = ApiKeyListSchema),
|
||||
(status = 401, description = "Unauthorized", body = ApiErrorResponse),
|
||||
(status = 500, description = "Internal server error", body = ApiErrorResponse),
|
||||
),
|
||||
security(
|
||||
("bearerAuth" = [])
|
||||
)
|
||||
)]
|
||||
async fn get_keys(
|
||||
State(state): State<AppState>,
|
||||
auth_user: AuthUser,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
let keys = api_key::get_api_keys_for_user(&state.conn, auth_user.id).await?;
|
||||
Ok(Json(ApiKeyListSchema::from(keys)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/me/api-keys",
|
||||
request_body = ApiKeyRequest,
|
||||
responses(
|
||||
(status = 201, description = "API key created", body = ApiKeyResponse),
|
||||
(status = 400, description = "Bad request", body = ApiErrorResponse),
|
||||
(status = 401, description = "Unauthorized", body = ApiErrorResponse),
|
||||
(status = 422, description = "Validation error", body = ApiErrorResponse),
|
||||
(status = 500, description = "Internal server error", body = ApiErrorResponse),
|
||||
),
|
||||
security(
|
||||
("bearerAuth" = [])
|
||||
)
|
||||
)]
|
||||
async fn create_key(
|
||||
State(state): State<AppState>,
|
||||
auth_user: AuthUser,
|
||||
Json(params): Json<ApiKeyRequest>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
let (key_model, plaintext_key) =
|
||||
api_key::create_api_key(&state.conn, auth_user.id, params.name).await?;
|
||||
|
||||
let response = ApiKeyResponse::from_parts(key_model, Some(plaintext_key));
|
||||
Ok((StatusCode::CREATED, Json(response)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
delete,
|
||||
path = "/me/api-keys/{key_id}",
|
||||
responses(
|
||||
(status = 204, description = "API key deleted"),
|
||||
(status = 401, description = "Unauthorized", body = ApiErrorResponse),
|
||||
(status = 404, description = "API key not found", body = ApiErrorResponse),
|
||||
(status = 500, description = "Internal server error", body = ApiErrorResponse),
|
||||
),
|
||||
params(
|
||||
("key_id" = Uuid, Path, description = "The ID of the API key to delete")
|
||||
),
|
||||
security(
|
||||
("bearerAuth" = [])
|
||||
)
|
||||
)]
|
||||
async fn delete_key(
|
||||
State(state): State<AppState>,
|
||||
auth_user: AuthUser,
|
||||
Path(key_id): Path<Uuid>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
api_key::delete_api_key(&state.conn, key_id, auth_user.id).await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
pub fn create_api_key_router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/", get(get_keys).post(create_key))
|
||||
.route("/{key_id}", delete(delete_key))
|
||||
}
|
@@ -1,7 +1,7 @@
|
||||
use axum::{extract::State, response::IntoResponse, routing::get, Json, Router};
|
||||
|
||||
use app::{
|
||||
persistence::{follow::get_followed_ids, thought::get_feed_for_user},
|
||||
persistence::{follow::get_following_ids, thought::get_feed_for_user},
|
||||
state::AppState,
|
||||
};
|
||||
use models::schemas::thought::{ThoughtListSchema, ThoughtSchema};
|
||||
@@ -23,8 +23,8 @@ async fn feed_get(
|
||||
State(state): State<AppState>,
|
||||
auth_user: AuthUser,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
let followed_ids = get_followed_ids(&state.conn, auth_user.id).await?;
|
||||
let mut thoughts_with_authors = get_feed_for_user(&state.conn, followed_ids).await?;
|
||||
let following_ids = get_following_ids(&state.conn, auth_user.id).await?;
|
||||
let mut thoughts_with_authors = get_feed_for_user(&state.conn, following_ids).await?;
|
||||
|
||||
let own_thoughts = get_feed_for_user(&state.conn, vec![auth_user.id]).await?;
|
||||
thoughts_with_authors.extend(own_thoughts);
|
||||
|
@@ -1,5 +1,6 @@
|
||||
use axum::Router;
|
||||
|
||||
pub mod api_key;
|
||||
pub mod auth;
|
||||
pub mod feed;
|
||||
pub mod root;
|
||||
|
@@ -1,5 +1,8 @@
|
||||
use crate::error::ApiError;
|
||||
use app::{persistence::thought::get_thoughts_by_tag_name, state::AppState};
|
||||
use app::{
|
||||
persistence::{tag, thought::get_thoughts_by_tag_name},
|
||||
state::AppState,
|
||||
};
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
response::IntoResponse,
|
||||
@@ -19,20 +22,28 @@ async fn get_thoughts_by_tag(
|
||||
Path(tag_name): Path<String>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
let thoughts_with_authors = get_thoughts_by_tag_name(&state.conn, &tag_name).await;
|
||||
println!(
|
||||
"Result from get_thoughts_by_tag_name: {:?}",
|
||||
thoughts_with_authors
|
||||
);
|
||||
let thoughts_with_authors = thoughts_with_authors?;
|
||||
println!("Thoughts with authors: {:?}", thoughts_with_authors);
|
||||
let thoughts_schema: Vec<ThoughtSchema> = thoughts_with_authors
|
||||
.into_iter()
|
||||
.map(ThoughtSchema::from)
|
||||
.collect();
|
||||
println!("Thoughts schema: {:?}", thoughts_schema);
|
||||
Ok(Json(ThoughtListSchema::from(thoughts_schema)))
|
||||
}
|
||||
|
||||
pub fn create_tag_router() -> Router<AppState> {
|
||||
Router::new().route("/{tag_name}", get(get_thoughts_by_tag))
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/popular",
|
||||
responses((status = 200, description = "List of popular tags", body = Vec<String>))
|
||||
)]
|
||||
async fn get_popular_tags(State(state): State<AppState>) -> Result<impl IntoResponse, ApiError> {
|
||||
let tags = tag::get_popular_tags(&state.conn).await;
|
||||
println!("Fetched popular tags: {:?}", tags);
|
||||
let tags = tags?;
|
||||
Ok(Json(tags))
|
||||
}
|
||||
|
||||
pub fn create_tag_router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/{tag_name}", get(get_thoughts_by_tag))
|
||||
.route("/popular", get(get_popular_tags))
|
||||
}
|
||||
|
@@ -19,9 +19,12 @@ use models::schemas::user::{UserListSchema, UserSchema};
|
||||
use models::{params::user::UpdateUserParams, schemas::thought::ThoughtListSchema};
|
||||
use models::{queries::user::UserQuery, schemas::thought::ThoughtSchema};
|
||||
|
||||
use crate::extractor::{Json, Valid};
|
||||
use crate::models::ApiErrorResponse;
|
||||
use crate::{error::ApiError, extractor::AuthUser};
|
||||
use crate::{
|
||||
extractor::{Json, Valid},
|
||||
routers::api_key::create_api_key_router,
|
||||
};
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
@@ -245,7 +248,12 @@ async fn get_user_by_param(
|
||||
}
|
||||
} else {
|
||||
match get_user_by_username(&state.conn, &username).await {
|
||||
Ok(Some(user)) => Json(UserSchema::from(user)).into_response(),
|
||||
Ok(Some(user)) => {
|
||||
let top_friends = app::persistence::user::get_top_friends(&state.conn, user.id)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
Json(UserSchema::from((user, top_friends))).into_response()
|
||||
}
|
||||
Ok(None) => ApiError::from(UserError::NotFound).into_response(),
|
||||
Err(e) => ApiError::from(e).into_response(),
|
||||
}
|
||||
@@ -329,7 +337,9 @@ async fn get_me(
|
||||
let user = get_user(&state.conn, auth_user.id)
|
||||
.await?
|
||||
.ok_or(UserError::NotFound)?;
|
||||
Ok(axum::Json(UserSchema::from(user)))
|
||||
let top_friends = app::persistence::user::get_top_friends(&state.conn, auth_user.id).await?;
|
||||
|
||||
Ok(axum::Json(UserSchema::from((user, top_friends))))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
@@ -358,6 +368,7 @@ pub fn create_user_router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/", get(users_get))
|
||||
.route("/me", get(get_me).put(update_me))
|
||||
.nest("/me/api-keys", create_api_key_router())
|
||||
.route("/{param}", get(get_user_by_param))
|
||||
.route("/{username}/thoughts", get(user_thoughts_get))
|
||||
.route(
|
||||
|
@@ -12,4 +12,6 @@ path = "src/lib.rs"
|
||||
bcrypt = "0.17.1"
|
||||
models = { path = "../models" }
|
||||
validator = "0.20"
|
||||
rand = "0.8.5"
|
||||
sea-orm = { version = "1.1.12" }
|
||||
chrono = { workspace = true }
|
||||
|
93
thoughts-backend/app/src/persistence/api_key.rs
Normal file
93
thoughts-backend/app/src/persistence/api_key.rs
Normal file
@@ -0,0 +1,93 @@
|
||||
use bcrypt::{hash, verify, DEFAULT_COST};
|
||||
use models::domains::{api_key, user};
|
||||
use rand::distributions::{Alphanumeric, DistString};
|
||||
use sea_orm::{
|
||||
prelude::Uuid, ActiveModelTrait, ColumnTrait, DbConn, DbErr, EntityTrait, QueryFilter, Set,
|
||||
};
|
||||
|
||||
use crate::error::UserError;
|
||||
|
||||
const KEY_PREFIX: &str = "th_";
|
||||
const KEY_RANDOM_LENGTH: usize = 32;
|
||||
const KEY_LOOKUP_PREFIX_LENGTH: usize = 8;
|
||||
|
||||
fn generate_key() -> String {
|
||||
let random_part = Alphanumeric.sample_string(&mut rand::thread_rng(), KEY_RANDOM_LENGTH);
|
||||
format!("{}{}", KEY_PREFIX, random_part)
|
||||
}
|
||||
|
||||
pub async fn create_api_key(
|
||||
db: &DbConn,
|
||||
user_id: Uuid,
|
||||
name: String,
|
||||
) -> Result<(api_key::Model, String), UserError> {
|
||||
let plaintext_key = generate_key();
|
||||
let key_hash =
|
||||
hash(&plaintext_key, DEFAULT_COST).map_err(|e| UserError::Internal(e.to_string()))?;
|
||||
let key_prefix = plaintext_key[..KEY_LOOKUP_PREFIX_LENGTH].to_string();
|
||||
|
||||
let new_key = api_key::ActiveModel {
|
||||
user_id: Set(user_id),
|
||||
name: Set(name),
|
||||
key_hash: Set(key_hash),
|
||||
key_prefix: Set(key_prefix),
|
||||
..Default::default()
|
||||
}
|
||||
.insert(db)
|
||||
.await
|
||||
.map_err(|e| UserError::Internal(e.to_string()))?;
|
||||
|
||||
Ok((new_key, plaintext_key))
|
||||
}
|
||||
|
||||
pub async fn validate_api_key(db: &DbConn, plaintext_key: &str) -> Result<user::Model, UserError> {
|
||||
if !plaintext_key.starts_with(KEY_PREFIX)
|
||||
|| plaintext_key.len() != KEY_PREFIX.len() + KEY_RANDOM_LENGTH
|
||||
{
|
||||
return Err(UserError::Validation("Invalid API key format".to_string()));
|
||||
}
|
||||
|
||||
let key_prefix = &plaintext_key[..KEY_LOOKUP_PREFIX_LENGTH];
|
||||
|
||||
let candidate_keys = api_key::Entity::find()
|
||||
.filter(api_key::Column::KeyPrefix.eq(key_prefix))
|
||||
.all(db)
|
||||
.await
|
||||
.map_err(|e| UserError::Internal(e.to_string()))?;
|
||||
|
||||
for key in candidate_keys {
|
||||
if verify(plaintext_key, &key.key_hash).unwrap_or(false) {
|
||||
return super::user::get_user(db, key.user_id)
|
||||
.await
|
||||
.map_err(|e| UserError::Internal(e.to_string()))?
|
||||
.ok_or(UserError::NotFound);
|
||||
}
|
||||
}
|
||||
|
||||
Err(UserError::Validation("Invalid API key".to_string()))
|
||||
}
|
||||
|
||||
pub async fn get_api_keys_for_user(
|
||||
db: &DbConn,
|
||||
user_id: Uuid,
|
||||
) -> Result<Vec<api_key::Model>, DbErr> {
|
||||
api_key::Entity::find()
|
||||
.filter(api_key::Column::UserId.eq(user_id))
|
||||
.all(db)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn delete_api_key(db: &DbConn, key_id: Uuid, user_id: Uuid) -> Result<(), UserError> {
|
||||
let result = api_key::Entity::delete_many()
|
||||
.filter(api_key::Column::Id.eq(key_id))
|
||||
.filter(api_key::Column::UserId.eq(user_id)) // Ensure user owns the key
|
||||
.exec(db)
|
||||
.await
|
||||
.map_err(|e| UserError::Internal(e.to_string()))?;
|
||||
|
||||
if result.rows_affected == 0 {
|
||||
Err(UserError::NotFound)
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
@@ -13,7 +13,6 @@ fn hash_password(password: &str) -> Result<String, BcryptError> {
|
||||
}
|
||||
|
||||
pub async fn register_user(db: &DbConn, params: RegisterParams) -> Result<user::Model, UserError> {
|
||||
// Validate the parameters
|
||||
params
|
||||
.validate()
|
||||
.map_err(|e| UserError::Validation(e.to_string()))?;
|
||||
@@ -22,8 +21,10 @@ pub async fn register_user(db: &DbConn, params: RegisterParams) -> Result<user::
|
||||
hash_password(¶ms.password).map_err(|e| UserError::Internal(e.to_string()))?;
|
||||
|
||||
let new_user = user::ActiveModel {
|
||||
username: Set(params.username),
|
||||
username: Set(params.username.clone()),
|
||||
password_hash: Set(Some(hashed_password)),
|
||||
email: Set(Some(params.email)),
|
||||
display_name: Set(Some(params.username)),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
|
@@ -7,7 +7,7 @@ use models::domains::follow;
|
||||
|
||||
pub async fn add_follower(
|
||||
db: &DbConn,
|
||||
followed_id: Uuid,
|
||||
following_id: Uuid,
|
||||
follower_actor_id: &str,
|
||||
) -> Result<(), UserError> {
|
||||
let follower_username = follower_actor_id
|
||||
@@ -20,21 +20,21 @@ pub async fn add_follower(
|
||||
.map_err(|e| UserError::Internal(e.to_string()))?
|
||||
.ok_or(UserError::NotFound)?;
|
||||
|
||||
follow_user(db, follower.id, followed_id)
|
||||
follow_user(db, follower.id, following_id)
|
||||
.await
|
||||
.map_err(|e| UserError::Internal(e.to_string()))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn follow_user(db: &DbConn, follower_id: Uuid, followed_id: Uuid) -> Result<(), DbErr> {
|
||||
if follower_id == followed_id {
|
||||
pub async fn follow_user(db: &DbConn, follower_id: Uuid, following_id: Uuid) -> Result<(), DbErr> {
|
||||
if follower_id == following_id {
|
||||
return Err(DbErr::Custom("Users cannot follow themselves".to_string()));
|
||||
}
|
||||
|
||||
let follow = follow::ActiveModel {
|
||||
follower_id: Set(follower_id),
|
||||
followed_id: Set(followed_id),
|
||||
following_id: Set(following_id),
|
||||
};
|
||||
|
||||
follow.insert(db).await?;
|
||||
@@ -44,11 +44,11 @@ pub async fn follow_user(db: &DbConn, follower_id: Uuid, followed_id: Uuid) -> R
|
||||
pub async fn unfollow_user(
|
||||
db: &DbConn,
|
||||
follower_id: Uuid,
|
||||
followed_id: Uuid,
|
||||
following_id: Uuid,
|
||||
) -> Result<(), UserError> {
|
||||
let deleted_result = follow::Entity::delete_many()
|
||||
.filter(follow::Column::FollowerId.eq(follower_id))
|
||||
.filter(follow::Column::FollowedId.eq(followed_id))
|
||||
.filter(follow::Column::FollowingId.eq(following_id))
|
||||
.exec(db)
|
||||
.await
|
||||
.map_err(|e| UserError::Internal(e.to_string()))?;
|
||||
@@ -60,18 +60,18 @@ pub async fn unfollow_user(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get_followed_ids(db: &DbConn, user_id: Uuid) -> Result<Vec<Uuid>, DbErr> {
|
||||
pub async fn get_following_ids(db: &DbConn, user_id: Uuid) -> Result<Vec<Uuid>, DbErr> {
|
||||
let followed_users = follow::Entity::find()
|
||||
.filter(follow::Column::FollowerId.eq(user_id))
|
||||
.all(db)
|
||||
.await?;
|
||||
|
||||
Ok(followed_users.into_iter().map(|f| f.followed_id).collect())
|
||||
Ok(followed_users.into_iter().map(|f| f.following_id).collect())
|
||||
}
|
||||
|
||||
pub async fn get_follower_ids(db: &DbConn, user_id: Uuid) -> Result<Vec<Uuid>, DbErr> {
|
||||
let followers = follow::Entity::find()
|
||||
.filter(follow::Column::FollowedId.eq(user_id))
|
||||
.filter(follow::Column::FollowingId.eq(user_id))
|
||||
.all(db)
|
||||
.await?;
|
||||
Ok(followers.into_iter().map(|f| f.follower_id).collect())
|
||||
|
@@ -1,3 +1,4 @@
|
||||
pub mod api_key;
|
||||
pub mod auth;
|
||||
pub mod follow;
|
||||
pub mod tag;
|
||||
|
@@ -1,6 +1,8 @@
|
||||
use models::domains::{tag, thought_tag};
|
||||
use chrono::{Duration, Utc};
|
||||
use models::domains::{tag, thought, thought_tag};
|
||||
use sea_orm::{
|
||||
sqlx::types::uuid, ColumnTrait, ConnectionTrait, DbErr, EntityTrait, QueryFilter, Set,
|
||||
prelude::Expr, sea_query::Alias, sqlx::types::uuid, ColumnTrait, ConnectionTrait, DbErr,
|
||||
EntityTrait, QueryFilter, QueryOrder, QuerySelect, RelationTrait, Set,
|
||||
};
|
||||
use std::collections::HashSet;
|
||||
|
||||
@@ -84,3 +86,34 @@ where
|
||||
thought_tag::Entity::insert_many(links).exec(db).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get_popular_tags<C>(db: &C) -> Result<Vec<String>, DbErr>
|
||||
where
|
||||
C: ConnectionTrait,
|
||||
{
|
||||
let seven_days_ago = Utc::now() - Duration::days(7);
|
||||
|
||||
let popular_tags = tag::Entity::find()
|
||||
.select_only()
|
||||
.column(tag::Column::Name)
|
||||
.column_as(Expr::col((tag::Entity, tag::Column::Id)).count(), "count")
|
||||
.join(
|
||||
sea_orm::JoinType::InnerJoin,
|
||||
tag::Relation::ThoughtTag.def(),
|
||||
)
|
||||
.join(
|
||||
sea_orm::JoinType::InnerJoin,
|
||||
thought_tag::Relation::Thought.def(),
|
||||
)
|
||||
.filter(thought::Column::CreatedAt.gte(seven_days_ago))
|
||||
.group_by(tag::Column::Name)
|
||||
.group_by(tag::Column::Id)
|
||||
.order_by_desc(Expr::col(Alias::new("count")))
|
||||
.order_by_asc(tag::Column::Name)
|
||||
.limit(10)
|
||||
.into_tuple::<(String, i64)>()
|
||||
.all(db)
|
||||
.await?;
|
||||
|
||||
Ok(popular_tags.into_iter().map(|(name, _)| name).collect())
|
||||
}
|
||||
|
@@ -69,9 +69,9 @@ pub async fn get_thoughts_by_user(
|
||||
|
||||
pub async fn get_feed_for_user(
|
||||
db: &DbConn,
|
||||
followed_ids: Vec<Uuid>,
|
||||
following_ids: Vec<Uuid>,
|
||||
) -> Result<Vec<ThoughtWithAuthor>, UserError> {
|
||||
if followed_ids.is_empty() {
|
||||
if following_ids.is_empty() {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
|
||||
@@ -83,7 +83,7 @@ pub async fn get_feed_for_user(
|
||||
.column(thought::Column::AuthorId)
|
||||
.column_as(user::Column::Username, "author_username")
|
||||
.join(JoinType::InnerJoin, thought::Relation::User.def())
|
||||
.filter(thought::Column::AuthorId.is_in(followed_ids))
|
||||
.filter(thought::Column::AuthorId.is_in(following_ids))
|
||||
.order_by_desc(thought::Column::CreatedAt)
|
||||
.into_model::<ThoughtWithAuthor>()
|
||||
.all(db)
|
||||
|
@@ -1,6 +1,7 @@
|
||||
use sea_orm::prelude::Uuid;
|
||||
use sea_orm::{
|
||||
ActiveModelTrait, ColumnTrait, DbConn, DbErr, EntityTrait, QueryFilter, Set, TransactionTrait,
|
||||
ActiveModelTrait, ColumnTrait, DbConn, DbErr, EntityTrait, JoinType, QueryFilter, QueryOrder,
|
||||
QuerySelect, RelationTrait, Set, TransactionTrait,
|
||||
};
|
||||
|
||||
use models::domains::{top_friends, user};
|
||||
@@ -127,3 +128,12 @@ pub async fn update_user_profile(
|
||||
.await
|
||||
.map_err(|e| UserError::Internal(e.to_string()))
|
||||
}
|
||||
|
||||
pub async fn get_top_friends(db: &DbConn, user_id: Uuid) -> Result<Vec<user::Model>, DbErr> {
|
||||
user::Entity::find()
|
||||
.join(JoinType::InnerJoin, top_friends::Relation::User.def().rev())
|
||||
.filter(top_friends::Column::UserId.eq(user_id))
|
||||
.order_by_asc(top_friends::Column::Position)
|
||||
.all(db)
|
||||
.await
|
||||
}
|
||||
|
@@ -19,7 +19,7 @@ use models::schemas::{
|
||||
user_inbox_post,
|
||||
user_outbox_get,
|
||||
get_me,
|
||||
update_me
|
||||
update_me,
|
||||
),
|
||||
components(schemas(
|
||||
CreateUserParams,
|
||||
|
@@ -4,6 +4,7 @@ mod m20240101_000001_init;
|
||||
mod m20250905_000001_init;
|
||||
mod m20250906_100000_add_profile_fields;
|
||||
mod m20250906_130237_add_tags;
|
||||
mod m20250906_134056_add_api_keys;
|
||||
|
||||
pub struct Migrator;
|
||||
|
||||
@@ -15,6 +16,7 @@ impl MigratorTrait for Migrator {
|
||||
Box::new(m20250905_000001_init::Migration),
|
||||
Box::new(m20250906_100000_add_profile_fields::Migration),
|
||||
Box::new(m20250906_130237_add_tags::Migration),
|
||||
Box::new(m20250906_134056_add_api_keys::Migration),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
@@ -46,12 +46,12 @@ impl MigrationTrait for Migration {
|
||||
.table(Follow::Table)
|
||||
.if_not_exists()
|
||||
.col(uuid(Follow::FollowerId).not_null())
|
||||
.col(uuid(Follow::FollowedId).not_null())
|
||||
.col(uuid(Follow::FollowingId).not_null())
|
||||
// Composite Primary Key to ensure a user can only follow another once
|
||||
.primary_key(
|
||||
Index::create()
|
||||
.col(Follow::FollowerId)
|
||||
.col(Follow::FollowedId),
|
||||
.col(Follow::FollowingId),
|
||||
)
|
||||
.foreign_key(
|
||||
ForeignKey::create()
|
||||
@@ -62,8 +62,8 @@ impl MigrationTrait for Migration {
|
||||
)
|
||||
.foreign_key(
|
||||
ForeignKey::create()
|
||||
.name("fk_follow_followed_id")
|
||||
.from(Follow::Table, Follow::FollowedId)
|
||||
.name("fk_follow_following_id")
|
||||
.from(Follow::Table, Follow::FollowingId)
|
||||
.to(User::Table, User::Id)
|
||||
.on_delete(ForeignKeyAction::Cascade),
|
||||
)
|
||||
@@ -97,5 +97,5 @@ pub enum Follow {
|
||||
// The user who is initiating the follow
|
||||
FollowerId,
|
||||
// The user who is being followed
|
||||
FollowedId,
|
||||
FollowingId,
|
||||
}
|
||||
|
@@ -0,0 +1,69 @@
|
||||
use super::m20240101_000001_init::User;
|
||||
use sea_orm_migration::{prelude::*, schema::*};
|
||||
|
||||
#[derive(DeriveMigrationName)]
|
||||
pub struct Migration;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.create_table(
|
||||
Table::create()
|
||||
.table(ApiKey::Table)
|
||||
.if_not_exists()
|
||||
.col(
|
||||
ColumnDef::new(ApiKey::Id)
|
||||
.uuid()
|
||||
.not_null()
|
||||
.primary_key()
|
||||
.default(Expr::cust("gen_random_uuid()")),
|
||||
)
|
||||
.col(uuid(ApiKey::UserId).not_null())
|
||||
.foreign_key(
|
||||
ForeignKey::create()
|
||||
.name("fk_api_key_user_id")
|
||||
.from(ApiKey::Table, ApiKey::UserId)
|
||||
.to(User::Table, User::Id)
|
||||
.on_delete(ForeignKeyAction::Cascade),
|
||||
)
|
||||
.col(text(ApiKey::KeyHash).not_null().unique_key())
|
||||
.col(string(ApiKey::Name).not_null())
|
||||
.col(
|
||||
timestamp_with_time_zone(ApiKey::CreatedAt)
|
||||
.not_null()
|
||||
.default(Expr::current_timestamp()),
|
||||
)
|
||||
.col(ColumnDef::new(ApiKey::KeyPrefix).string_len(8).not_null())
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_index(
|
||||
Index::create()
|
||||
.name("idx-api_keys-key_prefix")
|
||||
.table(ApiKey::Table)
|
||||
.col(ApiKey::KeyPrefix)
|
||||
.to_owned(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.drop_table(Table::drop().table(ApiKey::Table).to_owned())
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(DeriveIden)]
|
||||
enum ApiKey {
|
||||
Table,
|
||||
Id,
|
||||
UserId,
|
||||
KeyHash,
|
||||
Name,
|
||||
CreatedAt,
|
||||
KeyPrefix,
|
||||
}
|
32
thoughts-backend/models/src/domains/api_key.rs
Normal file
32
thoughts-backend/models/src/domains/api_key.rs
Normal file
@@ -0,0 +1,32 @@
|
||||
use sea_orm::entity::prelude::*;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
|
||||
#[sea_orm(table_name = "api_key")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub id: Uuid,
|
||||
pub user_id: Uuid,
|
||||
pub key_prefix: String,
|
||||
#[sea_orm(unique)]
|
||||
pub key_hash: String,
|
||||
pub name: String,
|
||||
pub created_at: DateTimeWithTimeZone,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(
|
||||
belongs_to = "super::user::Entity",
|
||||
from = "Column::UserId",
|
||||
to = "super::user::Column::Id"
|
||||
)]
|
||||
User,
|
||||
}
|
||||
|
||||
impl Related<super::user::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::User.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
@@ -6,7 +6,7 @@ pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub follower_id: Uuid,
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub followed_id: Uuid,
|
||||
pub following_id: Uuid,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
@@ -21,12 +21,12 @@ pub enum Relation {
|
||||
Follower,
|
||||
#[sea_orm(
|
||||
belongs_to = "super::user::Entity",
|
||||
from = "Column::FollowedId",
|
||||
from = "Column::FollowingId",
|
||||
to = "super::user::Column::Id",
|
||||
on_update = "NoAction",
|
||||
on_delete = "Cascade"
|
||||
)]
|
||||
Followed,
|
||||
Following,
|
||||
}
|
||||
|
||||
impl Related<super::user::Entity> for Entity {
|
||||
|
@@ -2,6 +2,7 @@
|
||||
|
||||
pub mod prelude;
|
||||
|
||||
pub mod api_key;
|
||||
pub mod follow;
|
||||
pub mod tag;
|
||||
pub mod thought;
|
||||
|
@@ -1,5 +1,6 @@
|
||||
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.0.0
|
||||
|
||||
pub use super::api_key::Entity as ApiKey;
|
||||
pub use super::follow::Entity as Follow;
|
||||
pub use super::tag::Entity as Tag;
|
||||
pub use super::thought::Entity as Thought;
|
||||
|
@@ -28,6 +28,9 @@ pub enum Relation {
|
||||
|
||||
#[sea_orm(has_many = "super::top_friends::Entity")]
|
||||
TopFriends,
|
||||
|
||||
#[sea_orm(has_many = "super::api_key::Entity")]
|
||||
ApiKey,
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
|
@@ -6,6 +6,8 @@ use validator::Validate;
|
||||
pub struct RegisterParams {
|
||||
#[validate(length(min = 3))]
|
||||
pub username: String,
|
||||
#[validate(email)]
|
||||
pub email: String,
|
||||
#[validate(length(min = 6))]
|
||||
pub password: String,
|
||||
}
|
||||
|
62
thoughts-backend/models/src/schemas/api_key.rs
Normal file
62
thoughts-backend/models/src/schemas/api_key.rs
Normal file
@@ -0,0 +1,62 @@
|
||||
use crate::domains::api_key;
|
||||
use common::DateTimeWithTimeZoneWrapper;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct ApiKeySchema {
|
||||
pub id: Uuid,
|
||||
pub name: String,
|
||||
pub key_prefix: String,
|
||||
pub created_at: DateTimeWithTimeZoneWrapper,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct ApiKeyResponse {
|
||||
#[serde(flatten)]
|
||||
pub key: ApiKeySchema,
|
||||
/// The full plaintext API key. This is only returned on creation.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub plaintext_key: Option<String>,
|
||||
}
|
||||
|
||||
impl ApiKeyResponse {
|
||||
pub fn from_parts(model: api_key::Model, plaintext_key: Option<String>) -> Self {
|
||||
Self {
|
||||
key: ApiKeySchema {
|
||||
id: model.id,
|
||||
name: model.name,
|
||||
key_prefix: model.key_prefix,
|
||||
created_at: model.created_at.into(),
|
||||
},
|
||||
plaintext_key,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct ApiKeyListSchema {
|
||||
pub api_keys: Vec<ApiKeySchema>,
|
||||
}
|
||||
|
||||
impl From<Vec<api_key::Model>> for ApiKeyListSchema {
|
||||
fn from(keys: Vec<api_key::Model>) -> Self {
|
||||
Self {
|
||||
api_keys: keys
|
||||
.into_iter()
|
||||
.map(|k| ApiKeySchema {
|
||||
id: k.id,
|
||||
name: k.name,
|
||||
key_prefix: k.key_prefix,
|
||||
created_at: k.created_at.into(),
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub struct ApiKeyRequest {
|
||||
pub name: String,
|
||||
}
|
@@ -1,2 +1,3 @@
|
||||
pub mod api_key;
|
||||
pub mod thought;
|
||||
pub mod user;
|
||||
|
@@ -14,12 +14,26 @@ pub struct UserSchema {
|
||||
pub avatar_url: Option<String>,
|
||||
pub header_url: Option<String>,
|
||||
pub custom_css: Option<String>,
|
||||
// In a real implementation, you'd fetch and return this data.
|
||||
// For now, we'll omit it from the schema to keep it simple.
|
||||
// pub top_friends: Vec<String>,
|
||||
pub top_friends: Vec<String>,
|
||||
pub joined_at: DateTimeWithTimeZoneWrapper,
|
||||
}
|
||||
|
||||
impl From<(user::Model, Vec<user::Model>)> for UserSchema {
|
||||
fn from((user, top_friends): (user::Model, Vec<user::Model>)) -> Self {
|
||||
Self {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
display_name: user.display_name,
|
||||
bio: user.bio,
|
||||
avatar_url: user.avatar_url,
|
||||
header_url: user.header_url,
|
||||
custom_css: user.custom_css,
|
||||
top_friends: top_friends.into_iter().map(|u| u.username).collect(),
|
||||
joined_at: user.created_at.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<user::Model> for UserSchema {
|
||||
fn from(user: user::Model) -> Self {
|
||||
Self {
|
||||
@@ -30,6 +44,7 @@ impl From<user::Model> for UserSchema {
|
||||
avatar_url: user.avatar_url,
|
||||
header_url: user.header_url,
|
||||
custom_css: user.custom_css,
|
||||
top_friends: vec![], // Defaults to an empty list
|
||||
joined_at: user.created_at.into(),
|
||||
}
|
||||
}
|
||||
|
@@ -9,7 +9,7 @@ use utils::testing::{
|
||||
#[tokio::test]
|
||||
async fn test_webfinger_discovery() {
|
||||
let app = setup().await;
|
||||
create_user_with_password(&app.db, "testuser", "password123").await;
|
||||
create_user_with_password(&app.db, "testuser", "password123", "testuser@example.com").await;
|
||||
|
||||
// 1. Valid WebFinger lookup for existing user
|
||||
let url = "/.well-known/webfinger?resource=acct:testuser@localhost:3000";
|
||||
@@ -36,7 +36,7 @@ async fn test_webfinger_discovery() {
|
||||
#[tokio::test]
|
||||
async fn test_user_actor_endpoint() {
|
||||
let app = setup().await;
|
||||
create_user_with_password(&app.db, "testuser", "password123").await;
|
||||
create_user_with_password(&app.db, "testuser", "password123", "testuser@example.com").await;
|
||||
|
||||
let response = make_request_with_headers(
|
||||
app.router.clone(),
|
||||
@@ -64,9 +64,11 @@ async fn test_user_actor_endpoint() {
|
||||
async fn test_user_inbox_follow() {
|
||||
let app = setup().await;
|
||||
// user1 will be followed
|
||||
let user1 = create_user_with_password(&app.db, "user1", "password123").await;
|
||||
let user1 =
|
||||
create_user_with_password(&app.db, "user1", "password123", "user1@example.com").await;
|
||||
// user2 will be the follower
|
||||
let user2 = create_user_with_password(&app.db, "user2", "password123").await;
|
||||
let user2 =
|
||||
create_user_with_password(&app.db, "user2", "password123", "user2@example.com").await;
|
||||
|
||||
// Construct a follow activity from user2, targeting user1
|
||||
let follow_activity = json!({
|
||||
@@ -90,7 +92,7 @@ async fn test_user_inbox_follow() {
|
||||
assert_eq!(response.status(), StatusCode::ACCEPTED);
|
||||
|
||||
// Verify that user2 is now following user1 in the database
|
||||
let followers = app::persistence::follow::get_followed_ids(&app.db, user2.id)
|
||||
let followers = app::persistence::follow::get_following_ids(&app.db, user2.id)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
@@ -98,7 +100,7 @@ async fn test_user_inbox_follow() {
|
||||
"User2 should be following user1"
|
||||
);
|
||||
|
||||
let following = app::persistence::follow::get_followed_ids(&app.db, user1.id)
|
||||
let following = app::persistence::follow::get_following_ids(&app.db, user1.id)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
@@ -111,7 +113,7 @@ async fn test_user_inbox_follow() {
|
||||
#[tokio::test]
|
||||
async fn test_user_outbox_get() {
|
||||
let app = setup().await;
|
||||
create_user_with_password(&app.db, "testuser", "password123").await;
|
||||
create_user_with_password(&app.db, "testuser", "password123", "testuser@example.com").await;
|
||||
let token = super::main::login_user(app.router.clone(), "testuser", "password123").await;
|
||||
|
||||
// Create a thought first
|
||||
|
85
thoughts-backend/tests/api/api_key.rs
Normal file
85
thoughts-backend/tests/api/api_key.rs
Normal file
@@ -0,0 +1,85 @@
|
||||
use crate::api::main::{create_user_with_password, login_user, setup};
|
||||
use axum::http::{header, HeaderName, StatusCode};
|
||||
use http_body_util::BodyExt;
|
||||
use serde_json::{json, Value};
|
||||
use utils::testing::{make_jwt_request, make_request_with_headers};
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_api_key_flow() {
|
||||
let app = setup().await;
|
||||
let _ = create_user_with_password(
|
||||
&app.db,
|
||||
"apikey_user",
|
||||
"password123",
|
||||
"apikey_user@example.com",
|
||||
)
|
||||
.await;
|
||||
let jwt = login_user(app.router.clone(), "apikey_user", "password123").await;
|
||||
|
||||
// 1. Create a new API key using JWT auth
|
||||
let create_body = json!({ "name": "My Test Key" }).to_string();
|
||||
let response = make_jwt_request(
|
||||
app.router.clone(),
|
||||
"/users/me/api-keys",
|
||||
"POST",
|
||||
Some(create_body),
|
||||
&jwt,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(response.status(), StatusCode::CREATED);
|
||||
|
||||
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||
let v: Value = serde_json::from_slice(&body).unwrap();
|
||||
|
||||
let plaintext_key = v["plaintext_key"]
|
||||
.as_str()
|
||||
.expect("Plaintext key not found")
|
||||
.to_string();
|
||||
let key_id = v["id"].as_str().expect("Key ID not found").to_string();
|
||||
assert!(plaintext_key.starts_with("th_"));
|
||||
|
||||
// 2. Use the new API key to post a thought
|
||||
|
||||
let thought_body = json!({ "content": "Posting with an API key!" }).to_string();
|
||||
let key = plaintext_key.clone();
|
||||
let api_key_header = format!("ApiKey {}", key);
|
||||
let content_type = "application/json";
|
||||
let headers: Vec<(HeaderName, &str)> = vec![
|
||||
(header::AUTHORIZATION, &api_key_header),
|
||||
(header::CONTENT_TYPE, content_type),
|
||||
];
|
||||
|
||||
let response = make_request_with_headers(
|
||||
app.router.clone(),
|
||||
"/thoughts",
|
||||
"POST",
|
||||
Some(thought_body),
|
||||
headers,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(response.status(), StatusCode::CREATED);
|
||||
|
||||
// 3. Delete the API key using JWT auth
|
||||
let response = make_jwt_request(
|
||||
app.router.clone(),
|
||||
&format!("/users/me/api-keys/{}", key_id),
|
||||
"DELETE",
|
||||
None,
|
||||
&jwt,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(response.status(), StatusCode::NO_CONTENT);
|
||||
|
||||
// 4. Try to use the deleted key again, expecting failure
|
||||
let body = json!({ "content": "This should fail" }).to_string();
|
||||
let headers: Vec<(HeaderName, &str)> = vec![
|
||||
(header::AUTHORIZATION, &api_key_header),
|
||||
(header::CONTENT_TYPE, content_type),
|
||||
];
|
||||
|
||||
let response =
|
||||
make_request_with_headers(app.router.clone(), "/thoughts", "POST", Some(body), headers)
|
||||
.await;
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
@@ -11,6 +11,7 @@ async fn test_auth_flow() {
|
||||
|
||||
let register_body = json!({
|
||||
"username": "testuser",
|
||||
"email": "testuser@example.com",
|
||||
"password": "password123"
|
||||
})
|
||||
.to_string();
|
||||
@@ -26,6 +27,7 @@ async fn test_auth_flow() {
|
||||
"/auth/register",
|
||||
json!({
|
||||
"username": "testuser",
|
||||
"email": "testuser@example.com",
|
||||
"password": "password456"
|
||||
})
|
||||
.to_string(),
|
||||
@@ -48,6 +50,7 @@ async fn test_auth_flow() {
|
||||
|
||||
let bad_login_body = json!({
|
||||
"username": "testuser",
|
||||
"email": "testuser@example.com",
|
||||
"password": "wrongpassword"
|
||||
})
|
||||
.to_string();
|
||||
|
@@ -7,9 +7,9 @@ use utils::testing::make_jwt_request;
|
||||
#[tokio::test]
|
||||
async fn test_feed_and_user_thoughts() {
|
||||
let app = setup().await;
|
||||
create_user_with_password(&app.db, "user1", "password1").await;
|
||||
create_user_with_password(&app.db, "user2", "password2").await;
|
||||
create_user_with_password(&app.db, "user3", "password3").await;
|
||||
create_user_with_password(&app.db, "user1", "password1", "user1@example.com").await;
|
||||
create_user_with_password(&app.db, "user2", "password2", "user2@example.com").await;
|
||||
create_user_with_password(&app.db, "user3", "password3", "user3@example.com").await;
|
||||
|
||||
// As user1, post a thought
|
||||
let token = super::main::login_user(app.router.clone(), "user1", "password1").await;
|
||||
|
@@ -7,8 +7,8 @@ async fn test_follow_endpoints() {
|
||||
std::env::set_var("AUTH_SECRET", "test-secret");
|
||||
let app = setup().await;
|
||||
|
||||
create_user_with_password(&app.db, "user1", "password1").await;
|
||||
create_user_with_password(&app.db, "user2", "password2").await;
|
||||
create_user_with_password(&app.db, "user1", "password1", "user1@example.com").await;
|
||||
create_user_with_password(&app.db, "user2", "password2", "user2@example.com").await;
|
||||
|
||||
let token = super::main::login_user(app.router.clone(), "user1", "password1").await;
|
||||
|
||||
|
@@ -38,10 +38,12 @@ pub async fn create_user_with_password(
|
||||
db: &DatabaseConnection,
|
||||
username: &str,
|
||||
password: &str,
|
||||
email: &str,
|
||||
) -> user::Model {
|
||||
let params = RegisterParams {
|
||||
username: username.to_string(),
|
||||
password: password.to_string(),
|
||||
email: email.to_string(),
|
||||
};
|
||||
app::persistence::auth::register_user(db, params)
|
||||
.await
|
||||
|
@@ -1,4 +1,5 @@
|
||||
mod activitypub;
|
||||
mod api_key;
|
||||
mod auth;
|
||||
mod feed;
|
||||
mod follow;
|
||||
|
@@ -1,4 +1,4 @@
|
||||
use crate::api::main::{create_user_with_password, login_user, setup};
|
||||
use crate::api::main::{create_user_with_password, login_user, setup, TestApp};
|
||||
use axum::http::StatusCode;
|
||||
use http_body_util::BodyExt;
|
||||
use serde_json::{json, Value};
|
||||
@@ -7,7 +7,8 @@ use utils::testing::{make_get_request, make_jwt_request};
|
||||
#[tokio::test]
|
||||
async fn test_hashtag_flow() {
|
||||
let app = setup().await;
|
||||
let user = create_user_with_password(&app.db, "taguser", "password123").await;
|
||||
let user =
|
||||
create_user_with_password(&app.db, "taguser", "password123", "taguser@example.com").await;
|
||||
let token = login_user(app.router.clone(), "taguser", "password123").await;
|
||||
|
||||
// 1. Post a thought with hashtags
|
||||
@@ -25,7 +26,6 @@ async fn test_hashtag_flow() {
|
||||
|
||||
// 3. Fetch thoughts by tag "rustlang"
|
||||
let response = make_get_request(app.router.clone(), "/tags/rustlang", Some(user.id)).await;
|
||||
println!("Response: {:?}", response);
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body_bytes = response.into_body().collect().await.unwrap().to_bytes();
|
||||
let v: Value = serde_json::from_slice(&body_bytes).unwrap();
|
||||
@@ -49,3 +49,43 @@ async fn test_hashtag_flow() {
|
||||
assert_eq!(thoughts.len(), 1);
|
||||
assert_eq!(thoughts[0]["id"], thought_id);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_popular_tags() {
|
||||
let app = setup().await;
|
||||
let _ = create_user_with_password(&app.db, "poptag_user", "password123", "poptag@example.com")
|
||||
.await;
|
||||
let token = login_user(app.router.clone(), "poptag_user", "password123").await;
|
||||
|
||||
// Helper async function to post a thought
|
||||
async fn post_thought(app: &TestApp, token: &str, content: &str) {
|
||||
let body = json!({ "content": content }).to_string();
|
||||
let response =
|
||||
make_jwt_request(app.router.clone(), "/thoughts", "POST", Some(body), token).await;
|
||||
assert_eq!(response.status(), StatusCode::CREATED);
|
||||
}
|
||||
|
||||
// 1. Post thoughts to create tag usage data
|
||||
// Expected counts: rust (3), web (2), axum (2), testing (1)
|
||||
post_thought(&app, &token, "My first post about #rust and the #web").await;
|
||||
post_thought(&app, &token, "Another post about #rust and #axum").await;
|
||||
post_thought(&app, &token, "I'm really enjoying #rust lately").await;
|
||||
post_thought(&app, &token, "Let's talk about #axum and the #web").await;
|
||||
post_thought(&app, &token, "Don't forget about #testing").await;
|
||||
|
||||
// 2. Fetch the popular tags
|
||||
let response = make_get_request(app.router.clone(), "/tags/popular", None).await;
|
||||
println!("Response: {:?}", response);
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
|
||||
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||
let v: Vec<String> = serde_json::from_slice(&body).unwrap();
|
||||
|
||||
// 3. Assert the results
|
||||
assert_eq!(v.len(), 4, "Should return the 4 unique tags used");
|
||||
assert_eq!(
|
||||
v,
|
||||
vec!["rust", "axum", "web", "testing"],
|
||||
"Tags should be ordered by popularity, then alphabetically"
|
||||
);
|
||||
}
|
||||
|
@@ -10,8 +10,10 @@ use utils::testing::{make_delete_request, make_post_request};
|
||||
#[tokio::test]
|
||||
async fn test_thought_endpoints() {
|
||||
let app = setup().await;
|
||||
let user1 = create_user_with_password(&app.db, "user1", "password123").await; // AuthUser is ID 1
|
||||
let _user2 = create_user_with_password(&app.db, "user2", "password123").await; // Other user is ID 2
|
||||
let user1 =
|
||||
create_user_with_password(&app.db, "user1", "password123", "user1@example.com").await; // AuthUser is ID 1
|
||||
let _user2 =
|
||||
create_user_with_password(&app.db, "user2", "password123", "user2@example.com").await; // Other user is ID 2
|
||||
|
||||
// 1. Post a new thought as user 1
|
||||
let body = json!({ "content": "My first thought!" }).to_string();
|
||||
|
@@ -12,7 +12,8 @@ use crate::api::main::{create_user_with_password, login_user, setup};
|
||||
async fn test_post_users() {
|
||||
let app = setup().await;
|
||||
|
||||
let body = r#"{"username": "test", "password": "password123"}"#.to_owned();
|
||||
let body = r#"{"username": "test", "email": "test@example.com", "password": "password123"}"#
|
||||
.to_owned();
|
||||
let response = make_post_request(app.router, "/auth/register", body, None).await;
|
||||
|
||||
assert_eq!(response.status(), StatusCode::CREATED);
|
||||
@@ -21,14 +22,15 @@ async fn test_post_users() {
|
||||
let v: Value = serde_json::from_slice(&body).unwrap();
|
||||
|
||||
assert_eq!(v["username"], "test");
|
||||
assert!(v["display_name"].is_null());
|
||||
assert!(v["display_name"].is_string());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
pub(super) async fn test_post_users_error() {
|
||||
let app = setup().await;
|
||||
|
||||
let body = r#"{"username": "1", "password": "password123"}"#.to_owned();
|
||||
let body =
|
||||
r#"{"username": "1", "email": "test@example.com", "password": "password123"}"#.to_owned();
|
||||
let response = make_post_request(app.router, "/auth/register", body, None).await;
|
||||
|
||||
assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
||||
@@ -43,7 +45,8 @@ pub(super) async fn test_post_users_error() {
|
||||
pub async fn test_get_users() {
|
||||
let app = setup().await;
|
||||
|
||||
let body = r#"{"username": "test", "password": "password123"}"#.to_owned();
|
||||
let body = r#"{"username": "test", "email": "test@example.com", "password": "password123"}"#
|
||||
.to_owned();
|
||||
make_post_request(app.router.clone(), "/auth/register", body, None).await;
|
||||
|
||||
let response = make_get_request(app.router, "/users", None).await;
|
||||
@@ -65,6 +68,7 @@ async fn test_me_endpoints() {
|
||||
// 1. Register a new user
|
||||
let register_body = json!({
|
||||
"username": "me_user",
|
||||
"email": "me_user@example.com",
|
||||
"password": "password123"
|
||||
})
|
||||
.to_string();
|
||||
@@ -82,7 +86,7 @@ async fn test_me_endpoints() {
|
||||
let v: Value = serde_json::from_slice(&body).unwrap();
|
||||
assert_eq!(v["username"], "me_user");
|
||||
assert!(v["bio"].is_null());
|
||||
assert!(v["display_name"].is_null());
|
||||
assert!(v["display_name"].is_string());
|
||||
|
||||
// 4. PUT /users/me to update the profile
|
||||
let update_body = json!({
|
||||
@@ -119,10 +123,14 @@ async fn test_update_me_top_friends() {
|
||||
let app = setup().await;
|
||||
|
||||
// 1. Create users for the test
|
||||
let user_me = create_user_with_password(&app.db, "me_user", "password123").await;
|
||||
let friend1 = create_user_with_password(&app.db, "friend1", "password123").await;
|
||||
let friend2 = create_user_with_password(&app.db, "friend2", "password123").await;
|
||||
let _friend3 = create_user_with_password(&app.db, "friend3", "password123").await;
|
||||
let user_me =
|
||||
create_user_with_password(&app.db, "me_user", "password123", "me_user@example.com").await;
|
||||
let friend1 =
|
||||
create_user_with_password(&app.db, "friend1", "password123", "friend1@example.com").await;
|
||||
let friend2 =
|
||||
create_user_with_password(&app.db, "friend2", "password123", "friend2@example.com").await;
|
||||
let _friend3 =
|
||||
create_user_with_password(&app.db, "friend3", "password123", "friend3@example.com").await;
|
||||
|
||||
// 2. Log in as "me_user"
|
||||
let token = login_user(app.router.clone(), "me_user", "password123").await;
|
||||
@@ -183,3 +191,57 @@ async fn test_update_me_top_friends() {
|
||||
assert_eq!(top_friends_list_2[0].friend_id, friend2.id);
|
||||
assert_eq!(top_friends_list_2[0].position, 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_update_me_css_and_images() {
|
||||
let app = setup().await;
|
||||
|
||||
// 1. Create and log in as a user
|
||||
let _ =
|
||||
create_user_with_password(&app.db, "css_user", "password123", "css_user@example.com").await;
|
||||
let token = login_user(app.router.clone(), "css_user", "password123").await;
|
||||
|
||||
// 2. Attempt to update with an invalid avatar URL
|
||||
let invalid_body = json!({
|
||||
"avatar_url": "not-a-valid-url"
|
||||
})
|
||||
.to_string();
|
||||
|
||||
let response = make_jwt_request(
|
||||
app.router.clone(),
|
||||
"/users/me",
|
||||
"PUT",
|
||||
Some(invalid_body),
|
||||
&token,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
||||
|
||||
// 3. Update profile with valid URLs and custom CSS
|
||||
let valid_body = json!({
|
||||
"avatar_url": "https://example.com/new-avatar.png",
|
||||
"header_url": "https://example.com/new-header.jpg",
|
||||
"custom_css": "body { color: blue; }"
|
||||
})
|
||||
.to_string();
|
||||
|
||||
let response = make_jwt_request(
|
||||
app.router.clone(),
|
||||
"/users/me",
|
||||
"PUT",
|
||||
Some(valid_body),
|
||||
&token,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
|
||||
// 4. Verify the changes were persisted by fetching the profile again
|
||||
let response = make_jwt_request(app.router.clone(), "/users/me", "GET", None, &token).await;
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||
let v: Value = serde_json::from_slice(&body).unwrap();
|
||||
|
||||
assert_eq!(v["avatar_url"], "https://example.com/new-avatar.png");
|
||||
assert_eq!(v["header_url"], "https://example.com/new-header.jpg");
|
||||
assert_eq!(v["custom_css"], "body { color: blue; }");
|
||||
}
|
||||
|
Reference in New Issue
Block a user