Files

100 lines
5.1 KiB
Rust

// SPDX-License-Identifier: MIT
// Copyright (c) 2026 James Maes
//! Integration tests for CLI-level safety checks that run entirely offline.
//!
//! # Purpose
//! This test binary exercises the parts of the CLI surface that protect users
//! from destructive or unsupported operations *before* any network round-trip
//! to Notion is attempted. By construction every test here is hermetic: it
//! depends only on pure functions and in-memory schema parsing, so it requires
//! no API token, no live database, and no HTTP mocking. That keeps the suite
//! fast and deterministic in CI.
//!
//! # Behavior under test
//! - [`guard_applied_full_table_mutation`]: the guard rail that refuses to apply
//! a row-affecting statement (e.g. `DELETE`/`UPDATE`) to an entire table
//! unless the user has narrowed it with a `WHERE` clause or has explicitly
//! opted into the blast radius via `--force-all`. Both the rejection path and
//! the three "this is allowed" paths are covered.
//! - [`DatabaseSchema::unsupported_columns`]: detection of Notion property types
//! that this tool cannot faithfully represent (e.g. `formula`), which the CLI
//! uses to warn or bail when a `SELECT *` would otherwise silently drop data.
//!
//! # Testing approach
//! The tests treat the crate as an external consumer would: they import only
//! the crate's public API (`notion_sql::cli` / `notion_sql::schema`) rather than
//! reaching into private internals. Notion API payloads are simulated with
//! hand-written `serde_json` literals so schema parsing can be validated without
//! a real database fixture.
use notion_sql::{cli::guard_applied_full_table_mutation, schema::DatabaseSchema};
use serde_json::json;
/// Verifies the guard rejects an *applied* full-table mutation that has neither
/// a `WHERE` clause nor the explicit `--force-all` override.
///
/// This is the dangerous combination the guard exists to stop: `apply = true`
/// (the change is real, not a dry run), `force_all = false` (the user has not
/// opted into affecting every row), and `has_filter = false` (no `WHERE` to
/// narrow scope). The assertion checks the error message specifically mentions
/// the missing `WHERE` clause so the user gets actionable guidance rather than a
/// generic failure.
///
/// `"DELETE"` is passed as the statement label purely so the rendered message
/// reads naturally; the guard's decision does not depend on the statement text.
#[test]
fn rejects_applied_full_table_mutation_without_force_all() {
// `unwrap_err` asserts the guard returned `Err`; the call would panic here
// if the guard had (incorrectly) allowed this unsafe combination.
let error = guard_applied_full_table_mutation("DELETE", true, false, false).unwrap_err();
assert!(error.to_string().contains("requires a WHERE clause"));
}
/// Verifies the three argument combinations the guard must permit.
///
/// Each `unwrap` asserts the guard returned `Ok`, panicking the test if any of
/// these legitimate cases were wrongly rejected:
/// - dry run (`apply = false`): nothing is mutated, so scope is irrelevant;
/// - applied with `--force-all` (`force_all = true`): the user has explicitly
/// accepted affecting every row;
/// - applied with a filter (`has_filter = true`): a `WHERE` clause already
/// bounds the blast radius.
#[test]
fn allows_dry_run_or_forced_full_table_mutation() {
// Dry run: not applied, so a full-table scope is harmless.
guard_applied_full_table_mutation("DELETE", false, false, false).unwrap();
// Applied but explicitly forced: user has opted into the full-table effect.
guard_applied_full_table_mutation("DELETE", true, true, false).unwrap();
// Applied with a filter present: scope is already narrowed by a WHERE clause.
guard_applied_full_table_mutation("DELETE", true, false, true).unwrap();
}
/// Verifies that parsing a Notion schema flags property types the tool cannot
/// represent, so a `SELECT *` over them can be caught instead of silently
/// dropping columns.
///
/// The fixture defines two properties: a `title` (a fully supported type) and a
/// `formula` (a type this tool does not support, since formula values are
/// computed by Notion and have no faithful local representation). The test
/// asserts that `unsupported_columns` reports the `Formula` property by name.
///
/// `join(" ")` flattens the returned `Vec<String>` into a single string purely
/// to make the `contains` check independent of the vector's length or ordering;
/// the local variable is named `error` because the CLI surfaces this list as a
/// user-facing error/warning, even though the value itself is just joined names.
#[test]
fn rejects_select_all_with_unsupported_columns() {
// Build a schema from a minimal hand-written stand-in for a Notion database
// response, mixing one supported and one unsupported property type.
let schema = DatabaseSchema::from_notion_database(&json!({
"properties": {
"Name": { "type": "title", "title": {} },
"Formula": { "type": "formula", "formula": {} }
}
}))
.unwrap();
let error = schema.unsupported_columns().join(" ");
assert!(error.contains("Formula"));
}