Phase 2: share system + analytics feed (backend)
- share_links model/queries; opaque 12-char base62 tokens (PRD §17.1)
- POST /v1/cards/{id}/share — create share link (owner-scoped, modality-validated)
- GET /v1/s/{token} — record modality-attributed event (qr→qr_scan, nfc→nfc_tap)
and 302-redirect to the profile; unknown/expired → 404
- GET /v1/cards/{id}/share-links — list a card's links
- analytics_events now carry the share_token correlation
- GET /v1/cards/{id}/analytics/feed — chronological event feed (§6.7.2)
6 new integration tests; 73 backend tests total. fmt + clippy clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
c30d3afeec
commit
e66813ef58
@@ -0,0 +1,159 @@
|
||||
//! Integration tests for the share system (PRD §13.4, §17).
|
||||
|
||||
mod common;
|
||||
|
||||
use axum::http::StatusCode;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use common::{unique_handle, TestApp};
|
||||
|
||||
async fn create_card(app: &TestApp, token: &str) -> String {
|
||||
let handle = unique_handle();
|
||||
let def = json!({
|
||||
"face": { "layers": [], "background": { "type": "solid", "value": "#101014" } },
|
||||
"back": { "layers": [] }
|
||||
});
|
||||
let (_, created) = app
|
||||
.request(
|
||||
"POST",
|
||||
"/v1/cards",
|
||||
Some(token),
|
||||
Some(json!({"handle": handle, "definition": def})),
|
||||
)
|
||||
.await;
|
||||
created["id"].as_str().unwrap().to_string()
|
||||
}
|
||||
|
||||
async fn create_share(
|
||||
app: &TestApp,
|
||||
token: &str,
|
||||
card_id: &str,
|
||||
modality: &str,
|
||||
) -> (StatusCode, Value) {
|
||||
app.request(
|
||||
"POST",
|
||||
&format!("/v1/cards/{card_id}/share"),
|
||||
Some(token),
|
||||
Some(json!({ "modality": modality })),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_share_link_returns_token_and_url() {
|
||||
let app = require_app!();
|
||||
let token = app.register_and_token().await;
|
||||
let card_id = create_card(&app, &token).await;
|
||||
|
||||
let (status, body) = create_share(&app, &token, &card_id, "qr").await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
let tok = body["token"].as_str().unwrap();
|
||||
assert_eq!(tok.len(), 12);
|
||||
assert!(body["url"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.ends_with(&format!("/s/{tok}")));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_share_requires_auth_and_valid_modality() {
|
||||
let app = require_app!();
|
||||
let token = app.register_and_token().await;
|
||||
let card_id = create_card(&app, &token).await;
|
||||
|
||||
let (unauth, _) = app
|
||||
.request(
|
||||
"POST",
|
||||
&format!("/v1/cards/{card_id}/share"),
|
||||
None,
|
||||
Some(json!({"modality": "qr"})),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(unauth, StatusCode::UNAUTHORIZED);
|
||||
|
||||
let (bad, body) = create_share(&app, &token, &card_id, "telepathy").await;
|
||||
assert_eq!(bad, StatusCode::BAD_REQUEST);
|
||||
assert_eq!(body["code"], "validation");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resolve_redirects_and_attributes_qr_scan() {
|
||||
let app = require_app!();
|
||||
let token = app.register_and_token().await;
|
||||
let card_id = create_card(&app, &token).await;
|
||||
let (_, share) = create_share(&app, &token, &card_id, "qr").await;
|
||||
let tok = share["token"].as_str().unwrap();
|
||||
|
||||
let (status, location) = app.get_redirect(&format!("/v1/s/{tok}")).await;
|
||||
assert_eq!(status, StatusCode::FOUND);
|
||||
assert!(location.unwrap().starts_with("https://cardclaws.test/"));
|
||||
|
||||
// The resolution recorded a qr_scan attributed to the card.
|
||||
let (_, summary) = app
|
||||
.request(
|
||||
"GET",
|
||||
&format!("/v1/cards/{card_id}/analytics"),
|
||||
Some(&token),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(summary["qrScans"], 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn feed_shows_attributed_event_after_resolve() {
|
||||
let app = require_app!();
|
||||
let token = app.register_and_token().await;
|
||||
let card_id = create_card(&app, &token).await;
|
||||
let (_, share) = create_share(&app, &token, &card_id, "qr").await;
|
||||
let tok = share["token"].as_str().unwrap().to_string();
|
||||
|
||||
app.get_redirect(&format!("/v1/s/{tok}")).await;
|
||||
|
||||
let (status, body) = app
|
||||
.request(
|
||||
"GET",
|
||||
&format!("/v1/cards/{card_id}/analytics/feed"),
|
||||
Some(&token),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
let events = body.as_array().unwrap();
|
||||
assert!(events
|
||||
.iter()
|
||||
.any(|e| e["eventType"] == "qr_scan" && e["shareToken"] == tok));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resolve_unknown_token_is_404() {
|
||||
let app = require_app!();
|
||||
let (status, _) = app.get_redirect("/v1/s/doesnotexist1").await;
|
||||
assert_eq!(status, StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_share_links_returns_created_links() {
|
||||
let app = require_app!();
|
||||
let token = app.register_and_token().await;
|
||||
let card_id = create_card(&app, &token).await;
|
||||
create_share(&app, &token, &card_id, "qr").await;
|
||||
create_share(&app, &token, &card_id, "nfc").await;
|
||||
|
||||
let (status, body) = app
|
||||
.request(
|
||||
"GET",
|
||||
&format!("/v1/cards/{card_id}/share-links"),
|
||||
Some(&token),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
let arr = body.as_array().unwrap();
|
||||
assert_eq!(arr.len(), 2);
|
||||
let modalities: Vec<&str> = arr
|
||||
.iter()
|
||||
.map(|l| l["modality"].as_str().unwrap())
|
||||
.collect();
|
||||
assert!(modalities.contains(&"qr") && modalities.contains(&"nfc"));
|
||||
}
|
||||
Reference in New Issue
Block a user