- Tier capability model (pro-layers, geo-analytics, custom-domain, retention) - Pro-only layer types (video/particle/animatedGradient) rejected on card create/replace/patch for Free; geo analytics gated to Pro+ (402 tier_limit) - Gates read the authoritative DB tier, so upgrades apply without re-login - POST /v1/webhooks/revenuecat: shared-secret auth (constant-time), maps RevenueCat events to users.tier (purchase→pro/team/enterprise, cancel→free), unknown user = 2xx no-op; users::update_tier query 93 backend tests; fmt + clippy clean. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
114 lines
3.2 KiB
Rust
114 lines
3.2 KiB
Rust
//! Tier-gate enforcement (PRD §19.1). Tier is read from the DB so a simulated
|
|
//! upgrade (set_tier) takes effect immediately.
|
|
|
|
mod common;
|
|
|
|
use axum::http::StatusCode;
|
|
use serde_json::{json, Value};
|
|
|
|
use common::unique_handle;
|
|
|
|
fn card_with_particle() -> Value {
|
|
json!({
|
|
"face": {
|
|
"layers": [
|
|
{ "id": "p1", "type": "particle", "x": 0, "y": 0, "width": 1, "height": 1,
|
|
"opacity": 1, "zIndex": 1 }
|
|
],
|
|
"background": { "type": "solid", "value": "#101014" }
|
|
},
|
|
"back": { "layers": [] }
|
|
})
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn free_tier_cannot_use_pro_layers_but_pro_can() {
|
|
let app = require_app!();
|
|
let (token, user_id) = app.register_and_user().await;
|
|
|
|
// Free tier: a particle layer is rejected with a tier-limit (402).
|
|
let (status, body) = app
|
|
.request(
|
|
"POST",
|
|
"/v1/cards",
|
|
Some(&token),
|
|
Some(json!({ "handle": unique_handle(), "definition": card_with_particle() })),
|
|
)
|
|
.await;
|
|
assert_eq!(status, StatusCode::PAYMENT_REQUIRED);
|
|
assert_eq!(body["code"], "tier_limit");
|
|
|
|
// Upgrade to Pro → the same card is now accepted.
|
|
app.set_tier(&user_id, "pro").await;
|
|
let (status2, _) = app
|
|
.request(
|
|
"POST",
|
|
"/v1/cards",
|
|
Some(&token),
|
|
Some(json!({ "handle": unique_handle(), "definition": card_with_particle() })),
|
|
)
|
|
.await;
|
|
assert_eq!(status2, StatusCode::OK);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn free_tier_plain_card_still_works() {
|
|
let app = require_app!();
|
|
let (token, _) = app.register_and_user().await;
|
|
let plain = json!({
|
|
"face": { "layers": [], "background": { "type": "solid", "value": "#101014" } },
|
|
"back": { "layers": [] }
|
|
});
|
|
let (status, _) = app
|
|
.request(
|
|
"POST",
|
|
"/v1/cards",
|
|
Some(&token),
|
|
Some(json!({ "handle": unique_handle(), "definition": plain })),
|
|
)
|
|
.await;
|
|
assert_eq!(status, StatusCode::OK);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn geo_analytics_gated_to_pro() {
|
|
let app = require_app!();
|
|
let (token, user_id) = app.register_and_user().await;
|
|
let (_, created) = app
|
|
.request(
|
|
"POST",
|
|
"/v1/cards",
|
|
Some(&token),
|
|
Some(json!({
|
|
"handle": unique_handle(),
|
|
"definition": { "face": { "layers": [], "background": { "type": "solid", "value": "#101014" } }, "back": { "layers": [] } }
|
|
})),
|
|
)
|
|
.await;
|
|
let id = created["id"].as_str().unwrap();
|
|
|
|
// Free tier: geo is gated.
|
|
let (free_status, body) = app
|
|
.request(
|
|
"GET",
|
|
&format!("/v1/cards/{id}/analytics/geo"),
|
|
Some(&token),
|
|
None,
|
|
)
|
|
.await;
|
|
assert_eq!(free_status, StatusCode::PAYMENT_REQUIRED);
|
|
assert_eq!(body["code"], "tier_limit");
|
|
|
|
// Pro tier: geo is allowed (empty list here, but 200).
|
|
app.set_tier(&user_id, "pro").await;
|
|
let (pro_status, _) = app
|
|
.request(
|
|
"GET",
|
|
&format!("/v1/cards/{id}/analytics/geo"),
|
|
Some(&token),
|
|
None,
|
|
)
|
|
.await;
|
|
assert_eq!(pro_status, StatusCode::OK);
|
|
}
|