use faucet_core::Sink;
use faucet_core::check::ProbeStatus;
use faucet_core::idempotency::{
COMMIT_TOKEN_SCOPE_COL, COMMIT_TOKEN_TABLE, COMMIT_TOKEN_TOKEN_COL, format_token,
};
use faucet_sink_postgres::{PostgresColumnMapping, PostgresSink, PostgresSinkConfig};
use serde_json::json;
use sqlx::Row;
use testcontainers::{ContainerAsync, ImageExt, runners::AsyncRunner};
use testcontainers_modules::postgres::Postgres;
async fn start_postgres() -> (ContainerAsync<Postgres>, String) {
let image = Postgres::default().with_tag("16-alpine");
let container: ContainerAsync<Postgres> =
image.start().await.expect("postgres container start");
let port = container
.get_host_port_ipv4(5432)
.await
.expect("postgres port");
let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres");
(container, url)
}
async fn create_jsonb_table(url: &str) {
let pool = sqlx::PgPool::connect(url).await.expect("pool connect");
sqlx::query("CREATE TABLE events (data JSONB NOT NULL)")
.execute(&pool)
.await
.expect("create table");
pool.close().await;
}
async fn jsonb_row_count(url: &str) -> i64 {
let pool = sqlx::PgPool::connect(url).await.expect("pool connect");
let count: i64 = sqlx::query_scalar("SELECT COUNT(*)::BIGINT FROM events")
.fetch_one(&pool)
.await
.expect("count");
pool.close().await;
count
}
async fn stored_token(url: &str, scope: &str) -> Option<String> {
let pool = sqlx::PgPool::connect(url).await.expect("pool connect");
let sql = format!(
"SELECT \"{COMMIT_TOKEN_TOKEN_COL}\" FROM \"{COMMIT_TOKEN_TABLE}\" \
WHERE \"{COMMIT_TOKEN_SCOPE_COL}\" = $1"
);
let row = sqlx::query(&sql)
.bind(scope)
.fetch_optional(&pool)
.await
.expect("token read");
pool.close().await;
row.map(|r| r.get::<String, _>(0))
}
#[tokio::test(flavor = "multi_thread")]
async fn idempotent_write_persists_data_and_token_atomically() {
let (_container, url) = start_postgres().await;
create_jsonb_table(&url).await;
let config = PostgresSinkConfig::new(&url, "events").with_batch_size(0);
let sink = PostgresSink::new(config).await.expect("sink new");
assert!(
sink.supports_idempotent_writes(),
"postgres sink advertises idempotent writes"
);
assert_eq!(
sink.last_committed_token("scope-a")
.await
.expect("token read"),
None,
"fresh scope has no committed token"
);
let token = format_token(1);
let records = vec![json!({"k": "v1"}), json!({"k": "v2"})];
let written = sink
.write_batch_idempotent(&records, "scope-a", &token)
.await
.expect("idempotent write");
assert_eq!(written, 2);
assert_eq!(jsonb_row_count(&url).await, 2);
assert_eq!(
sink.last_committed_token("scope-a")
.await
.expect("token read")
.as_deref(),
Some(token.as_str())
);
assert_eq!(
stored_token(&url, "scope-a").await.as_deref(),
Some(token.as_str())
);
}
#[tokio::test(flavor = "multi_thread")]
async fn idempotent_write_upserts_token_on_repeat_scope() {
let (_container, url) = start_postgres().await;
create_jsonb_table(&url).await;
let config = PostgresSinkConfig::new(&url, "events").with_batch_size(0);
let sink = PostgresSink::new(config).await.expect("sink new");
let t1 = format_token(1);
let t2 = format_token(2);
sink.write_batch_idempotent(&[json!({"n": 1})], "s", &t1)
.await
.expect("write 1");
sink.write_batch_idempotent(&[json!({"n": 2})], "s", &t2)
.await
.expect("write 2");
assert_eq!(jsonb_row_count(&url).await, 2);
assert_eq!(
sink.last_committed_token("s")
.await
.expect("read")
.as_deref(),
Some(t2.as_str()),
"watermark must advance to the latest token"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn idempotent_write_auto_map_mode_commits_data_and_token() {
let (_container, url) = start_postgres().await;
let pool = sqlx::PgPool::connect(&url).await.expect("pool connect");
sqlx::query("CREATE TABLE rows_t (id BIGINT, name TEXT)")
.execute(&pool)
.await
.expect("create table");
pool.close().await;
let config = PostgresSinkConfig::new(&url, "rows_t")
.column_mapping(PostgresColumnMapping::AutoMap)
.with_batch_size(0);
let sink = PostgresSink::new(config).await.expect("sink new");
let token = format_token(5);
let written = sink
.write_batch_idempotent(&[json!({"id": 9, "name": "z"})], "auto-scope", &token)
.await
.expect("idempotent automap write");
assert_eq!(written, 1);
let pool = sqlx::PgPool::connect(&url).await.expect("pool connect");
let name: String = sqlx::query_scalar("SELECT name FROM rows_t WHERE id = 9")
.fetch_one(&pool)
.await
.expect("read back");
pool.close().await;
assert_eq!(name, "z");
assert_eq!(
sink.last_committed_token("auto-scope")
.await
.expect("read")
.as_deref(),
Some(token.as_str())
);
}
#[tokio::test(flavor = "multi_thread")]
async fn auto_map_into_missing_table_errors_with_no_columns() {
let (_container, url) = start_postgres().await;
let config = PostgresSinkConfig::new(&url, "does_not_exist")
.column_mapping(PostgresColumnMapping::AutoMap)
.with_batch_size(0);
let sink = PostgresSink::new(config).await.expect("sink new");
let err = sink
.write_batch(&[json!({"a": 1})])
.await
.expect_err("missing table must error");
let msg = err.to_string();
assert!(
msg.contains("no columns or does not exist"),
"must surface the missing-table error; got: {msg}"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn auto_map_skips_records_with_no_matching_columns_then_noop() {
let (_container, url) = start_postgres().await;
let pool = sqlx::PgPool::connect(&url).await.expect("pool connect");
sqlx::query("CREATE TABLE t (id BIGINT, name TEXT)")
.execute(&pool)
.await
.expect("create table");
pool.close().await;
let config = PostgresSinkConfig::new(&url, "t")
.column_mapping(PostgresColumnMapping::AutoMap)
.with_batch_size(0);
let sink = PostgresSink::new(config).await.expect("sink new");
let written = sink
.write_batch(&[json!({"nope": 1}), json!({"other": 2})])
.await
.expect("write");
assert_eq!(written, 0, "all records skipped → zero written");
let pool = sqlx::PgPool::connect(&url).await.expect("pool connect");
let count: i64 = sqlx::query_scalar("SELECT COUNT(*)::BIGINT FROM t")
.fetch_one(&pool)
.await
.expect("count");
pool.close().await;
assert_eq!(count, 0, "no INSERT issued when nothing matches");
}
#[tokio::test(flavor = "multi_thread")]
async fn auto_map_non_object_record_errors() {
let (_container, url) = start_postgres().await;
let pool = sqlx::PgPool::connect(&url).await.expect("pool connect");
sqlx::query("CREATE TABLE t (id BIGINT)")
.execute(&pool)
.await
.expect("create table");
pool.close().await;
let config = PostgresSinkConfig::new(&url, "t")
.column_mapping(PostgresColumnMapping::AutoMap)
.with_batch_size(0);
let sink = PostgresSink::new(config).await.expect("sink new");
let err = sink
.write_batch(&[json!(42)])
.await
.expect_err("non-object must error");
assert!(
err.to_string()
.contains("AutoMap requires JSON object records"),
"got: {err}"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn check_passes_against_live_server_and_metadata_methods() {
let (_container, url) = start_postgres().await;
create_jsonb_table(&url).await;
let config = PostgresSinkConfig::new(&url, "events").with_schema("public");
let sink = PostgresSink::new(config).await.expect("sink new");
let schema = sink.config_schema();
assert!(schema.get("properties").is_some(), "schema: {schema}");
let uri = sink.dataset_uri();
assert!(
!uri.contains("postgres:postgres"),
"credentials leaked: {uri}"
);
assert!(
uri.contains("table=public.events"),
"dataset_uri must carry the schema-qualified table: {uri}"
);
let report = sink
.check(&faucet_core::check::CheckContext::default())
.await
.expect("check");
assert_eq!(report.probes.len(), 1);
assert_eq!(report.probes[0].name, "auth");
assert!(
matches!(report.probes[0].status, ProbeStatus::Pass),
"auth probe must pass: {:?}",
report.probes[0].status
);
assert_eq!(sink.write_batch(&[]).await.expect("empty"), 0);
}