Files

217 lines
9.3 KiB
Rust

// SPDX-License-Identifier: MIT
// Copyright (c) 2026 James Maes
//! Integration tests for [`coerce_property_value`], the boundary that turns a
//! SQL literal (as parsed by `sqlparser`) into the JSON shape the Notion API
//! expects for a single property write.
//!
//! # What is under test
//! Notion is strongly typed: a `Number` property expects `{ "number": n }`, a
//! `Title` expects an array of rich-text spans, a `Status` expects
//! `{ "status": { "name": ... } }`, and so on. SQL, by contrast, hands us loosely
//! typed literals. `coerce_property_value` bridges that gap, and these tests pin
//! down the contract for each [`PropertyType`] variant:
//! - the happy-path payload shape per type (title, number, checkbox, select,
//! multi-select, ...),
//! - rejection of values that cannot be coerced to the target type,
//! - the special handling of SQL `NULL`, which "clears" a property for the
//! nullable/collection types but is an error for types that have no empty
//! representation (e.g. `Title`, `Checkbox`).
//!
//! # Testing approach
//! Rather than construct `sqlparser` AST nodes by hand, each test feeds a SQL
//! snippet through the real parser via [`expr`] and extracts the right-hand-side
//! literal of a synthetic `WHERE` clause. This keeps the tests honest: they
//! exercise the same AST shapes the production query path produces. Expected
//! payloads are written with [`serde_json::json`] and compared structurally, so
//! key ordering in the source literal does not affect the assertion.
//!
//! As a test crate this file defines no public surface; everything here is a
//! helper or a `#[test]` and is private to the integration-test binary.
use notion_sql::{
schema::{PropertySchema, PropertyType},
value::coerce_property_value,
};
use serde_json::json;
/// Builds a minimal [`PropertySchema`] for a single column under test.
///
/// The production code keys coercion behavior off the property's
/// [`PropertyType`]; the name is incidental (it appears in error messages), so
/// tests supply a human-readable name alongside the type they want to exercise.
///
/// # Parameters
/// - `name`: the property/column name, copied into the schema as an owned `String`.
/// - `property_type`: the Notion type that drives coercion and payload shape.
///
/// # Returns
/// A `PropertySchema` carrying exactly the supplied name and type.
fn property(name: &str, property_type: PropertyType) -> PropertySchema {
PropertySchema {
name: name.to_string(),
property_type,
}
}
/// Parses a SQL literal snippet and returns it as a standalone `Expr`.
///
/// There is no public way to ask `sqlparser` for "just a literal expression",
/// so we wrap the snippet in a throwaway `SELECT * FROM t WHERE c = <sql>` and
/// pluck the right-hand side of the resulting `BinaryOp` (`c = <sql>`). That
/// right-hand side is exactly the AST node the production coercion path receives,
/// which is why the tests go through the real parser instead of hand-building
/// `Expr` values.
///
/// # Parameters
/// - `sql`: a SQL literal fragment such as `'Task'`, `3.5`, `true`, or `NULL`.
/// It is interpolated verbatim into the comparison's right-hand side.
///
/// # Returns
/// A cloned, owned copy of the parsed literal expression.
///
/// # Panics
/// Panics if the snippet fails to parse, if the parsed statement is not the
/// expected `SELECT` → `Select` → `WHERE BinaryOp` shape, or if no statement was
/// produced. In a test helper a panic is the desired failure mode: a malformed
/// input should fail loudly rather than silently feed a wrong AST to the coercer.
fn expr(sql: &str) -> sqlparser::ast::Expr {
// Embed the literal in a complete, parseable statement; `GenericDialect`
// accepts the broadest syntax so any reasonable literal form is honored.
let parsed = sqlparser::parser::Parser::parse_sql(
&sqlparser::dialect::GenericDialect {},
&format!("SELECT * FROM t WHERE c = {sql}"),
)
.unwrap();
let statement = parsed.into_iter().next().unwrap();
// Walk the AST down to the WHERE comparison. Each `if let` narrows one level;
// the literal we want is the `right` operand of the `c = <sql>` binary op.
if let sqlparser::ast::Statement::Query(query) = statement {
if let sqlparser::ast::SetExpr::Select(select) = query.body.as_ref() {
if let Some(sqlparser::ast::Expr::BinaryOp { right, .. }) = &select.selection {
return right.as_ref().clone();
}
}
}
// Reached only if the AST did not match the expected shape above.
panic!("failed to extract literal expression")
}
/// A `Title` literal becomes Notion's rich-text title array.
///
/// Notion models titles as an array of typed rich-text spans, even for a plain
/// string, so a bare SQL string must expand to a single `text` span wrapping the
/// content rather than a scalar.
#[test]
fn coerces_title_to_text_payload() {
let payload =
coerce_property_value(&property("Name", PropertyType::Title), &expr("'Task'")).unwrap();
assert_eq!(
payload,
json!({ "title": [{ "type": "text", "text": { "content": "Task" } }] })
);
}
/// A numeric literal maps to `{ "number": n }`, preserving fractional values.
///
/// Using `3.5` guards against an integer-only coercion path: the value must
/// round-trip as a JSON float, not be truncated.
#[test]
fn coerces_number_to_number_payload() {
let payload =
coerce_property_value(&property("Priority", PropertyType::Number), &expr("3.5")).unwrap();
assert_eq!(payload, json!({ "number": 3.5 }));
}
/// A SQL boolean literal maps to `{ "checkbox": bool }`.
#[test]
fn coerces_checkbox_to_bool_payload() {
let payload =
coerce_property_value(&property("Done", PropertyType::Checkbox), &expr("true")).unwrap();
assert_eq!(payload, json!({ "checkbox": true }));
}
/// Single- and multi-valued choice types produce their respective option shapes.
///
/// A `Status` (here used as the single-choice case) wraps one option name in an
/// object, while `MultiSelect` splits a comma-delimited string into an array of
/// option objects. The `'A, B'` input also asserts that surrounding whitespace
/// after the comma is trimmed (`"B"`, not `" B"`), so option names match Notion's
/// existing options.
#[test]
fn coerces_select_and_multi_select_payloads() {
let select =
coerce_property_value(&property("Status", PropertyType::Status), &expr("'Done'")).unwrap();
let multi = coerce_property_value(
&property("Tags", PropertyType::MultiSelect),
&expr("'A, B'"),
)
.unwrap();
assert_eq!(select, json!({ "status": { "name": "Done" } }));
assert_eq!(
multi,
json!({ "multi_select": [{ "name": "A" }, { "name": "B" }] })
);
}
/// Coercion fails when a literal cannot be interpreted as the target type.
///
/// Feeding the string `'maybe'` to a `Checkbox` must surface an error mentioning
/// the value is "not a valid boolean", confirming the coercer validates rather
/// than silently coercing arbitrary strings to `false`/`true`.
#[test]
fn rejects_type_mismatches() {
let error = coerce_property_value(&property("Done", PropertyType::Checkbox), &expr("'maybe'"))
.unwrap_err();
assert!(error.to_string().contains("not a valid boolean"));
}
/// SQL `NULL` clears each property type that has an "empty" representation.
///
/// Notion clears a property by writing its type-appropriate empty value, which
/// differs per type: rich-text and multi-select clear to an empty array, while
/// number/select/date clear to JSON `null`. This test pins each of those mappings
/// so a future refactor cannot accidentally send `null` where an empty array is
/// required (or vice versa), which Notion would reject or misinterpret.
#[test]
fn clears_nullable_properties_with_null() {
assert_eq!(
coerce_property_value(&property("Notes", PropertyType::RichText), &expr("NULL")).unwrap(),
json!({ "rich_text": [] })
);
assert_eq!(
coerce_property_value(&property("Priority", PropertyType::Number), &expr("NULL")).unwrap(),
json!({ "number": null })
);
assert_eq!(
coerce_property_value(&property("Status", PropertyType::Select), &expr("NULL")).unwrap(),
json!({ "select": null })
);
assert_eq!(
coerce_property_value(&property("Tags", PropertyType::MultiSelect), &expr("NULL")).unwrap(),
json!({ "multi_select": [] })
);
assert_eq!(
coerce_property_value(&property("Due", PropertyType::Date), &expr("NULL")).unwrap(),
json!({ "date": null })
);
}
/// SQL `NULL` is rejected for types that have no valid empty representation.
///
/// A `Title` cannot be blank in Notion and a `Checkbox` is always true/false, so
/// neither can be "cleared". Both must return an error whose message contains
/// "cannot be cleared", distinguishing this deliberate rejection from a generic
/// type-mismatch error.
#[test]
fn rejects_null_for_non_clearable_properties() {
let title_error =
coerce_property_value(&property("Name", PropertyType::Title), &expr("NULL")).unwrap_err();
let checkbox_error =
coerce_property_value(&property("Done", PropertyType::Checkbox), &expr("NULL"))
.unwrap_err();
assert!(title_error.to_string().contains("cannot be cleared"));
assert!(checkbox_error.to_string().contains("cannot be cleared"));
}