Experience #9 (lite): a published card can carry a "Now" line shown with a live pulsing dot, so a scanned profile reflects what the person is currently doing. - Backend: demo publish accepts optional `now` -> definition.profile.now; test asserts it. Gate green (120 tests). - Web: renders a "Now: …" block with an animated green dot. astro check clean. - Mobile: a "Now" field on the publish screen. Verified live alongside a link payload (CTA button). Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
404 lines
12 KiB
Rust
404 lines
12 KiB
Rust
//! Integration tests for the card surface (PRD §13.2, §15.2).
|
|
|
|
mod common;
|
|
|
|
use axum::http::StatusCode;
|
|
use serde_json::{json, Value};
|
|
|
|
use common::{unique_handle, TestApp};
|
|
|
|
fn definition() -> Value {
|
|
json!({
|
|
"face": { "layers": [], "background": { "type": "solid", "value": "#101014" } },
|
|
"back": { "layers": [], "background": { "type": "solid", "value": "#101014" } }
|
|
})
|
|
}
|
|
|
|
/// Create a draft card; returns its id and handle.
|
|
async fn create_card(app: &TestApp, token: &str) -> (String, String) {
|
|
let handle = unique_handle();
|
|
let (status, body) = app
|
|
.request(
|
|
"POST",
|
|
"/v1/cards",
|
|
Some(token),
|
|
Some(json!({ "handle": handle, "definition": definition() })),
|
|
)
|
|
.await;
|
|
assert_eq!(status, StatusCode::OK, "create failed: {body}");
|
|
(body["id"].as_str().unwrap().to_string(), handle)
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn create_card_success() {
|
|
let app = require_app!();
|
|
let token = app.register_and_token().await;
|
|
let (id, handle) = create_card(&app, &token).await;
|
|
assert!(!id.is_empty());
|
|
|
|
let (status, body) = app
|
|
.request("GET", &format!("/v1/cards/{id}"), Some(&token), None)
|
|
.await;
|
|
assert_eq!(status, StatusCode::OK);
|
|
assert_eq!(body["handle"], handle);
|
|
assert_eq!(body["status"], "draft");
|
|
assert_eq!(body["version"], 1);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn create_card_unauthorized() {
|
|
let app = require_app!();
|
|
let (status, _) = app
|
|
.request(
|
|
"POST",
|
|
"/v1/cards",
|
|
None,
|
|
Some(json!({ "handle": unique_handle(), "definition": definition() })),
|
|
)
|
|
.await;
|
|
assert_eq!(status, StatusCode::UNAUTHORIZED);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn update_card_increments_version() {
|
|
let app = require_app!();
|
|
let token = app.register_and_token().await;
|
|
let (id, _) = create_card(&app, &token).await;
|
|
|
|
let (s1, b1) = app
|
|
.request(
|
|
"PUT",
|
|
&format!("/v1/cards/{id}"),
|
|
Some(&token),
|
|
Some(json!({ "definition": definition() })),
|
|
)
|
|
.await;
|
|
assert_eq!(s1, StatusCode::OK);
|
|
assert_eq!(b1["version"], 2);
|
|
|
|
let (_, b2) = app
|
|
.request(
|
|
"PUT",
|
|
&format!("/v1/cards/{id}"),
|
|
Some(&token),
|
|
Some(json!({ "definition": definition() })),
|
|
)
|
|
.await;
|
|
assert_eq!(b2["version"], 3);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn patch_merges_definition_keys() {
|
|
let app = require_app!();
|
|
let token = app.register_and_token().await;
|
|
let (id, _) = create_card(&app, &token).await;
|
|
|
|
let (status, body) = app
|
|
.request(
|
|
"PATCH",
|
|
&format!("/v1/cards/{id}"),
|
|
Some(&token),
|
|
Some(json!({ "definition": { "settings": { "flipDurationMs": 250 } } })),
|
|
)
|
|
.await;
|
|
assert_eq!(status, StatusCode::OK);
|
|
// Original face key survives; new settings key is merged in.
|
|
assert!(body["definition"]["face"].is_object());
|
|
assert_eq!(body["definition"]["settings"]["flipDurationMs"], 250);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn delete_card_archives_not_destroys() {
|
|
let app = require_app!();
|
|
let token = app.register_and_token().await;
|
|
let (id, _) = create_card(&app, &token).await;
|
|
|
|
let (status, body) = app
|
|
.request("DELETE", &format!("/v1/cards/{id}"), Some(&token), None)
|
|
.await;
|
|
assert_eq!(status, StatusCode::OK);
|
|
assert_eq!(body["status"], "archived");
|
|
|
|
// Row still exists and is fetchable by its owner.
|
|
let (get_status, _) = app
|
|
.request("GET", &format!("/v1/cards/{id}"), Some(&token), None)
|
|
.await;
|
|
assert_eq!(get_status, StatusCode::OK);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn publish_respects_free_tier_limit() {
|
|
let app = require_app!();
|
|
let token = app.register_and_token().await; // free tier => 1 active card
|
|
let (id_a, _) = create_card(&app, &token).await;
|
|
let (id_b, _) = create_card(&app, &token).await;
|
|
|
|
let (s_a, _) = app
|
|
.request(
|
|
"POST",
|
|
&format!("/v1/cards/{id_a}/publish"),
|
|
Some(&token),
|
|
None,
|
|
)
|
|
.await;
|
|
assert_eq!(s_a, StatusCode::OK);
|
|
|
|
let (s_b, body) = app
|
|
.request(
|
|
"POST",
|
|
&format!("/v1/cards/{id_b}/publish"),
|
|
Some(&token),
|
|
None,
|
|
)
|
|
.await;
|
|
assert_eq!(s_b, StatusCode::PAYMENT_REQUIRED);
|
|
assert_eq!(body["code"], "tier_limit");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn get_card_by_handle_public_then_404_when_archived() {
|
|
let app = require_app!();
|
|
let token = app.register_and_token().await;
|
|
let (id, handle) = create_card(&app, &token).await;
|
|
|
|
// Draft is not publicly resolvable.
|
|
let (draft_status, _) = app
|
|
.request("GET", &format!("/v1/cards/handle/{handle}"), None, None)
|
|
.await;
|
|
assert_eq!(draft_status, StatusCode::NOT_FOUND);
|
|
|
|
// Publish -> public access works without auth.
|
|
app.request(
|
|
"POST",
|
|
&format!("/v1/cards/{id}/publish"),
|
|
Some(&token),
|
|
None,
|
|
)
|
|
.await;
|
|
let (active_status, body) = app
|
|
.request("GET", &format!("/v1/cards/handle/{handle}"), None, None)
|
|
.await;
|
|
assert_eq!(active_status, StatusCode::OK);
|
|
assert_eq!(body["handle"], handle);
|
|
|
|
// Archive -> no longer publicly resolvable.
|
|
app.request("DELETE", &format!("/v1/cards/{id}"), Some(&token), None)
|
|
.await;
|
|
let (archived_status, _) = app
|
|
.request("GET", &format!("/v1/cards/handle/{handle}"), None, None)
|
|
.await;
|
|
assert_eq!(archived_status, StatusCode::NOT_FOUND);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn cannot_access_another_users_card() {
|
|
let app = require_app!();
|
|
let owner = app.register_and_token().await;
|
|
let (id, _) = create_card(&app, &owner).await;
|
|
|
|
let intruder = app.register_and_token().await;
|
|
let (status, _) = app
|
|
.request("GET", &format!("/v1/cards/{id}"), Some(&intruder), None)
|
|
.await;
|
|
// NotFound (not Forbidden) so existence isn't leaked.
|
|
assert_eq!(status, StatusCode::NOT_FOUND);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn duplicate_creates_independent_draft() {
|
|
let app = require_app!();
|
|
let token = app.register_and_token().await;
|
|
let (id, handle) = create_card(&app, &token).await;
|
|
|
|
let (status, body) = app
|
|
.request(
|
|
"POST",
|
|
&format!("/v1/cards/{id}/duplicate"),
|
|
Some(&token),
|
|
None,
|
|
)
|
|
.await;
|
|
assert_eq!(status, StatusCode::OK);
|
|
assert_ne!(body["id"].as_str().unwrap(), id);
|
|
assert_ne!(body["handle"].as_str().unwrap(), handle);
|
|
assert_eq!(body["status"], "draft");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn welcome_generates_and_appears_on_public_profile() {
|
|
let app = require_app!();
|
|
let token = app.register_and_token().await;
|
|
let (id, handle) = create_card(&app, &token).await;
|
|
|
|
// Generate the AI welcome (FakeAiClient returns a 1x1 png).
|
|
let (status, body) = app
|
|
.request(
|
|
"POST",
|
|
&format!("/v1/cards/{id}/welcome"),
|
|
Some(&token),
|
|
Some(json!({ "prompt": "a calm forest at dawn", "message": "Great to meet you, I'm Omar" })),
|
|
)
|
|
.await;
|
|
assert_eq!(status, StatusCode::OK, "set welcome failed: {body}");
|
|
assert_eq!(body["welcome"]["kind"], "image");
|
|
assert!(body["welcome"]["imageUrl"]
|
|
.as_str()
|
|
.unwrap()
|
|
.contains("welcome/"));
|
|
|
|
// Publish -> the public profile exposes the welcome.
|
|
app.request(
|
|
"POST",
|
|
&format!("/v1/cards/{id}/publish"),
|
|
Some(&token),
|
|
None,
|
|
)
|
|
.await;
|
|
let (status, profile) = app
|
|
.request("GET", &format!("/v1/cards/handle/{handle}"), None, None)
|
|
.await;
|
|
assert_eq!(status, StatusCode::OK);
|
|
assert_eq!(profile["welcome"]["message"], "Great to meet you, I'm Omar");
|
|
assert!(profile["welcome"]["imageUrl"]
|
|
.as_str()
|
|
.unwrap()
|
|
.starts_with("http"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn welcome_requires_ownership() {
|
|
let app = require_app!();
|
|
let owner = app.register_and_token().await;
|
|
let (id, _handle) = create_card(&app, &owner).await;
|
|
let other = app.register_and_token().await;
|
|
let (status, _) = app
|
|
.request(
|
|
"POST",
|
|
&format!("/v1/cards/{id}/welcome"),
|
|
Some(&other),
|
|
Some(json!({ "prompt": "x" })),
|
|
)
|
|
.await;
|
|
assert_eq!(status, StatusCode::NOT_FOUND);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn we_met_capture_and_owner_list() {
|
|
let app = require_app!();
|
|
let token = app.register_and_token().await;
|
|
let (id, handle) = create_card(&app, &token).await;
|
|
app.request(
|
|
"POST",
|
|
&format!("/v1/cards/{id}/publish"),
|
|
Some(&token),
|
|
None,
|
|
)
|
|
.await;
|
|
|
|
// A scanner shares back (public, unauthenticated).
|
|
let (s, _) = app
|
|
.request(
|
|
"POST",
|
|
&format!("/v1/profile/{handle}/connect"),
|
|
None,
|
|
Some(json!({ "name": "Dana Scanner", "email": "[email protected]", "note": "met at SXSW" })),
|
|
)
|
|
.await;
|
|
assert_eq!(s, StatusCode::OK);
|
|
|
|
// The owner sees it in their connections.
|
|
let (s, body) = app
|
|
.request(
|
|
"GET",
|
|
&format!("/v1/cards/{id}/connections"),
|
|
Some(&token),
|
|
None,
|
|
)
|
|
.await;
|
|
assert_eq!(s, StatusCode::OK);
|
|
assert_eq!(body[0]["name"], "Dana Scanner");
|
|
assert_eq!(body[0]["note"], "met at SXSW");
|
|
|
|
// Name is required.
|
|
let (s, _) = app
|
|
.request(
|
|
"POST",
|
|
&format!("/v1/profile/{handle}/connect"),
|
|
None,
|
|
Some(json!({ "name": " " })),
|
|
)
|
|
.await;
|
|
assert_eq!(s, StatusCode::BAD_REQUEST);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn demo_publish_creates_scannable_profile_with_welcome() {
|
|
let app = require_app!();
|
|
let (status, body) = app
|
|
.request(
|
|
"POST",
|
|
"/v1/demo/publish",
|
|
None,
|
|
Some(json!({
|
|
"name": "Omar Sobh",
|
|
"title": "Founder",
|
|
"links": [{ "label": "Site", "url": "https://cardclaws.com" }],
|
|
"welcomePrompt": "a calm forest at dawn",
|
|
"welcomeMessage": "Great to meet you",
|
|
"payload": { "kind": "code", "label": "20% off", "value": "CARD20" },
|
|
"now": "Building CardClaws · open to design partners"
|
|
})),
|
|
)
|
|
.await;
|
|
assert_eq!(status, StatusCode::OK, "publish failed: {body}");
|
|
let handle = body["handle"].as_str().unwrap();
|
|
assert!(body["profileUrl"].as_str().unwrap().ends_with(handle));
|
|
|
|
// The published card resolves publicly with the owner name + welcome + payload.
|
|
let (status, profile) = app
|
|
.request("GET", &format!("/v1/cards/handle/{handle}"), None, None)
|
|
.await;
|
|
assert_eq!(status, StatusCode::OK);
|
|
assert_eq!(profile["ownerDisplayName"], "Omar Sobh");
|
|
assert_eq!(profile["welcome"]["message"], "Great to meet you");
|
|
assert_eq!(
|
|
profile["definition"]["profile"]["payload"]["value"],
|
|
"CARD20"
|
|
);
|
|
assert_eq!(
|
|
profile["definition"]["profile"]["now"],
|
|
"Building CardClaws · open to design partners"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn demo_publish_requires_a_name() {
|
|
let app = require_app!();
|
|
let (status, _) = app
|
|
.request(
|
|
"POST",
|
|
"/v1/demo/publish",
|
|
None,
|
|
Some(json!({ "name": " " })),
|
|
)
|
|
.await;
|
|
assert_eq!(status, StatusCode::BAD_REQUEST);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn connections_list_is_owner_only() {
|
|
let app = require_app!();
|
|
let token = app.register_and_token().await;
|
|
let (id, _handle) = create_card(&app, &token).await;
|
|
let other = app.register_and_token().await;
|
|
let (status, _) = app
|
|
.request(
|
|
"GET",
|
|
&format!("/v1/cards/{id}/connections"),
|
|
Some(&other),
|
|
None,
|
|
)
|
|
.await;
|
|
assert_eq!(status, StatusCode::NOT_FOUND);
|
|
}
|