#[path = "../common/mod.rs"]
mod common;
use common::run_lua;
use serde_json::json;
use wiremock::matchers::{header, method, path, query_param};
use wiremock::{Mock, MockServer, ResponseTemplate};
const OPEN_GATE: &str = r#"
local lp = require("assay.lead_provider")
local spent = {}
local gate = lp.gate({
approve = function(op, cents) spent[#spent + 1] = op .. ":" .. cents; return true end,
meter = function() end,
})
"#;
const CLOSED_GATE: &str = r#"
local lp = require("assay.lead_provider")
local gate = lp.gate({
approve = function() return false, "over_cap" end,
meter = function() end,
})
"#;
fn script(gate: &str, module: &str, alias: &str, ctor: &str, uri: &str, body: &str) -> String {
format!(
"{gate}\nlocal {alias} = require(\"assay.{module}\")\n\
local c = {alias}.client(gate, {{ {ctor}, base_url = \"{uri}\" }})\n{body}"
)
}
async fn contactout(uri: &str, body: &str) -> Result<(), mlua::Error> {
run_lua(&script(OPEN_GATE, "contactout", "co", r#"token = "k""#, uri, body)).await
}
async fn co_ok(server: &MockServer, body: &str) {
contactout(&server.uri(), body).await.unwrap();
}
async fn bettercontact(gate: &str, uri: &str, body: &str) -> Result<(), mlua::Error> {
run_lua(&script(gate, "bettercontact", "bc", r#"api_key = "bc-key""#, uri, body)).await
}
async fn server_returning(m: &str, p: &str, status: u16) -> MockServer {
let server = MockServer::start().await;
let mock = if m == "POST" {
Mock::given(method("POST")).and(path(p.to_string()))
} else {
Mock::given(method("GET")).and(path(p.to_string()))
};
mock.respond_with(ResponseTemplate::new(status)).mount(&server).await;
server
}
fn assert_says(err: mlua::Error, want: &str, ctx: &str) {
let text = err.to_string();
assert!(text.contains(want), "{ctx} gave {text}, wanted {want}");
}
fn profile_response(profile: serde_json::Value) -> ResponseTemplate {
ResponseTemplate::new(200).set_body_json(json!({ "status_code": 200, "profile": profile }))
}
#[tokio::test]
async fn test_contactout_sends_the_bare_token_header() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/v1/linkedin/enrich"))
.and(header("token", "k"))
.respond_with(profile_response(json!({
"url": "https://www.linkedin.com/in/jchurch",
"full_name": "Jonathan Church",
"headline": "Managing Director",
"work_email": ["jonathan.church@cheaney.co.uk"],
"email": ["jonathan.church@cheaney.co.uk", "jc@personal.example"]
})))
.mount(&server)
.await;
co_ok(
&server,
r#"
local p = c:enrich_linkedin("https://www.linkedin.com/in/jchurch")
assert.eq(p.full_name, "Jonathan Church")
assert.eq(p.first_name, "Jonathan")
assert.eq(p.last_name, "Church")
assert.eq(p.title, "Managing Director")
assert.eq(#p.emails, 2)
assert.eq(p.emails[1].address, "jonathan.church@cheaney.co.uk")
assert.eq(p.emails[1].verification_status, "UNKNOWN")
assert.eq(p.provenance.provider, "contactout")
assert.not_nil(p.provenance.retrieved_at)
assert.eq(spent[1], "find_person:0")
"#,
)
.await;
}
#[tokio::test]
async fn test_contactout_puts_work_email_first_without_duplicating_it() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/v1/people/person"))
.and(query_param("email", "jc@cheaney.co.uk"))
.respond_with(profile_response(json!({
"email": ["jc@cheaney.co.uk"],
"work_email": ["jc@cheaney.co.uk"],
"linkedin": "https://www.linkedin.com/in/jc"
})))
.mount(&server)
.await;
co_ok(
&server,
r#"
local p = c:profile_by_email("jc@cheaney.co.uk")
assert.eq(#p.emails, 1)
assert.eq(p.linkedin, "https://www.linkedin.com/in/jc")
"#,
)
.await;
}
async fn mount_enrich_expecting(server: &MockServer, body: serde_json::Value) {
Mock::given(method("POST"))
.and(path("/v1/people/enrich"))
.and(wiremock::matchers::body_json(body))
.respond_with(profile_response(json!({ "full_name": "Jonathan Church" })))
.mount(server)
.await;
}
#[tokio::test]
async fn test_contactout_sends_company_as_an_array_and_omits_it_when_absent() {
let server = MockServer::start().await;
mount_enrich_expecting(
&server,
json!({
"full_name": "Jonathan Church",
"company": ["Joseph Cheaney & Sons"],
"include": ["work_email"]
}),
)
.await;
mount_enrich_expecting(
&server,
json!({ "full_name": "Jonathan Church", "include": ["work_email"] }),
)
.await;
co_ok(
&server,
r#"
local with = c:find_person({ full_name = "Jonathan Church", company = "Joseph Cheaney & Sons" })
assert.eq(with.full_name, "Jonathan Church")
local without = c:find_person({ full_name = "Jonathan Church" })
assert.eq(without.full_name, "Jonathan Church")
"#,
)
.await;
}
#[tokio::test]
async fn test_contactout_maps_the_company_object_to_name_and_domain() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/v1/people/person"))
.respond_with(profile_response(json!({
"full_name": "Jonathan Church",
"company": {
"name": "Joseph Cheaney & Sons",
"domain": "cheaney.co.uk",
"email_domain": "cheaney.co.uk"
}
})))
.mount(&server)
.await;
co_ok(
&server,
r#"
local p = c:profile_by_email("jc@cheaney.co.uk")
assert.eq(p.company, "Joseph Cheaney & Sons")
assert.eq(p.domain, "cheaney.co.uk")
"#,
)
.await;
}
#[tokio::test]
async fn test_contactout_reports_an_unknown_person_as_nil_not_an_error() {
let server = server_returning("GET", "/v1/linkedin/enrich", 404).await;
co_ok(
&server,
r#"assert.eq(c:enrich_linkedin("https://www.linkedin.com/in/nobody"), nil)"#,
)
.await;
}
#[tokio::test]
async fn test_contactout_distinguishes_auth_and_rate_limit_from_absence() {
for (status, want) in [(401u16, "rejected the token"), (429, "rate limited")] {
let server = server_returning("GET", "/v1/linkedin/enrich", status).await;
let err = contactout(&server.uri(), r#"c:enrich_linkedin("https://x/in/y")"#)
.await
.unwrap_err();
assert_says(err, want, &format!("HTTP {status}"));
}
}
#[tokio::test]
async fn test_contactout_refuses_to_build_without_a_gate_or_a_token() {
for (args, want) in [
(r#"nil, { token = "k" }"#, "gate is required"),
(r#"{}, { token = "k" }"#, "gate is required"),
(r#"gate, { token = "" }"#, "token required"),
] {
let err = run_lua(&format!(
"{OPEN_GATE}\nlocal co = require(\"assay.contactout\")\nco.client({args})"
))
.await
.unwrap_err();
assert_says(err, want, args);
}
}
const RUN_ID: &str = "fefbc2203558eb3adcea";
async fn mount_submit(server: &MockServer) {
Mock::given(method("POST"))
.and(path("/async"))
.and(header("x-api-key", "bc-key"))
.respond_with(ResponseTemplate::new(201).set_body_json(json!({
"success": true, "id": RUN_ID, "message": "Processing..."
})))
.mount(server)
.await;
}
async fn mount_result(server: &MockServer, status: u16, body: serde_json::Value) {
Mock::given(method("GET"))
.and(path(format!("/async/{RUN_ID}")))
.respond_with(ResponseTemplate::new(status).set_body_json(body))
.mount(server)
.await;
}
async fn run_server(status: u16, body: serde_json::Value) -> MockServer {
let server = MockServer::start().await;
mount_submit(&server).await;
mount_result(&server, status, body).await;
server
}
#[tokio::test]
async fn test_bettercontact_submits_then_reads_the_terminated_run() {
let server = run_server(
200,
json!({
"id": RUN_ID, "status": "terminated",
"credits_consumed": 1, "credits_left": 331,
"summary": { "total": 1, "valid": 1 },
"data": [{
"contact_first_name": "Jonathan",
"contact_last_name": "Church",
"contact_full_name": "Jonathan Church",
"contact_email_address_provider": "dropcontact",
"contact_job_title": "Managing Director",
"contact_email_address": "jonathan.church@cheaney.co.uk",
"contact_email_address_status": "deliverable",
"company_name": "Joseph Cheaney & Sons",
"company_domain": "cheaney.co.uk",
"contact_location_country": "United Kingdom"
}]
}),
)
.await;
bettercontact(
OPEN_GATE,
&server.uri(),
r#"
local p = c:find_person({ first_name = "Jonathan", last_name = "Church",
domain = "cheaney.co.uk" }, { poll_ms = 1, attempts = 3 })
assert.eq(p.full_name, "Jonathan Church")
assert.eq(p.company, "Joseph Cheaney & Sons")
assert.eq(p.domain, "cheaney.co.uk")
assert.eq(#p.emails, 1)
assert.eq(p.emails[1].address, "jonathan.church@cheaney.co.uk")
assert.eq(p.emails[1].verification_status, "PROBABLE")
assert.eq(p.emails[1].vendor_status, "deliverable")
assert.eq(p.emails[1].provider_used, "dropcontact")
assert.eq(p.provenance.provider, "bettercontact")
assert.eq(spent[1], "find_person:0")
"#,
)
.await
.unwrap();
}
#[tokio::test]
async fn test_bettercontact_never_launders_a_vendor_claim_into_verified() {
for (vendor, want) in [
("deliverable", "PROBABLE"),
("catch_all", "CATCH_ALL"),
("catch_all_safe", "CATCH_ALL"),
("catch_all_not_safe", "CATCH_ALL"),
("undeliverable", "INVALID"),
("not_found", "UNKNOWN"),
("something_new", "UNKNOWN"),
] {
let server = run_server(
200,
json!({
"id": RUN_ID, "status": "terminated",
"data": [{ "contact_email_address": "a@b.com", "contact_email_address_status": vendor }]
}),
)
.await;
bettercontact(
OPEN_GATE,
&server.uri(),
&format!(
r#"
local emails = c:resolve_email({{ first_name = "A", last_name = "B" }},
{{ poll_ms = 1, attempts = 2 }})
assert.eq(emails[1].verification_status, "{want}")
"#
),
)
.await
.unwrap();
}
}
#[tokio::test]
async fn test_bettercontact_treats_only_terminated_as_finished() {
let server = run_server(
202,
json!({ "id": RUN_ID, "status": "processing", "message": "Processing..." }),
)
.await;
bettercontact(
OPEN_GATE,
&server.uri(),
&format!(
r#"
local run = c:result("{RUN_ID}")
assert.eq(run.status, "processing")
assert.eq(run.terminated, false)
assert.eq(#run.people, 0)
local p, reason = c:find_person({{ first_name = "A", last_name = "B" }},
{{ poll_ms = 1, attempts = 2 }})
assert.eq(p, nil)
assert.eq(reason, "not_terminated")
"#
),
)
.await
.unwrap();
}
#[tokio::test]
async fn test_bettercontact_declined_budget_never_reaches_the_vendor() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/async"))
.respond_with(ResponseTemplate::new(500))
.expect(0)
.mount(&server)
.await;
bettercontact(
CLOSED_GATE,
&server.uri(),
r#"
local p, reason = c:find_person({ first_name = "A", last_name = "B" })
assert.eq(p, nil)
assert.eq(reason, "over_cap")
"#,
)
.await
.unwrap();
}
#[tokio::test]
async fn test_bettercontact_distinguishes_auth_credits_and_rate_limit() {
for (status, want) in [
(401u16, "rejected the key"),
(402, "out of credits"),
(429, "rate limited"),
] {
let server = server_returning("POST", "/async", status).await;
let err = bettercontact(
OPEN_GATE,
&server.uri(),
r#"c:submit({ { first_name = "A", last_name = "B" } })"#,
)
.await
.unwrap_err();
assert_says(err, want, &format!("HTTP {status}"));
}
}
#[tokio::test]
async fn test_bettercontact_refuses_to_build_or_submit_without_the_essentials() {
for (stmt, want) in [
(r#"bc.client(nil, { api_key = "k" })"#, "gate is required"),
(r#"bc.client(gate, { api_key = "" })"#, "api_key required"),
(
r#"bc.client(gate, { api_key = "k", base_url = "http://x" }):submit({})"#,
"at least one person",
),
] {
let err = run_lua(&format!(
"{OPEN_GATE}\nlocal bc = require(\"assay.bettercontact\")\n{stmt}"
))
.await
.unwrap_err();
assert_says(err, want, stmt);
}
}