366 lines
11 KiB
Rust
366 lines
11 KiB
Rust
use serde::{Deserialize, Serialize};
|
|
use sqlx::Row;
|
|
|
|
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum ContentFavoriteProvider {
|
|
Modrinth,
|
|
Curseforge,
|
|
Mcarchive,
|
|
}
|
|
|
|
impl ContentFavoriteProvider {
|
|
pub const fn as_str(self) -> &'static str {
|
|
match self {
|
|
Self::Modrinth => "modrinth",
|
|
Self::Curseforge => "curseforge",
|
|
Self::Mcarchive => "mcarchive",
|
|
}
|
|
}
|
|
|
|
fn parse(value: &str) -> crate::Result<Self> {
|
|
match value {
|
|
"modrinth" => Ok(Self::Modrinth),
|
|
"curseforge" => Ok(Self::Curseforge),
|
|
"mcarchive" => Ok(Self::Mcarchive),
|
|
_ => Err(crate::ErrorKind::InputError(format!(
|
|
"Unsupported content favorite provider: {value}"
|
|
))
|
|
.into()),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum ContentFavoriteType {
|
|
Mod,
|
|
Resourcepack,
|
|
Datapack,
|
|
Shader,
|
|
}
|
|
|
|
impl ContentFavoriteType {
|
|
pub const fn as_str(self) -> &'static str {
|
|
match self {
|
|
Self::Mod => "mod",
|
|
Self::Resourcepack => "resourcepack",
|
|
Self::Datapack => "datapack",
|
|
Self::Shader => "shader",
|
|
}
|
|
}
|
|
|
|
fn parse(value: &str) -> crate::Result<Self> {
|
|
match value {
|
|
"mod" => Ok(Self::Mod),
|
|
"resourcepack" => Ok(Self::Resourcepack),
|
|
"datapack" => Ok(Self::Datapack),
|
|
"shader" => Ok(Self::Shader),
|
|
_ => Err(crate::ErrorKind::InputError(format!(
|
|
"Unsupported content favorite type: {value}"
|
|
))
|
|
.into()),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
|
pub struct ContentFavorite {
|
|
pub provider: ContentFavoriteProvider,
|
|
pub project_id: String,
|
|
pub content_type: ContentFavoriteType,
|
|
pub saved_at: i64,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
|
pub struct ContentFavoriteInput {
|
|
pub provider: ContentFavoriteProvider,
|
|
pub project_id: String,
|
|
pub content_type: ContentFavoriteType,
|
|
}
|
|
|
|
impl ContentFavoriteInput {
|
|
fn validate(&self) -> crate::Result<()> {
|
|
if self.project_id.trim().is_empty() {
|
|
return Err(crate::ErrorKind::InputError(
|
|
"A content favorite must have a project ID".to_string(),
|
|
)
|
|
.into());
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
fn from_row(row: sqlx::sqlite::SqliteRow) -> crate::Result<ContentFavorite> {
|
|
let provider: String = row.try_get("provider")?;
|
|
let content_type: String = row.try_get("content_type")?;
|
|
Ok(ContentFavorite {
|
|
provider: ContentFavoriteProvider::parse(&provider)?,
|
|
project_id: row.try_get("project_id")?,
|
|
content_type: ContentFavoriteType::parse(&content_type)?,
|
|
saved_at: row.try_get("saved_at")?,
|
|
})
|
|
}
|
|
|
|
pub async fn list(
|
|
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite>,
|
|
) -> crate::Result<Vec<ContentFavorite>> {
|
|
let rows = sqlx::query(
|
|
"
|
|
SELECT provider, project_id, content_type, saved_at
|
|
FROM content_favorites
|
|
ORDER BY saved_at DESC, provider ASC, project_id ASC
|
|
",
|
|
)
|
|
.fetch_all(exec)
|
|
.await?;
|
|
|
|
rows.into_iter().map(from_row).collect()
|
|
}
|
|
|
|
pub async fn add(
|
|
favorite: ContentFavoriteInput,
|
|
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite> + Copy,
|
|
) -> crate::Result<ContentFavorite> {
|
|
favorite.validate()?;
|
|
let project_id = favorite.project_id.trim();
|
|
let provider = favorite.provider.as_str();
|
|
let content_type = favorite.content_type.as_str();
|
|
|
|
sqlx::query(
|
|
"
|
|
INSERT INTO content_favorites (provider, project_id, content_type, saved_at)
|
|
VALUES (?, ?, ?, CAST((julianday('now') - 2440587.5) * 86400000 AS INTEGER))
|
|
ON CONFLICT (provider, project_id) DO UPDATE SET
|
|
content_type = excluded.content_type
|
|
",
|
|
)
|
|
.bind(provider)
|
|
.bind(project_id)
|
|
.bind(content_type)
|
|
.execute(exec)
|
|
.await?;
|
|
|
|
let row = sqlx::query(
|
|
"
|
|
SELECT provider, project_id, content_type, saved_at
|
|
FROM content_favorites
|
|
WHERE provider = ? AND project_id = ?
|
|
",
|
|
)
|
|
.bind(provider)
|
|
.bind(project_id)
|
|
.fetch_one(exec)
|
|
.await?;
|
|
|
|
from_row(row)
|
|
}
|
|
|
|
pub async fn remove(
|
|
provider: ContentFavoriteProvider,
|
|
project_id: &str,
|
|
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite>,
|
|
) -> crate::Result<()> {
|
|
if project_id.trim().is_empty() {
|
|
return Err(crate::ErrorKind::InputError(
|
|
"A content favorite must have a project ID".to_string(),
|
|
)
|
|
.into());
|
|
}
|
|
|
|
sqlx::query(
|
|
"DELETE FROM content_favorites WHERE provider = ? AND project_id = ?",
|
|
)
|
|
.bind(provider.as_str())
|
|
.bind(project_id.trim())
|
|
.execute(exec)
|
|
.await?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::{
|
|
ContentFavoriteInput, ContentFavoriteProvider, ContentFavoriteType,
|
|
add, list, remove,
|
|
};
|
|
use sqlx::sqlite::SqlitePoolOptions;
|
|
use std::time::Duration;
|
|
|
|
async fn pool() -> sqlx::SqlitePool {
|
|
let pool = SqlitePoolOptions::new()
|
|
.max_connections(1)
|
|
.connect("sqlite::memory:")
|
|
.await
|
|
.expect("in-memory database");
|
|
sqlx::migrate!().run(&pool).await.expect("migrations");
|
|
pool
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn content_favorites_migration_preserves_existing_database_rows() {
|
|
let pool = SqlitePoolOptions::new()
|
|
.max_connections(1)
|
|
.connect("sqlite::memory:")
|
|
.await
|
|
.expect("in-memory database");
|
|
sqlx::raw_sql(
|
|
"CREATE TABLE existing_content (id INTEGER PRIMARY KEY, marker TEXT NOT NULL); INSERT INTO existing_content (marker) VALUES ('keep');",
|
|
)
|
|
.execute(&pool)
|
|
.await
|
|
.expect("legacy database data");
|
|
|
|
sqlx::raw_sql(include_str!(
|
|
"../../migrations/20260820200000_content-favorites.sql"
|
|
))
|
|
.execute(&pool)
|
|
.await
|
|
.expect("apply content favorites migration");
|
|
|
|
let marker: String = sqlx::query_scalar(
|
|
"SELECT marker FROM existing_content WHERE id = 1",
|
|
)
|
|
.fetch_one(&pool)
|
|
.await
|
|
.expect("legacy row after migration");
|
|
let table_exists: i64 = sqlx::query_scalar(
|
|
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'content_favorites'",
|
|
)
|
|
.fetch_one(&pool)
|
|
.await
|
|
.expect("content favorites table query");
|
|
|
|
assert_eq!(marker, "keep");
|
|
assert_eq!(table_exists, 1);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn content_favorites_are_provider_qualified_and_idempotent() {
|
|
let pool = pool().await;
|
|
let modrinth = add(
|
|
ContentFavoriteInput {
|
|
provider: ContentFavoriteProvider::Modrinth,
|
|
project_id: "same-id".to_string(),
|
|
content_type: ContentFavoriteType::Mod,
|
|
},
|
|
&pool,
|
|
)
|
|
.await
|
|
.expect("add Modrinth favorite");
|
|
let duplicate = add(
|
|
ContentFavoriteInput {
|
|
provider: ContentFavoriteProvider::Modrinth,
|
|
project_id: "same-id".to_string(),
|
|
content_type: ContentFavoriteType::Mod,
|
|
},
|
|
&pool,
|
|
)
|
|
.await
|
|
.expect("add duplicate favorite");
|
|
add(
|
|
ContentFavoriteInput {
|
|
provider: ContentFavoriteProvider::Curseforge,
|
|
project_id: "same-id".to_string(),
|
|
content_type: ContentFavoriteType::Shader,
|
|
},
|
|
&pool,
|
|
)
|
|
.await
|
|
.expect("add CurseForge favorite");
|
|
|
|
let favorites = list(&pool).await.expect("list favorites");
|
|
assert_eq!(favorites.len(), 2);
|
|
assert_eq!(duplicate.saved_at, modrinth.saved_at);
|
|
assert!(favorites.iter().any(|favorite| {
|
|
favorite.provider == ContentFavoriteProvider::Modrinth
|
|
&& favorite.project_id == "same-id"
|
|
}));
|
|
assert!(favorites.iter().any(|favorite| {
|
|
favorite.provider == ContentFavoriteProvider::Curseforge
|
|
&& favorite.project_id == "same-id"
|
|
}));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn removing_a_content_favorite_is_idempotent() {
|
|
let pool = pool().await;
|
|
remove(ContentFavoriteProvider::Modrinth, "missing", &pool)
|
|
.await
|
|
.expect("remove missing favorite");
|
|
assert!(list(&pool).await.expect("list favorites").is_empty());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn content_favorites_reject_invalid_database_values_and_sort_by_saved_time()
|
|
{
|
|
let pool = pool().await;
|
|
sqlx::query(
|
|
"INSERT INTO content_favorites (provider, project_id, content_type, saved_at) VALUES ('modrinth', 'older', 'mod', 1)",
|
|
)
|
|
.execute(&pool)
|
|
.await
|
|
.expect("insert valid favorite");
|
|
sqlx::query(
|
|
"INSERT INTO content_favorites (provider, project_id, content_type, saved_at) VALUES ('curseforge', 'newer', 'shader', 2)",
|
|
)
|
|
.execute(&pool)
|
|
.await
|
|
.expect("insert valid favorite");
|
|
|
|
assert!(sqlx::query(
|
|
"INSERT INTO content_favorites (provider, project_id, content_type, saved_at) VALUES ('unknown', 'bad-provider', 'mod', 3)",
|
|
)
|
|
.execute(&pool)
|
|
.await
|
|
.is_err());
|
|
assert!(sqlx::query(
|
|
"INSERT INTO content_favorites (provider, project_id, content_type, saved_at) VALUES ('modrinth', 'bad-type', 'modpack', 3)",
|
|
)
|
|
.execute(&pool)
|
|
.await
|
|
.is_err());
|
|
|
|
let favorites = list(&pool).await.expect("list favorites");
|
|
assert_eq!(
|
|
favorites
|
|
.iter()
|
|
.map(|favorite| favorite.project_id.as_str())
|
|
.collect::<Vec<_>>(),
|
|
vec!["newer", "older"]
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn readding_after_remove_gets_a_new_saved_time() {
|
|
let pool = pool().await;
|
|
let first = add(
|
|
ContentFavoriteInput {
|
|
provider: ContentFavoriteProvider::Modrinth,
|
|
project_id: "sodium".to_string(),
|
|
content_type: ContentFavoriteType::Mod,
|
|
},
|
|
&pool,
|
|
)
|
|
.await
|
|
.expect("add favorite");
|
|
remove(ContentFavoriteProvider::Modrinth, "sodium", &pool)
|
|
.await
|
|
.expect("remove favorite");
|
|
tokio::time::sleep(Duration::from_millis(2)).await;
|
|
let second = add(
|
|
ContentFavoriteInput {
|
|
provider: ContentFavoriteProvider::Modrinth,
|
|
project_id: "sodium".to_string(),
|
|
content_type: ContentFavoriteType::Mod,
|
|
},
|
|
&pool,
|
|
)
|
|
.await
|
|
.expect("re-add favorite");
|
|
|
|
assert!(second.saved_at > first.saved_at);
|
|
}
|
|
}
|