Phase 19: persistent notifications system
CI / Build, lint & test (push) Failing after 58s

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:
funman300
2026-06-25 17:57:24 -07:00
parent 9e481ec072
commit f0dbabc409
9 changed files with 293 additions and 49 deletions
+80
View File
@@ -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(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);
}
}