use axum::{
Extension,
extract::{Json, Path, Query, State},
};
use crate::authz;
use crate::db::queries::changes::{clamp_changes_limit, get_index, list_changes};
use crate::db::{
DbPool,
models::{ChangesPage, IndexSnapshot, Role},
};
use crate::error::LificError;
use super::with_read;
#[derive(Debug, serde::Deserialize)]
pub(super) struct ChangesQuery {
pub since: Option<i64>,
pub limit: Option<i64>,
}
pub(super) async fn project_changes(
State(db): State<DbPool>,
Extension(identity): Extension<Option<crate::resolve_caller::ResolvedIdentity>>,
Path(id): Path<i64>,
Query(q): Query<ChangesQuery>,
) -> Result<Json<ChangesPage>, LificError> {
authz::require_role(&db, &identity, id, Role::Viewer)?;
let limit = clamp_changes_limit(q.limit);
let since = q.since.unwrap_or(0);
with_read(&db, |conn| list_changes(conn, id, since, limit)).map(Json)
}
pub(super) async fn project_index(
State(db): State<DbPool>,
Extension(identity): Extension<Option<crate::resolve_caller::ResolvedIdentity>>,
Path(id): Path<i64>,
) -> Result<Json<IndexSnapshot>, LificError> {
authz::require_role(&db, &identity, id, Role::Viewer)?;
with_read(&db, |conn| get_index(conn, id)).map(Json)
}
#[cfg(test)]
mod tests {
use crate::api::test_helpers::*;
use axum::http::StatusCode;
async fn seed_issue(app: &axum::Router, project_id: i64, title: &str) -> serde_json::Value {
parse_json(
json_post(
app,
"/api/issues",
serde_json::json!({ "project_id": project_id, "title": title }),
)
.await,
)
.await
}
#[tokio::test]
async fn index_returns_live_rows_and_a_cursor() {
let app = test_app();
let (project_id, _) = seed_project(&app).await;
let live = seed_issue(&app, project_id, "live").await;
let doomed = seed_issue(&app, project_id, "doomed").await;
json_post(
&app,
"/api/pages",
serde_json::json!({ "project_id": project_id, "title": "Design" }),
)
.await;
json_delete(
&app,
&format!("/api/issues/{}", doomed["id"].as_i64().unwrap()),
)
.await;
let resp = json_get(&app, &format!("/api/projects/{project_id}/index")).await;
assert_eq!(resp.status(), StatusCode::OK);
let body = parse_json(resp).await;
let issues = body["issues"].as_array().unwrap();
assert_eq!(issues.len(), 1, "the tombstoned issue must not appear");
assert_eq!(issues[0]["id"], live["id"]);
assert_eq!(issues[0]["kind"], "issue");
assert_eq!(issues[0]["deleted"], false);
assert!(issues[0].get("description").is_none());
let pages = body["pages"].as_array().unwrap();
assert_eq!(pages.len(), 1);
assert_eq!(pages[0]["identifier"], "TST-DOC-1");
assert!(pages[0].get("content").is_none());
let cursor = body["cursor"].as_i64().unwrap();
assert!(
issues
.iter()
.chain(pages.iter())
.all(|row| row["seq"].as_i64().unwrap() <= cursor),
"cursor must be at or above every row it shipped"
);
}
#[tokio::test]
async fn changes_backfills_creates_updates_and_deletes_since_a_cursor() {
let app = test_app();
let (project_id, _) = seed_project(&app).await;
let untouched = seed_issue(&app, project_id, "untouched").await;
let edited = seed_issue(&app, project_id, "edited").await;
let doomed = seed_issue(&app, project_id, "doomed").await;
let cursor = parse_json(json_get(&app, &format!("/api/projects/{project_id}/index")).await)
.await["cursor"]
.as_i64()
.unwrap();
json_put(
&app,
&format!("/api/issues/{}", edited["id"].as_i64().unwrap()),
serde_json::json!({ "title": "edited twice" }),
)
.await;
json_delete(
&app,
&format!("/api/issues/{}", doomed["id"].as_i64().unwrap()),
)
.await;
let body = parse_json(
json_get(
&app,
&format!("/api/projects/{project_id}/changes?since={cursor}"),
)
.await,
)
.await;
let changes = body["changes"].as_array().unwrap();
assert_eq!(changes.len(), 2, "only the two touched rows");
assert!(!body["has_more"].as_bool().unwrap());
let seqs: Vec<i64> = changes.iter().map(|c| c["seq"].as_i64().unwrap()).collect();
let mut sorted = seqs.clone();
sorted.sort_unstable();
assert_eq!(seqs, sorted, "ascending by seq");
assert_eq!(body["cursor"].as_i64().unwrap(), *seqs.last().unwrap());
let edited_row = changes
.iter()
.find(|c| c["id"] == edited["id"])
.expect("the edited issue");
assert_eq!(edited_row["deleted"], false);
assert_eq!(edited_row["title"], "edited twice");
let tombstone = changes
.iter()
.find(|c| c["id"] == doomed["id"])
.expect("the tombstone");
assert_eq!(tombstone["deleted"], true);
assert_eq!(tombstone["kind"], "issue");
assert!(tombstone.get("title").is_none(), "tombstones carry no body");
assert!(
!changes.iter().any(|c| c["id"] == untouched["id"]),
"an untouched row must not be re-delivered"
);
}
#[tokio::test]
async fn changes_includes_comments_scoped_to_the_project() {
let app = test_app();
let (project_id, _) = seed_project(&app).await;
let issue = seed_issue(&app, project_id, "commented").await;
let issue_id = issue["id"].as_i64().unwrap();
json_post(
&app,
&format!("/api/issues/{issue_id}/comments"),
serde_json::json!({ "content": "hello there" }),
)
.await;
let body =
parse_json(json_get(&app, &format!("/api/projects/{project_id}/changes")).await).await;
let comments: Vec<&serde_json::Value> = body["changes"]
.as_array()
.unwrap()
.iter()
.filter(|c| c["kind"] == "comment")
.collect();
assert_eq!(comments.len(), 1);
assert_eq!(comments[0]["issue_id"], issue_id);
assert_eq!(comments[0]["username"], "test-admin");
assert!(
comments[0].get("content").is_none(),
"comment bodies load per-detail"
);
}
#[tokio::test]
async fn changes_paginate_without_gaps_or_duplicates() {
let app = test_app();
let (project_id, _) = seed_project(&app).await;
for n in 0..5 {
seed_issue(&app, project_id, &format!("issue {n}")).await;
}
let first = parse_json(
json_get(&app, &format!("/api/projects/{project_id}/changes?limit=2")).await,
)
.await;
assert_eq!(first["changes"].as_array().unwrap().len(), 2);
assert!(first["has_more"].as_bool().unwrap());
assert_eq!(
first["cursor"].as_i64().unwrap(),
first["changes"][1]["seq"].as_i64().unwrap()
);
let mut ids: Vec<i64> = first["changes"]
.as_array()
.unwrap()
.iter()
.map(|c| c["id"].as_i64().unwrap())
.collect();
let mut cursor = first["cursor"].as_i64().unwrap();
loop {
let next = parse_json(
json_get(
&app,
&format!("/api/projects/{project_id}/changes?since={cursor}&limit=2"),
)
.await,
)
.await;
ids.extend(
next["changes"]
.as_array()
.unwrap()
.iter()
.map(|c| c["id"].as_i64().unwrap()),
);
cursor = next["cursor"].as_i64().unwrap();
if !next["has_more"].as_bool().unwrap() {
break;
}
}
assert_eq!(ids.len(), 5, "every issue arrived exactly once");
let mut unique = ids.clone();
unique.sort_unstable();
unique.dedup();
assert_eq!(unique.len(), 5);
}
#[tokio::test]
async fn changes_clamps_limit_and_applies_a_default() {
let app = test_app();
let (project_id, _) = seed_project(&app).await;
for n in 0..3 {
seed_issue(&app, project_id, &format!("issue {n}")).await;
}
for uri in [
format!("/api/projects/{project_id}/changes?limit=999999"),
format!("/api/projects/{project_id}/changes"),
format!("/api/projects/{project_id}/changes?limit=-1"),
] {
let resp = json_get(&app, &uri).await;
assert_eq!(resp.status(), StatusCode::OK, "{uri}");
let body = parse_json(resp).await;
let count = body["changes"].as_array().unwrap().len();
assert!(count <= 3, "{uri} returned {count}");
}
let clamped = parse_json(
json_get(
&app,
&format!("/api/projects/{project_id}/changes?limit=-1"),
)
.await,
)
.await;
assert_eq!(clamped["changes"].as_array().unwrap().len(), 1);
assert!(clamped["has_more"].as_bool().unwrap());
}
#[tokio::test]
async fn a_row_created_after_the_index_cursor_arrives_in_the_next_delta() {
let app = test_app();
let (project_id, _) = seed_project(&app).await;
seed_issue(&app, project_id, "before").await;
let cursor = parse_json(json_get(&app, &format!("/api/projects/{project_id}/index")).await)
.await["cursor"]
.as_i64()
.unwrap();
let after = seed_issue(&app, project_id, "after").await;
let body = parse_json(
json_get(
&app,
&format!("/api/projects/{project_id}/changes?since={cursor}"),
)
.await,
)
.await;
let changes = body["changes"].as_array().unwrap();
assert_eq!(changes.len(), 1);
assert_eq!(changes[0]["id"], after["id"]);
assert!(changes[0]["seq"].as_i64().unwrap() > cursor);
}
}
#[cfg(test)]
mod authz_gating_tests {
use crate::api::test_helpers::*;
use axum::http::StatusCode;
#[tokio::test]
async fn changes_and_index_deny_non_member_allow_viewer() {
let (db, _admin, lead, _maintainer, viewer, non_member, project_id) =
setup_membership_test();
let lead_app = app_as_user(db.clone(), &lead);
json_post(
&lead_app,
"/api/issues",
serde_json::json!({ "project_id": project_id, "title": "Gated" }),
)
.await;
let non_member_app = app_as_user(db.clone(), &non_member);
for uri in [
format!("/api/projects/{project_id}/changes"),
format!("/api/projects/{project_id}/index"),
] {
assert_eq!(
json_get(&non_member_app, &uri).await.status(),
StatusCode::FORBIDDEN,
"{uri}"
);
}
let viewer_app = app_as_user(db, &viewer);
let changes =
parse_json(json_get(&viewer_app, &format!("/api/projects/{project_id}/changes")).await)
.await;
assert_eq!(changes["changes"].as_array().unwrap().len(), 1);
let index =
parse_json(json_get(&viewer_app, &format!("/api/projects/{project_id}/index")).await)
.await;
assert_eq!(index["issues"].as_array().unwrap().len(), 1);
}
}