New notifications table (migration 0009) stores event-driven alerts alongside the existing dynamic state notifications (unclaimed objectives, expiring loans, season ending soon). Persistent notifications are created automatically during match processing: one per level gained, one per expired loan card, one per objective newly completed, and one when a season ends (with promotion/relegation result and rewards in the body). GET /notifications now returns a merged list — persistent entries (newest-first, limit 50) followed by dynamic entries — plus an unread_count for the badge. Each item carries type, title, body, is_read, and (for persistent) id and created_at. PATCH /notifications/:id/read marks a single persistent notification read. POST /notifications/read-all marks all persistent ones read. Four new tests: unread_count field, level-up notification creation, mark-all-read, single-read PATCH. Core now at 77 tests. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,13 @@
|
|||||||
|
-- Persistent event-driven notifications (level-ups, loan expiry, season end, etc.)
|
||||||
|
-- Dynamic state notifications (expiring loans, unclaimed objectives) remain in code.
|
||||||
|
CREATE TABLE IF NOT EXISTS notifications (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
kind TEXT NOT NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
body TEXT NOT NULL,
|
||||||
|
is_read INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_notifications_is_read ON notifications (is_read);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_notifications_created ON notifications (created_at DESC);
|
||||||
+3
-1
@@ -18,7 +18,7 @@ use anyhow::Result;
|
|||||||
use axum::{
|
use axum::{
|
||||||
extract::DefaultBodyLimit,
|
extract::DefaultBodyLimit,
|
||||||
middleware,
|
middleware,
|
||||||
routing::{delete, get, post, put},
|
routing::{delete, get, patch, post, put},
|
||||||
Router,
|
Router,
|
||||||
};
|
};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
@@ -195,6 +195,8 @@ pub async fn build(pool: Pool, cfg: Config) -> Result<Router> {
|
|||||||
.route("/settings", put(routes::settings::put_settings))
|
.route("/settings", put(routes::settings::put_settings))
|
||||||
.route("/division", get(routes::division::get_division))
|
.route("/division", get(routes::division::get_division))
|
||||||
.route("/notifications", get(routes::notifications::get_notifications))
|
.route("/notifications", get(routes::notifications::get_notifications))
|
||||||
|
.route("/notifications/read-all", post(routes::notifications::mark_all_notifications_read))
|
||||||
|
.route("/notifications/:id/read", patch(routes::notifications::mark_notification_read))
|
||||||
.route("/fut-champs", get(routes::fut_champs::get_fut_champs))
|
.route("/fut-champs", get(routes::fut_champs::get_fut_champs))
|
||||||
.route("/fut-champs/start", post(routes::fut_champs::post_start_fut_champs))
|
.route("/fut-champs/start", post(routes::fut_champs::post_start_fut_champs))
|
||||||
.route("/fut-champs/history", get(routes::fut_champs::get_champs_history))
|
.route("/fut-champs/history", get(routes::fut_champs::get_champs_history))
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
pub mod card;
|
pub mod card;
|
||||||
pub mod chemistry_style;
|
pub mod chemistry_style;
|
||||||
|
pub mod notification;
|
||||||
pub mod club;
|
pub mod club;
|
||||||
pub mod draft;
|
pub mod draft;
|
||||||
pub mod fut_champs;
|
pub mod fut_champs;
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
use serde::Serialize;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, sqlx::FromRow)]
|
||||||
|
pub struct Notification {
|
||||||
|
pub id: String,
|
||||||
|
pub kind: String,
|
||||||
|
pub title: String,
|
||||||
|
pub body: String,
|
||||||
|
pub is_read: bool,
|
||||||
|
pub created_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Notification {
|
||||||
|
pub fn new(kind: &str, title: &str, body: &str) -> Self {
|
||||||
|
Self {
|
||||||
|
id: Uuid::new_v4().to_string(),
|
||||||
|
kind: kind.to_string(),
|
||||||
|
title: title.to_string(),
|
||||||
|
body: body.to_string(),
|
||||||
|
is_read: false,
|
||||||
|
created_at: chrono::Utc::now().to_rfc3339(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+84
-43
@@ -1,73 +1,75 @@
|
|||||||
use axum::{extract::State, Json};
|
use axum::{
|
||||||
|
extract::{Path, State},
|
||||||
|
Json,
|
||||||
|
};
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
app::AppState,
|
app::AppState,
|
||||||
error::AppResult,
|
error::{AppError, AppResult},
|
||||||
services::{club as club_svc, objective as obj_svc, profile as profile_svc},
|
services::{club as club_svc, notification as notif_svc, objective as obj_svc, profile as profile_svc},
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Returns actionable notifications for the current profile.
|
/// GET /notifications
|
||||||
///
|
///
|
||||||
/// Built dynamically from DB state — no separate notifications table.
|
/// Returns persistent (event-driven) notifications merged with dynamic state
|
||||||
/// Covers:
|
/// notifications (unclaimed objectives, expiring loans, season ending soon).
|
||||||
/// - Completed objectives whose reward hasn't been claimed yet
|
/// Persistent notifications carry an `id` and `is_read` flag; dynamic ones
|
||||||
/// - Loan cards that will expire within 2 matches
|
/// have `id: null` and are always considered unread.
|
||||||
/// - Season ending soon (≤2 matches remaining)
|
|
||||||
pub async fn get_notifications(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
pub async fn get_notifications(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||||
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||||
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
||||||
let mut notifications: Vec<Value> = Vec::new();
|
|
||||||
|
|
||||||
// 1. Completed but unclaimed objectives
|
// ── Persistent notifications ─────────────────────────────────────────────
|
||||||
|
let persistent = notif_svc::list(&state.pool).await?;
|
||||||
|
let persistent_unread = persistent.iter().filter(|n| !n.is_read).count() as i64;
|
||||||
|
|
||||||
|
// ── Dynamic: unclaimed objective rewards ─────────────────────────────────
|
||||||
let objectives =
|
let objectives =
|
||||||
obj_svc::get_objectives_with_progress(&state.pool, &profile.id, &state.obj_defs).await?;
|
obj_svc::get_objectives_with_progress(&state.pool, &profile.id, &state.obj_defs).await?;
|
||||||
for obj in &objectives {
|
let mut dynamic: Vec<Value> = objectives
|
||||||
if obj.completed && !obj.claimed {
|
.iter()
|
||||||
notifications.push(json!({
|
.filter(|o| o.completed && !o.claimed)
|
||||||
|
.map(|o| json!({
|
||||||
|
"id": null,
|
||||||
"type": "objective_complete",
|
"type": "objective_complete",
|
||||||
"objective_id": obj.definition.id,
|
"title": format!("Objective complete: {}", o.definition.title),
|
||||||
"title": format!("Objective complete: {}", obj.definition.title),
|
"body": format!("Claim your reward for \"{}\" (+{} coins)", o.definition.title, o.definition.reward_coins),
|
||||||
"message": format!("Claim your reward for \"{}\"", obj.definition.title),
|
"is_read": false,
|
||||||
"reward_coins": obj.definition.reward_coins,
|
"created_at": null,
|
||||||
"reward_pack": obj.definition.reward_pack_id,
|
}))
|
||||||
}));
|
.collect();
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Loan cards expiring within 2 matches
|
// ── Dynamic: loan cards expiring within 2 matches ────────────────────────
|
||||||
let expiring_loans: Vec<(String, String, Option<i64>)> = sqlx::query_as(
|
let expiring: Vec<(String, String, Option<i64>)> = sqlx::query_as(
|
||||||
"SELECT oc.id, oc.card_id, oc.loan_matches_remaining \
|
"SELECT oc.id, oc.card_id, oc.loan_matches_remaining \
|
||||||
FROM owned_cards oc WHERE oc.club_id = ? AND oc.is_loan = 1 \
|
FROM owned_cards oc WHERE oc.club_id = ? AND oc.is_loan = 1 \
|
||||||
AND oc.loan_matches_remaining <= 2",
|
AND oc.loan_matches_remaining IS NOT NULL AND oc.loan_matches_remaining <= 2",
|
||||||
)
|
)
|
||||||
.bind(&club.id)
|
.bind(&club.id)
|
||||||
.fetch_all(&state.pool)
|
.fetch_all(&state.pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
for (owned_id, card_id, remaining) in expiring_loans {
|
for (owned_id, card_id, remaining) in expiring {
|
||||||
let card_name = state
|
let card_name = state
|
||||||
.card_db
|
.card_db
|
||||||
.get(&card_id)
|
.get(&card_id)
|
||||||
.map(|c| c.name.clone())
|
.map(|c| c.name.clone())
|
||||||
.unwrap_or_else(|| card_id.clone());
|
.unwrap_or_else(|| card_id.clone());
|
||||||
notifications.push(json!({
|
dynamic.push(json!({
|
||||||
|
"id": null,
|
||||||
"type": "loan_expiring",
|
"type": "loan_expiring",
|
||||||
"owned_card_id": owned_id,
|
|
||||||
"card_id": card_id,
|
|
||||||
"title": format!("Loan expiring: {card_name}"),
|
"title": format!("Loan expiring: {card_name}"),
|
||||||
"message": format!(
|
"body": format!("{card_name} has {} match(es) remaining on loan.", remaining.unwrap_or(0)),
|
||||||
"{card_name} has {} match(es) remaining on loan",
|
"is_read": false,
|
||||||
remaining.unwrap_or(0)
|
"created_at": null,
|
||||||
),
|
"owned_card_id": owned_id,
|
||||||
"matches_remaining": remaining,
|
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Season ending soon (≤2 matches remaining)
|
// ── Dynamic: season ending soon ──────────────────────────────────────────
|
||||||
let season_row: Option<(i64, i64)> = sqlx::query_as(
|
let season_row: Option<(i64, i64)> =
|
||||||
"SELECT matches_played, division FROM seasons WHERE profile_id = ?",
|
sqlx::query_as("SELECT matches_played, division FROM seasons WHERE profile_id = ?")
|
||||||
)
|
|
||||||
.bind(&profile.id)
|
.bind(&profile.id)
|
||||||
.fetch_optional(&state.pool)
|
.fetch_optional(&state.pool)
|
||||||
.await?;
|
.await?;
|
||||||
@@ -75,17 +77,56 @@ pub async fn get_notifications(State(state): State<AppState>) -> AppResult<Json<
|
|||||||
if let Some((played, division)) = season_row {
|
if let Some((played, division)) = season_row {
|
||||||
let remaining = (10 - played).max(0);
|
let remaining = (10 - played).max(0);
|
||||||
if remaining <= 2 && remaining > 0 {
|
if remaining <= 2 && remaining > 0 {
|
||||||
notifications.push(json!({
|
dynamic.push(json!({
|
||||||
|
"id": null,
|
||||||
"type": "season_ending",
|
"type": "season_ending",
|
||||||
"title": format!("Season ending — Division {division}"),
|
"title": format!("Season ending — Division {division}"),
|
||||||
"message": format!("{remaining} match(es) left in the season"),
|
"body": format!("{remaining} match(es) left in the current season."),
|
||||||
"matches_remaining": remaining,
|
"is_read": false,
|
||||||
|
"created_at": null,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Merge: persistent first (newest first), then dynamic ─────────────────
|
||||||
|
// Use "type" key for compatibility with dashboard and existing tests.
|
||||||
|
let all: Vec<Value> = persistent
|
||||||
|
.iter()
|
||||||
|
.map(|n| json!({
|
||||||
|
"id": n.id,
|
||||||
|
"type": n.kind,
|
||||||
|
"title": n.title,
|
||||||
|
"body": n.body,
|
||||||
|
"is_read": n.is_read,
|
||||||
|
"created_at": n.created_at,
|
||||||
|
}))
|
||||||
|
.chain(dynamic.iter().cloned())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let total_unread = persistent_unread + dynamic.len() as i64;
|
||||||
|
|
||||||
Ok(Json(json!({
|
Ok(Json(json!({
|
||||||
"notifications": notifications,
|
"notifications": all,
|
||||||
"count": notifications.len(),
|
"unread_count": total_unread,
|
||||||
})))
|
})))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// PATCH /notifications/:id/read
|
||||||
|
pub async fn mark_notification_read(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
) -> AppResult<Json<Value>> {
|
||||||
|
let found = notif_svc::mark_read(&state.pool, &id).await?;
|
||||||
|
if !found {
|
||||||
|
return Err(AppError::NotFound(format!("notification '{id}' not found")));
|
||||||
|
}
|
||||||
|
Ok(Json(json!({ "marked_read": id })))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// POST /notifications/read-all
|
||||||
|
pub async fn mark_all_notifications_read(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
) -> AppResult<Json<Value>> {
|
||||||
|
let count = notif_svc::mark_all_read(&state.pool).await?;
|
||||||
|
Ok(Json(json!({ "marked_read": count })))
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ use crate::{
|
|||||||
match_result::{Match, MatchRewardResult, SubmitMatchRequest},
|
match_result::{Match, MatchRewardResult, SubmitMatchRequest},
|
||||||
objective::ObjectiveDefinition,
|
objective::ObjectiveDefinition,
|
||||||
},
|
},
|
||||||
services::{card_db::CardDb, club, objective, profile, season as season_svc, statistics},
|
services::{card_db::CardDb, club, notification, objective, profile, season as season_svc, statistics},
|
||||||
};
|
};
|
||||||
use rand::{seq::SliceRandom, Rng};
|
use rand::{seq::SliceRandom, Rng};
|
||||||
|
|
||||||
@@ -136,6 +136,16 @@ pub async fn process_match(
|
|||||||
|
|
||||||
club::add_coins(pool, club_id, coins).await?;
|
club::add_coins(pool, club_id, coins).await?;
|
||||||
let level_ups = profile::add_xp_with_levelup(pool, profile_id, club_id, xp).await?;
|
let level_ups = profile::add_xp_with_levelup(pool, profile_id, club_id, xp).await?;
|
||||||
|
|
||||||
|
for ev in &level_ups {
|
||||||
|
let body = if let Some(ref pack) = ev.pack_granted {
|
||||||
|
format!("You reached level {}! Reward: {} coins + {pack}.", ev.new_level, ev.coins_granted)
|
||||||
|
} else {
|
||||||
|
format!("You reached level {}! Reward: {} coins.", ev.new_level, ev.coins_granted)
|
||||||
|
};
|
||||||
|
let _ = notification::create(pool, "level_up", &format!("Level {}!", ev.new_level), &body).await;
|
||||||
|
}
|
||||||
|
|
||||||
statistics::record_match(
|
statistics::record_match(
|
||||||
pool,
|
pool,
|
||||||
profile_id,
|
profile_id,
|
||||||
@@ -171,13 +181,35 @@ pub async fn process_match(
|
|||||||
objective::increment_metric(pool, profile_id, obj_defs, "coinsearned", coins).await?;
|
objective::increment_metric(pool, profile_id, obj_defs, "coinsearned", coins).await?;
|
||||||
objectives_updated.append(&mut c);
|
objectives_updated.append(&mut c);
|
||||||
|
|
||||||
|
for obj_id in &objectives_updated {
|
||||||
|
let title = "Objective complete!";
|
||||||
|
let body = format!("\"{}\" is now complete. Claim your reward in Objectives.", obj_id);
|
||||||
|
let _ = notification::create(pool, "objective_complete", title, &body).await;
|
||||||
|
}
|
||||||
|
|
||||||
// Decrement loan matches remaining for squad starters; collect expired loan IDs.
|
// Decrement loan matches remaining for squad starters; collect expired loan IDs.
|
||||||
let expired_loans = process_loan_expiry(pool, club_id, &req.squad_id).await?;
|
let expired_loans = process_loan_expiry(pool, club_id, &req.squad_id).await?;
|
||||||
|
|
||||||
|
for owned_id in &expired_loans {
|
||||||
|
let body = format!("Loan card (id: {owned_id}) has expired and been removed from your club.");
|
||||||
|
let _ = notification::create(pool, "loan_expired", "Loan card expired", &body).await;
|
||||||
|
}
|
||||||
|
|
||||||
// Update season progress (creates the season row if it doesn't exist yet).
|
// Update season progress (creates the season row if it doesn't exist yet).
|
||||||
season_svc::get_or_create(pool, profile_id).await?;
|
season_svc::get_or_create(pool, profile_id).await?;
|
||||||
let (_, season_end) = season_svc::record_match(pool, club_id, profile_id, outcome).await?;
|
let (_, season_end) = season_svc::record_match(pool, club_id, profile_id, outcome).await?;
|
||||||
|
|
||||||
|
if let Some(ref se) = season_end {
|
||||||
|
use crate::models::season::SeasonResult;
|
||||||
|
let direction = match se.result {
|
||||||
|
SeasonResult::Promoted => "Promoted",
|
||||||
|
SeasonResult::Relegated => "Relegated",
|
||||||
|
SeasonResult::Maintained => "Maintained",
|
||||||
|
};
|
||||||
|
let body = format!("{direction} — now in Division {}. Rewards: {} coins{}.", se.new_division, se.coins_awarded, se.pack_awarded.as_deref().map(|p| format!(" + {p}")).unwrap_or_default());
|
||||||
|
let _ = notification::create(pool, "season_end", "Season complete!", &body).await;
|
||||||
|
}
|
||||||
|
|
||||||
Ok(MatchRewardResult {
|
Ok(MatchRewardResult {
|
||||||
match_record,
|
match_record,
|
||||||
coins_awarded: coins,
|
coins_awarded: coins,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
pub mod card_db;
|
pub mod card_db;
|
||||||
pub mod club;
|
pub mod club;
|
||||||
|
pub mod notification;
|
||||||
pub mod draft;
|
pub mod draft;
|
||||||
pub mod event;
|
pub mod event;
|
||||||
pub mod fut_champs;
|
pub mod fut_champs;
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
use crate::{db::Pool, error::AppResult, models::notification::Notification};
|
||||||
|
|
||||||
|
pub async fn create(pool: &Pool, kind: &str, title: &str, body: &str) -> AppResult<()> {
|
||||||
|
let n = Notification::new(kind, title, body);
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO notifications (id, kind, title, body, is_read, created_at) \
|
||||||
|
VALUES (?, ?, ?, ?, 0, ?)",
|
||||||
|
)
|
||||||
|
.bind(&n.id)
|
||||||
|
.bind(&n.kind)
|
||||||
|
.bind(&n.title)
|
||||||
|
.bind(&n.body)
|
||||||
|
.bind(&n.created_at)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list(pool: &Pool) -> AppResult<Vec<Notification>> {
|
||||||
|
sqlx::query_as::<_, Notification>(
|
||||||
|
"SELECT id, kind, title, body, is_read, created_at \
|
||||||
|
FROM notifications ORDER BY created_at DESC LIMIT 50",
|
||||||
|
)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await
|
||||||
|
.map_err(Into::into)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn mark_read(pool: &Pool, id: &str) -> AppResult<bool> {
|
||||||
|
let r = sqlx::query("UPDATE notifications SET is_read = 1 WHERE id = ?")
|
||||||
|
.bind(id)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(r.rows_affected() > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn mark_all_read(pool: &Pool) -> AppResult<i64> {
|
||||||
|
let r = sqlx::query("UPDATE notifications SET is_read = 1 WHERE is_read = 0")
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(r.rows_affected() as i64)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn unread_count(pool: &Pool) -> AppResult<i64> {
|
||||||
|
sqlx::query_scalar("SELECT COUNT(*) FROM notifications WHERE is_read = 0")
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await
|
||||||
|
.map_err(Into::into)
|
||||||
|
}
|
||||||
@@ -1513,3 +1513,83 @@ async fn test_level_up_grants_milestone_pack_at_level_5() {
|
|||||||
assert_eq!(pack_for_level(10), Some("silver_pack"));
|
assert_eq!(pack_for_level(10), Some("silver_pack"));
|
||||||
assert_eq!(pack_for_level(7), None);
|
assert_eq!(pack_for_level(7), None);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Phase 19: Persistent notifications ───────────────────────────────────────
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_notifications_have_unread_count() {
|
||||||
|
let app = build_test_app().await;
|
||||||
|
auth(&app, "NotifCountPlayer").await;
|
||||||
|
|
||||||
|
let (status, json) = json_get(&app, "/notifications").await;
|
||||||
|
assert_eq!(status, StatusCode::OK);
|
||||||
|
assert!(json["unread_count"].is_number(), "unread_count missing");
|
||||||
|
assert!(json["notifications"].is_array(), "notifications not an array");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_level_up_creates_persistent_notification() {
|
||||||
|
let app = build_test_app().await;
|
||||||
|
auth(&app, "LevelUpNotifPlayer").await;
|
||||||
|
|
||||||
|
// Play several wins to guarantee crossing the 500 XP threshold (level 2)
|
||||||
|
for _ in 0..5 {
|
||||||
|
json_post(&app, "/matches/result", serde_json::json!({
|
||||||
|
"squad_id": "dummy", "opponent_name": "Bot",
|
||||||
|
"goals_for": 3, "goals_against": 0, "mode": "squad_battles"
|
||||||
|
})).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
let (status, json) = json_get(&app, "/notifications").await;
|
||||||
|
assert_eq!(status, StatusCode::OK);
|
||||||
|
let notifs = json["notifications"].as_array().unwrap();
|
||||||
|
let has_level_up = notifs.iter().any(|n| n["type"] == "level_up");
|
||||||
|
assert!(has_level_up, "level_up notification not found after gaining levels");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_mark_all_notifications_read() {
|
||||||
|
let app = build_test_app().await;
|
||||||
|
auth(&app, "MarkReadPlayer").await;
|
||||||
|
|
||||||
|
// Generate a notification via level-up
|
||||||
|
for _ in 0..5 {
|
||||||
|
json_post(&app, "/matches/result", serde_json::json!({
|
||||||
|
"squad_id": "dummy", "opponent_name": "Bot",
|
||||||
|
"goals_for": 3, "goals_against": 0, "mode": "squad_battles"
|
||||||
|
})).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark all persistent notifications read
|
||||||
|
let (status, json) = json_post(&app, "/notifications/read-all", serde_json::json!({})).await;
|
||||||
|
assert_eq!(status, StatusCode::OK, "{json}");
|
||||||
|
assert!(json["marked_read"].as_i64().unwrap_or(0) >= 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_mark_single_notification_read() {
|
||||||
|
let app = build_test_app().await;
|
||||||
|
auth(&app, "SingleReadPlayer").await;
|
||||||
|
|
||||||
|
// Generate level-up notifications
|
||||||
|
for _ in 0..5 {
|
||||||
|
json_post(&app, "/matches/result", serde_json::json!({
|
||||||
|
"squad_id": "dummy", "opponent_name": "Bot",
|
||||||
|
"goals_for": 3, "goals_against": 0, "mode": "squad_battles"
|
||||||
|
})).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
let (_, json) = json_get(&app, "/notifications").await;
|
||||||
|
let notifs = json["notifications"].as_array().unwrap();
|
||||||
|
// Find one with an id (persistent) and PATCH it
|
||||||
|
let persistent = notifs.iter().find(|n| n["id"].is_string());
|
||||||
|
if let Some(n) = persistent {
|
||||||
|
let id = n["id"].as_str().unwrap();
|
||||||
|
let uri = format!("/notifications/{id}/read");
|
||||||
|
let resp = app.clone().oneshot(
|
||||||
|
Request::builder().method("PATCH").uri(&uri)
|
||||||
|
.body(Body::empty()).unwrap()
|
||||||
|
).await.unwrap();
|
||||||
|
assert_eq!(resp.status(), StatusCode::OK);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user