Add redis caching to getting user notifications and projects [MOD-540] (#723)

* Add redis caching to getting a user's project ids

* Run `cargo sqlx prepare` to update the sqlx-data.json

* Add redis caching for getting user notifications

* Fix new clippy warnings

* Remove log that shouldn't have been committed

* Batch insert of notifications (untested)

* sqlx prepare...

* Fix merge conflict things and use new redis struct

* Fix bug with calling delete_many without any elements (caught by tests)

* cargo sqlx prepare

* Add tests around cache invalidation (and fix bug they caught!)

* Some test reorg based on code review suggestions
This commit is contained in:
Jackson Kruger
2023-10-12 17:52:24 -05:00
committed by GitHub
parent d66270eef0
commit abf4cd71ba
34 changed files with 848 additions and 379 deletions

175
tests/common/api_v2.rs Normal file
View File

@@ -0,0 +1,175 @@
#![allow(dead_code)]
use super::{
actix::AppendsMultipart,
asserts::assert_status,
database::{MOD_USER_PAT, USER_USER_PAT},
environment::LocalService,
request_data::ProjectCreationRequestData,
};
use actix_http::StatusCode;
use actix_web::{
dev::ServiceResponse,
test::{self, TestRequest},
};
use labrinth::models::{
notifications::Notification,
projects::{Project, Version},
};
use serde_json::json;
use std::rc::Rc;
pub struct ApiV2 {
pub test_app: Rc<Box<dyn LocalService>>,
}
impl ApiV2 {
pub async fn call(&self, req: actix_http::Request) -> ServiceResponse {
self.test_app.call(req).await.unwrap()
}
pub async fn add_public_project(
&self,
creation_data: ProjectCreationRequestData,
) -> (Project, Version) {
// Add a project.
let req = TestRequest::post()
.uri("/v2/project")
.append_header(("Authorization", USER_USER_PAT))
.set_multipart(creation_data.segment_data)
.to_request();
let resp = self.call(req).await;
assert_status(resp, StatusCode::OK);
// Approve as a moderator.
let req = TestRequest::patch()
.uri(&format!("/v2/project/{}", creation_data.slug))
.append_header(("Authorization", MOD_USER_PAT))
.set_json(json!(
{
"status": "approved"
}
))
.to_request();
let resp = self.call(req).await;
assert_status(resp, StatusCode::NO_CONTENT);
let project = self
.get_project_deserialized(&creation_data.slug, USER_USER_PAT)
.await;
// Get project's versions
let req = TestRequest::get()
.uri(&format!("/v2/project/{}/version", creation_data.slug))
.append_header(("Authorization", USER_USER_PAT))
.to_request();
let resp = self.call(req).await;
let versions: Vec<Version> = test::read_body_json(resp).await;
let version = versions.into_iter().next().unwrap();
(project, version)
}
pub async fn remove_project(&self, project_slug_or_id: &str, pat: &str) -> ServiceResponse {
let req = test::TestRequest::delete()
.uri(&format!("/v2/project/{project_slug_or_id}"))
.append_header(("Authorization", pat))
.to_request();
let resp = self.call(req).await;
assert_eq!(resp.status(), 204);
resp
}
pub async fn get_project_deserialized(&self, slug: &str, pat: &str) -> Project {
let req = TestRequest::get()
.uri(&format!("/v2/project/{slug}"))
.append_header(("Authorization", pat))
.to_request();
let resp = self.call(req).await;
test::read_body_json(resp).await
}
pub async fn get_user_projects_deserialized(
&self,
user_id_or_username: &str,
pat: &str,
) -> Vec<Project> {
let req = test::TestRequest::get()
.uri(&format!("/v2/user/{}/projects", user_id_or_username))
.append_header(("Authorization", pat))
.to_request();
let resp = self.call(req).await;
assert_eq!(resp.status(), 200);
test::read_body_json(resp).await
}
pub async fn add_user_to_team(
&self,
team_id: &str,
user_id: &str,
pat: &str,
) -> ServiceResponse {
let req = test::TestRequest::post()
.uri(&format!("/v2/team/{team_id}/members"))
.append_header(("Authorization", pat))
.set_json(json!( {
"user_id": user_id
}))
.to_request();
self.call(req).await
}
pub async fn join_team(&self, team_id: &str, pat: &str) -> ServiceResponse {
let req = test::TestRequest::post()
.uri(&format!("/v2/team/{team_id}/join"))
.append_header(("Authorization", pat))
.to_request();
self.call(req).await
}
pub async fn remove_from_team(
&self,
team_id: &str,
user_id: &str,
pat: &str,
) -> ServiceResponse {
let req = test::TestRequest::delete()
.uri(&format!("/v2/team/{team_id}/members/{user_id}"))
.append_header(("Authorization", pat))
.to_request();
self.call(req).await
}
pub async fn get_user_notifications_deserialized(
&self,
user_id: &str,
pat: &str,
) -> Vec<Notification> {
let req = test::TestRequest::get()
.uri(&format!("/v2/user/{user_id}/notifications"))
.append_header(("Authorization", pat))
.to_request();
let resp = self.call(req).await;
test::read_body_json(resp).await
}
pub async fn mark_notification_read(
&self,
notification_id: &str,
pat: &str,
) -> ServiceResponse {
let req = test::TestRequest::patch()
.uri(&format!("/v2/notification/{notification_id}"))
.append_header(("Authorization", pat))
.to_request();
self.call(req).await
}
pub async fn delete_notification(&self, notification_id: &str, pat: &str) -> ServiceResponse {
let req = test::TestRequest::delete()
.uri(&format!("/v2/notification/{notification_id}"))
.append_header(("Authorization", pat))
.to_request();
self.call(req).await
}
}

3
tests/common/asserts.rs Normal file
View File

@@ -0,0 +1,3 @@
pub fn assert_status(response: actix_web::dev::ServiceResponse, status: actix_http::StatusCode) {
assert_eq!(response.status(), status, "{:#?}", response.response());
}

View File

@@ -3,16 +3,15 @@ use labrinth::{models::projects::Project, models::projects::Version};
use serde_json::json;
use sqlx::Executor;
use crate::common::{
actix::AppendsMultipart,
database::{MOD_USER_PAT, USER_USER_PAT},
};
use crate::common::{actix::AppendsMultipart, database::USER_USER_PAT};
use super::{
actix::{MultipartSegment, MultipartSegmentData},
environment::TestEnvironment,
request_data::get_public_project_creation_data,
};
#[allow(dead_code)]
pub const DUMMY_CATEGORIES: &'static [&str] = &[
"combat",
"decoration",
@@ -23,6 +22,14 @@ pub const DUMMY_CATEGORIES: &'static [&str] = &[
"optimization",
];
#[allow(dead_code)]
pub enum DummyJarFile {
DummyProjectAlpha,
DummyProjectBeta,
BasicMod,
BasicModDifferent,
}
pub struct DummyData {
pub alpha_team_id: String,
pub beta_team_id: String,
@@ -75,94 +82,19 @@ pub async fn add_dummy_data(test_env: &TestEnvironment) -> DummyData {
}
pub async fn add_project_alpha(test_env: &TestEnvironment) -> (Project, Version) {
// Adds dummy data to the database with sqlx (projects, versions, threads)
// Generate test project data.
let json_data = json!(
{
"title": "Test Project Alpha",
"slug": "alpha",
"description": "A dummy project for testing with.",
"body": "This project is approved, and versions are listed.",
"client_side": "required",
"server_side": "optional",
"initial_versions": [{
"file_parts": ["dummy-project-alpha.jar"],
"version_number": "1.2.3",
"version_title": "start",
"dependencies": [],
"game_versions": ["1.20.1"] ,
"release_channel": "release",
"loaders": ["fabric"],
"featured": true
}],
"categories": [],
"license_id": "MIT"
}
);
// Basic json
let json_segment = MultipartSegment {
name: "data".to_string(),
filename: None,
content_type: Some("application/json".to_string()),
data: MultipartSegmentData::Text(serde_json::to_string(&json_data).unwrap()),
};
// Basic file
let file_segment = MultipartSegment {
name: "dummy-project-alpha.jar".to_string(),
filename: Some("dummy-project-alpha.jar".to_string()),
content_type: Some("application/java-archive".to_string()),
data: MultipartSegmentData::Binary(
include_bytes!("../../tests/files/dummy-project-alpha.jar").to_vec(),
),
};
// Add a project.
let req = TestRequest::post()
.uri("/v2/project")
.append_header(("Authorization", USER_USER_PAT))
.set_multipart(vec![json_segment.clone(), file_segment.clone()])
.to_request();
let resp = test_env.call(req).await;
assert_eq!(resp.status(), 200);
// Approve as a moderator.
let req = TestRequest::patch()
.uri("/v2/project/alpha")
.append_header(("Authorization", MOD_USER_PAT))
.set_json(json!(
{
"status": "approved"
}
test_env
.v2
.add_public_project(get_public_project_creation_data(
"alpha",
DummyJarFile::DummyProjectAlpha,
))
.to_request();
let resp = test_env.call(req).await;
assert_eq!(resp.status(), 204);
// Get project
let req = TestRequest::get()
.uri("/v2/project/alpha")
.append_header(("Authorization", USER_USER_PAT))
.to_request();
let resp = test_env.call(req).await;
let project: Project = test::read_body_json(resp).await;
// Get project's versions
let req = TestRequest::get()
.uri("/v2/project/alpha/version")
.append_header(("Authorization", USER_USER_PAT))
.to_request();
let resp = test_env.call(req).await;
let versions: Vec<Version> = test::read_body_json(resp).await;
let version = versions.into_iter().next().unwrap();
(project, version)
.await
}
pub async fn add_project_beta(test_env: &TestEnvironment) -> (Project, Version) {
// Adds dummy data to the database with sqlx (projects, versions, threads)
// Generate test project data.
let jar = DummyJarFile::DummyProjectBeta;
let json_data = json!(
{
"title": "Test Project Beta",
@@ -172,7 +104,7 @@ pub async fn add_project_beta(test_env: &TestEnvironment) -> (Project, Version)
"client_side": "required",
"server_side": "optional",
"initial_versions": [{
"file_parts": ["dummy-project-beta.jar"],
"file_parts": [jar.filename()],
"version_number": "1.2.3",
"version_title": "start",
"status": "unlisted",
@@ -200,12 +132,10 @@ pub async fn add_project_beta(test_env: &TestEnvironment) -> (Project, Version)
// Basic file
let file_segment = MultipartSegment {
name: "dummy-project-beta.jar".to_string(),
filename: Some("dummy-project-beta.jar".to_string()),
name: jar.filename(),
filename: Some(jar.filename()),
content_type: Some("application/java-archive".to_string()),
data: MultipartSegmentData::Binary(
include_bytes!("../../tests/files/dummy-project-beta.jar").to_vec(),
),
data: MultipartSegmentData::Binary(jar.bytes()),
};
// Add a project.
@@ -237,3 +167,30 @@ pub async fn add_project_beta(test_env: &TestEnvironment) -> (Project, Version)
(project, version)
}
impl DummyJarFile {
pub fn filename(&self) -> String {
match self {
DummyJarFile::DummyProjectAlpha => "dummy-project-alpha.jar",
DummyJarFile::DummyProjectBeta => "dummy-project-beta.jar",
DummyJarFile::BasicMod => "basic-mod.jar",
DummyJarFile::BasicModDifferent => "basic-mod-different.jar",
}
.to_string()
}
pub fn bytes(&self) -> Vec<u8> {
match self {
DummyJarFile::DummyProjectAlpha => {
include_bytes!("../../tests/files/dummy-project-alpha.jar").to_vec()
}
DummyJarFile::DummyProjectBeta => {
include_bytes!("../../tests/files/dummy-project-beta.jar").to_vec()
}
DummyJarFile::BasicMod => include_bytes!("../../tests/files/basic-mod.jar").to_vec(),
DummyJarFile::BasicModDifferent => {
include_bytes!("../../tests/files/basic-mod-different.jar").to_vec()
}
}
}
}

View File

@@ -1,7 +1,15 @@
#![allow(dead_code)]
use super::{database::TemporaryDatabase, dummy_data};
use std::rc::Rc;
use super::{
api_v2::ApiV2,
asserts::assert_status,
database::{TemporaryDatabase, FRIEND_USER_ID, USER_USER_PAT},
dummy_data,
};
use crate::common::setup;
use actix_http::StatusCode;
use actix_web::{dev::ServiceResponse, test, App};
use futures::Future;
@@ -22,8 +30,9 @@ where
// temporary sqlx db like #[sqlx::test] would.
// Use .call(req) on it directly to make a test call as if test::call_service(req) were being used.
pub struct TestEnvironment {
test_app: Box<dyn LocalService>,
test_app: Rc<Box<dyn LocalService>>,
pub db: TemporaryDatabase,
pub v2: ApiV2,
pub dummy: Option<dummy_data::DummyData>,
}
@@ -40,9 +49,12 @@ impl TestEnvironment {
let db = TemporaryDatabase::create().await;
let labrinth_config = setup(&db).await;
let app = App::new().configure(|cfg| labrinth::app_config(cfg, labrinth_config.clone()));
let test_app = test::init_service(app).await;
let test_app: Rc<Box<dyn LocalService>> = Rc::new(Box::new(test::init_service(app).await));
Self {
test_app: Box::new(test_app),
v2: ApiV2 {
test_app: test_app.clone(),
},
test_app,
db,
dummy: None,
}
@@ -54,9 +66,21 @@ impl TestEnvironment {
pub async fn call(&self, req: actix_http::Request) -> ServiceResponse {
self.test_app.call(req).await.unwrap()
}
pub async fn generate_friend_user_notification(&self) {
let resp = self
.v2
.add_user_to_team(
&self.dummy.as_ref().unwrap().alpha_team_id,
FRIEND_USER_ID,
USER_USER_PAT,
)
.await;
assert_status(resp, StatusCode::NO_CONTENT);
}
}
trait LocalService {
pub trait LocalService {
fn call(
&self,
req: actix_http::Request,

View File

@@ -5,10 +5,13 @@ use std::sync::Arc;
use self::database::TemporaryDatabase;
pub mod actix;
pub mod api_v2;
pub mod asserts;
pub mod database;
pub mod dummy_data;
pub mod environment;
pub mod pats;
pub mod request_data;
pub mod scopes;
// Testing equivalent to 'setup' function, producing a LabrinthConfig

View File

@@ -0,0 +1,60 @@
use serde_json::json;
use super::{actix::MultipartSegment, dummy_data::DummyJarFile};
use crate::common::actix::MultipartSegmentData;
pub struct ProjectCreationRequestData {
pub slug: String,
pub jar: DummyJarFile,
pub segment_data: Vec<MultipartSegment>,
}
pub fn get_public_project_creation_data(
slug: &str,
jar: DummyJarFile,
) -> ProjectCreationRequestData {
let json_data = json!(
{
"title": format!("Test Project {slug}"),
"slug": slug,
"description": "A dummy project for testing with.",
"body": "This project is approved, and versions are listed.",
"client_side": "required",
"server_side": "optional",
"initial_versions": [{
"file_parts": [jar.filename()],
"version_number": "1.2.3",
"version_title": "start",
"dependencies": [],
"game_versions": ["1.20.1"] ,
"release_channel": "release",
"loaders": ["fabric"],
"featured": true
}],
"categories": [],
"license_id": "MIT"
}
);
// Basic json
let json_segment = MultipartSegment {
name: "data".to_string(),
filename: None,
content_type: Some("application/json".to_string()),
data: MultipartSegmentData::Text(serde_json::to_string(&json_data).unwrap()),
};
// Basic file
let file_segment = MultipartSegment {
name: jar.filename(),
filename: Some(jar.filename()),
content_type: Some("application/java-archive".to_string()),
data: MultipartSegmentData::Binary(jar.bytes()),
};
ProjectCreationRequestData {
slug: slug.to_string(),
jar,
segment_data: vec![json_segment.clone(), file_segment.clone()],
}
}

View File

@@ -83,9 +83,10 @@ impl<'a> ScopeTest<'a> {
if resp.status().as_u16() != self.expected_failure_code {
return Err(format!(
"Expected failure code {}, got {}",
"Expected failure code {}, got {} ({:#?})",
self.expected_failure_code,
resp.status().as_u16()
resp.status().as_u16(),
resp.response()
));
}
@@ -106,8 +107,9 @@ impl<'a> ScopeTest<'a> {
if !(resp.status().is_success() || resp.status().is_redirection()) {
return Err(format!(
"Expected success code, got {}",
resp.status().as_u16()
"Expected success code, got {} ({:#?})",
resp.status().as_u16(),
resp.response()
));
}

70
tests/notifications.rs Normal file
View File

@@ -0,0 +1,70 @@
use common::{
database::{FRIEND_USER_ID, FRIEND_USER_PAT, USER_USER_PAT},
environment::with_test_environment,
};
mod common;
#[actix_rt::test]
pub async fn get_user_notifications_after_team_invitation_returns_notification() {
with_test_environment(|test_env| async move {
let alpha_team_id = test_env.dummy.as_ref().unwrap().alpha_team_id.clone();
let api = test_env.v2;
api.get_user_notifications_deserialized(FRIEND_USER_ID, FRIEND_USER_PAT)
.await;
api.add_user_to_team(&alpha_team_id, FRIEND_USER_ID, USER_USER_PAT)
.await;
let notifications = api
.get_user_notifications_deserialized(FRIEND_USER_ID, FRIEND_USER_PAT)
.await;
assert_eq!(1, notifications.len());
})
.await;
}
#[actix_rt::test]
pub async fn get_user_notifications_after_reading_indicates_notification_read() {
with_test_environment(|test_env| async move {
test_env.generate_friend_user_notification().await;
let api = test_env.v2;
let notifications = api
.get_user_notifications_deserialized(FRIEND_USER_ID, FRIEND_USER_PAT)
.await;
assert_eq!(1, notifications.len());
let notification_id = notifications[0].id.to_string();
api.mark_notification_read(&notification_id, FRIEND_USER_PAT)
.await;
let notifications = api
.get_user_notifications_deserialized(FRIEND_USER_ID, FRIEND_USER_PAT)
.await;
assert_eq!(1, notifications.len());
assert!(notifications[0].read);
})
.await;
}
#[actix_rt::test]
pub async fn get_user_notifications_after_deleting_does_not_show_notification() {
with_test_environment(|test_env| async move {
test_env.generate_friend_user_notification().await;
let api = test_env.v2;
let notifications = api
.get_user_notifications_deserialized(FRIEND_USER_ID, FRIEND_USER_PAT)
.await;
assert_eq!(1, notifications.len());
let notification_id = notifications[0].id.to_string();
api.delete_notification(&notification_id, FRIEND_USER_PAT)
.await;
let notifications = api
.get_user_notifications_deserialized(FRIEND_USER_ID, FRIEND_USER_PAT)
.await;
assert_eq!(0, notifications.len());
})
.await;
}

View File

@@ -269,11 +269,7 @@ async fn test_add_remove_project() {
let id = body["id"].to_string();
// Remove the project
let req = test::TestRequest::delete()
.uri("/v2/project/demo")
.append_header(("Authorization", USER_USER_PAT))
.to_request();
let resp = test_env.call(req).await;
let resp = test_env.v2.remove_project("demo", USER_USER_PAT).await;
assert_eq!(resp.status(), 204);
// Confirm that the project is gone from the cache

View File

@@ -92,14 +92,10 @@ pub async fn notifications_scopes() {
// We will invite user 'friend' to project team, and use that as a notification
// Get notifications
let req = TestRequest::post()
.uri(&format!("/v2/team/{alpha_team_id}/members"))
.append_header(("Authorization", USER_USER_PAT))
.set_json(json!( {
"user_id": FRIEND_USER_ID // friend
}))
.to_request();
let resp = test_env.call(req).await;
let resp = test_env
.v2
.add_user_to_team(alpha_team_id, FRIEND_USER_ID, USER_USER_PAT)
.await;
assert_eq!(resp.status(), 204);
// Notification get
@@ -164,14 +160,10 @@ pub async fn notifications_scopes() {
// Mass notification delete
// We invite mod, get the notification ID, and do mass delete using that
let req = test::TestRequest::post()
.uri(&format!("/v2/team/{alpha_team_id}/members"))
.append_header(("Authorization", USER_USER_PAT))
.set_json(json!( {
"user_id": MOD_USER_ID // mod
}))
.to_request();
let resp = test_env.call(req).await;
let resp = test_env
.v2
.add_user_to_team(alpha_team_id, MOD_USER_ID, USER_USER_PAT)
.await;
assert_eq!(resp.status(), 204);
let read_notifications = Scopes::NOTIFICATION_READ;
let req_gen = || test::TestRequest::get().uri(&format!("/v2/user/{MOD_USER_ID}/notifications"));

102
tests/user.rs Normal file
View File

@@ -0,0 +1,102 @@
use common::{
database::{FRIEND_USER_ID, FRIEND_USER_PAT, USER_USER_ID, USER_USER_PAT},
environment::with_test_environment,
};
use crate::common::{dummy_data::DummyJarFile, request_data::get_public_project_creation_data};
mod common;
#[actix_rt::test]
pub async fn get_user_projects_after_creating_project_returns_new_project() {
with_test_environment(|test_env| async move {
let api = test_env.v2;
api.get_user_projects_deserialized(USER_USER_ID, USER_USER_PAT)
.await;
let (project, _) = api
.add_public_project(get_public_project_creation_data(
"slug",
DummyJarFile::BasicMod,
))
.await;
let resp_projects = api
.get_user_projects_deserialized(USER_USER_ID, USER_USER_PAT)
.await;
assert!(resp_projects.iter().any(|p| p.id == project.id));
})
.await;
}
#[actix_rt::test]
pub async fn get_user_projects_after_deleting_project_shows_removal() {
with_test_environment(|test_env| async move {
let api = test_env.v2;
let (project, _) = api
.add_public_project(get_public_project_creation_data(
"iota",
DummyJarFile::BasicMod,
))
.await;
api.get_user_projects_deserialized(USER_USER_ID, USER_USER_PAT)
.await;
api.remove_project(&project.slug.as_ref().unwrap(), USER_USER_PAT)
.await;
let resp_projects = api
.get_user_projects_deserialized(USER_USER_ID, USER_USER_PAT)
.await;
assert!(!resp_projects.iter().any(|p| p.id == project.id));
})
.await;
}
#[actix_rt::test]
pub async fn get_user_projects_after_joining_team_shows_team_projects() {
with_test_environment(|test_env| async move {
let alpha_team_id = &test_env.dummy.as_ref().unwrap().alpha_team_id;
let alpha_project_id = &test_env.dummy.as_ref().unwrap().alpha_project_id;
let api = test_env.v2;
api.get_user_projects_deserialized(FRIEND_USER_ID, FRIEND_USER_PAT)
.await;
api.add_user_to_team(alpha_team_id, FRIEND_USER_ID, USER_USER_PAT)
.await;
api.join_team(&alpha_team_id, FRIEND_USER_PAT).await;
let projects = api
.get_user_projects_deserialized(FRIEND_USER_ID, FRIEND_USER_PAT)
.await;
assert!(projects
.iter()
.any(|p| p.id.to_string() == *alpha_project_id));
})
.await;
}
#[actix_rt::test]
pub async fn get_user_projects_after_leaving_team_shows_no_team_projects() {
with_test_environment(|test_env| async move {
let alpha_team_id = &test_env.dummy.as_ref().unwrap().alpha_team_id;
let alpha_project_id = &test_env.dummy.as_ref().unwrap().alpha_project_id;
let api = test_env.v2;
api.add_user_to_team(alpha_team_id, FRIEND_USER_ID, USER_USER_PAT)
.await;
api.join_team(&alpha_team_id, FRIEND_USER_PAT).await;
api.get_user_projects_deserialized(FRIEND_USER_ID, FRIEND_USER_PAT)
.await;
api.remove_from_team(&alpha_team_id, FRIEND_USER_ID, USER_USER_PAT)
.await;
let projects = api
.get_user_projects_deserialized(FRIEND_USER_ID, FRIEND_USER_PAT)
.await;
assert!(!projects
.iter()
.any(|p| p.id.to_string() == *alpha_project_id));
})
.await;
}