use axum::http::{Method, StatusCode, header};
use cratefield_core::{Config, MapConfig, Statement};
use cratefield_module_privacy::Privacy;
use cratefield_module_waitlist::Waitlist;
use cratefield_testing::TestHarness;
use serde_json::Value;
use std::sync::Arc;
use tower::ServiceExt;
const ADMIN: &str = "test-admin-token-0123456789abcdef";
const ALICE: &str = "01HCWAITLISTALICE0000000001";
const BOB: &str = "01HCWAITLISTBOB000000000002";
fn privacy_kit() -> TestHarness {
let config: Arc<dyn Config> = Arc::new(MapConfig::from_pairs([
("ADMIN_TOKEN".to_owned(), ADMIN.to_owned()),
(
"HARNESS_SECRET".to_owned(),
"cratefield-testing-dummy-secret-0123456789".to_owned(),
),
]));
TestHarness::with_ports(
vec![
Box::new(Waitlist::new().products(["launch"])),
Box::new(Privacy::new()),
],
move |ports| {
ports.config = Arc::clone(&config);
},
)
}
#[allow(clippy::too_many_arguments)]
async fn seed(
kit: &TestHarness,
id: &str,
email: &str,
position: i64,
referral_code: &str,
referred_by: Option<&str>,
referrals: i64,
) {
kit.db
.execute(&Statement::with_values(
"INSERT INTO waitlist_entries (id, email, email_normalized, product, status, \
position, referral_code, referred_by, referrals, answers, created_at, confirmed_at, \
generation) VALUES (?, ?, ?, 'launch', 'confirmed', ?, ?, ?, ?, ?, ?, ?, 1)"
.to_owned(),
vec![
id.into(),
email.into(),
email.into(),
position.into(),
referral_code.into(),
referred_by.map(str::to_owned).into(),
referrals.into(),
r#"{"role":"founder"}"#.into(),
"2026-01-01T00:00:00Z".into(),
"2026-01-01T00:00:00Z".into(),
],
))
.await
.expect("seeding a waitlist entry");
}
async fn entry(kit: &TestHarness, id: &str) -> Value {
let rows = kit
.db
.query(&Statement::with_values(
"SELECT email, email_normalized, answers, position, referral_code, referred_by, \
referrals, product, status FROM waitlist_entries WHERE id = ?"
.to_owned(),
vec![id.into()],
))
.await
.expect("reading the entry");
let row = rows.first().unwrap_or_else(|| panic!("{id} is gone"));
let mut object = serde_json::Map::new();
for column in [
"email",
"email_normalized",
"answers",
"referral_code",
"referred_by",
"product",
"status",
] {
object.insert(
column.to_owned(),
row.get::<String>(column).map_or(Value::Null, Value::String),
);
}
for column in ["position", "referrals"] {
object.insert(
column.to_owned(),
row.get::<i64>(column).map_or(Value::Null, Value::from),
);
}
Value::Object(object)
}
struct Answer {
status: StatusCode,
body: Vec<u8>,
}
impl Answer {
fn json(&self) -> Value {
serde_json::from_slice(&self.body)
.unwrap_or_else(|err| panic!("body is not JSON ({err}): {}", self.text()))
}
fn text(&self) -> String {
String::from_utf8_lossy(&self.body).into_owned()
}
}
async fn send(
kit: &TestHarness,
method: Method,
path: &str,
token: Option<&str>,
body: Option<&str>,
) -> Answer {
let mut builder = axum::http::Request::builder().method(method).uri(path);
if let Some(token) = token {
builder = builder.header(header::AUTHORIZATION, format!("Bearer {token}"));
}
let payload = match body {
Some(json) => {
builder = builder.header(header::CONTENT_TYPE, "application/json");
axum::body::Body::from(json.to_owned())
}
None => axum::body::Body::empty(),
};
let response = kit
.router
.clone()
.oneshot(builder.body(payload).expect("request builds"))
.await
.expect("router answers");
let status = response.status();
let body = axum::body::to_bytes(response.into_body(), 1024 * 1024)
.await
.expect("body reads")
.to_vec();
Answer { status, body }
}
async fn erase(kit: &TestHarness, subject: &str) -> Answer {
let preview = send(
kit,
Method::POST,
"/v1/privacy/erase",
Some(ADMIN),
Some(&format!(r#"{{"subject":"{subject}"}}"#)),
)
.await;
assert_eq!(preview.status, StatusCode::OK, "{}", preview.text());
let token = preview.json()["confirm_token"].clone();
send(
kit,
Method::POST,
"/v1/privacy/erase/confirm",
Some(ADMIN),
Some(&format!(r#"{{"token":{token}}}"#)),
)
.await
}
#[pollster::test]
async fn an_erasure_takes_the_person_out_of_the_entry_and_leaves_the_queue_intact() {
let kit = privacy_kit();
seed(&kit, ALICE, "alice@example.com", 1, "ALICECODE", None, 2).await;
seed(
&kit,
BOB,
"bob@example.com",
2,
"BOBCODE00",
Some("ALICECODE"),
0,
)
.await;
let confirm = erase(&kit, ALICE).await;
assert_eq!(confirm.status, StatusCode::OK, "{}", confirm.text());
assert_eq!(confirm.json()["verified"], true);
let alice = entry(&kit, ALICE).await;
assert_eq!(alice["email"], Value::Null, "{alice}");
assert_eq!(alice["email_normalized"], Value::Null, "{alice}");
assert_eq!(alice["answers"], Value::Null, "{alice}");
assert_eq!(alice["position"], 1, "the queue lost a number: {alice}");
assert_eq!(alice["referrals"], 2, "a credit was rewritten: {alice}");
assert_eq!(
alice["referral_code"], "ALICECODE",
"Bob's referral now points at nothing: {alice}"
);
assert_eq!(alice["product"], "launch", "{alice}");
let bob = entry(&kit, BOB).await;
assert_eq!(bob["email"], "bob@example.com", "Bob was erased too: {bob}");
assert_eq!(bob["referred_by"], "ALICECODE", "{bob}");
assert_eq!(bob["position"], 2, "{bob}");
}
#[pollster::test]
async fn the_preview_says_anonymise_and_names_the_columns() {
let kit = privacy_kit();
seed(&kit, ALICE, "alice@example.com", 1, "ALICECODE", None, 0).await;
let preview = send(
&kit,
Method::POST,
"/v1/privacy/erase",
Some(ADMIN),
Some(&format!(r#"{{"subject":"{ALICE}"}}"#)),
)
.await;
assert_eq!(preview.status, StatusCode::OK, "{}", preview.text());
let body = preview.json();
let plan = body["plan"].as_array().expect("plan");
let entries = plan
.iter()
.find(|row| row["table"] == "waitlist_entries")
.expect("waitlist_entries planned");
assert_eq!(entries["action"], "anonymise", "{entries}");
assert_eq!(entries["rows"], 1, "{entries}");
assert_eq!(
entries["columns"],
serde_json::json!(["email", "email_normalized", "answers"]),
"{entries}"
);
let planned: Vec<&str> = plan
.iter()
.filter_map(|row| row["table"].as_str())
.collect();
assert_eq!(planned, ["waitlist_entries"], "{plan:?}");
}
#[pollster::test]
async fn an_export_carries_the_entry() {
let kit = privacy_kit();
seed(&kit, ALICE, "alice@example.com", 1, "ALICECODE", None, 2).await;
let response = send(
&kit,
Method::GET,
&format!("/v1/privacy/export?subject={ALICE}"),
Some(ADMIN),
None,
)
.await;
assert_eq!(response.status, StatusCode::OK, "{}", response.text());
let body = response.json();
let table = body["tables"]
.as_array()
.expect("tables")
.iter()
.find(|t| t["table"] == "waitlist_entries")
.expect("waitlist_entries exported")
.clone();
let row = &table["rows"][0];
assert_eq!(row["email"], "alice@example.com", "{row}");
assert_eq!(row["position"], 1, "{row}");
assert_eq!(row["answers"], r#"{"role":"founder"}"#, "{row}");
assert_eq!(table["kind"], "contact");
}
#[pollster::test]
async fn the_manifest_says_what_the_two_keyless_tables_hold() {
let kit = privacy_kit();
let response = send(&kit, Method::GET, "/v1/privacy/manifest", None, None).await;
assert_eq!(response.status, StatusCode::OK);
let body = response.json();
let not_personal = body["not_personal"].as_array().expect("not_personal");
let reason = |table: &str| -> String {
not_personal
.iter()
.find(|entry| entry["table"] == table)
.unwrap_or_else(|| panic!("{table} is not published at all: {not_personal:?}"))["reason"]
.as_str()
.unwrap_or_default()
.to_owned()
};
assert!(
not_personal
.iter()
.all(|entry| entry["table"] != "waitlist_send_cooldown"),
"the cooldown holds the address; it cannot be published as not personal: {not_personal:?}"
);
let unreachable = body["unreachable"].as_array().expect("unreachable");
let cooldown = unreachable
.iter()
.find(|entry| entry["table"] == "waitlist_send_cooldown")
.unwrap_or_else(|| panic!("cooldown not published as unreachable: {unreachable:?}"))
.clone();
assert_eq!(cooldown["kind"], "contact");
assert!(
cooldown["reason"]
.as_str()
.unwrap_or_default()
.contains("cannot match"),
"the cooldown reason has to say why erasure cannot reach it: {cooldown}"
);
let lock = reason("waitlist_position_lock");
assert!(
lock.contains("nobody is named"),
"the lock reason has to say it names nobody: {lock}"
);
let entries = body["holds"]
.as_array()
.expect("holds")
.iter()
.find(|entry| entry["table"] == "waitlist_entries")
.expect("waitlist_entries published")
.clone();
assert_eq!(entries["on_erasure"]["action"], "anonymise", "{entries}");
assert_eq!(body["holds_personal_data"], true);
}