// 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 = ` and /// pluck the right-hand side of the resulting `BinaryOp` (`c = `). 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 = ` 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")); }