AkurAI Build
Menu

Public repository / 89d76abbb01a83de2e71d39c6321fc0d7ef581be

AkurAI-Framework / crates/collections/tests/crud.rs

Latest change 11133e70d7ecc0416eabf8ca4bb82fd3dd873147 - feat(collections): add akurai-collections auto-generated-API engine by Ólafur Búi Ólafsson

//! End-to-end tests for the collections engine over a real on-disk B+tree.
//!
//! Every test gets its own file under `CARGO_TARGET_TMPDIR` (never `/tmp`).

use std::path::PathBuf;
use std::sync::atomic::{AtomicU32, Ordering};

use akurai_collections::{meta, CollError, Collection, Field, FieldKind, Store};
use akurai_json::{parse, Value};

static COUNTER: AtomicU32 = AtomicU32::new(0);

/// A unique db path inside the crate's target tmp dir.
fn db_path() -> PathBuf {
    let n = COUNTER.fetch_add(1, Ordering::Relaxed);
    let mut p = PathBuf::from(env!("CARGO_TARGET_TMPDIR"));
    p.push(format!("coll-test-{n}-{}.db", std::process::id()));
    let _ = std::fs::remove_file(&p);
    p
}

/// The canonical test collection: a `posts` resource.
fn posts() -> Collection {
    Collection::new(
        "posts",
        vec![
            Field::new("title", FieldKind::Text).required().embed(),
            Field::new("body", FieldKind::Text),
            Field::new("views", FieldKind::Int),
            Field::new("rating", FieldKind::Float),
            Field::new("draft", FieldKind::Bool),
        ],
    )
}

fn obj(pairs: Vec<(&str, Value)>) -> Value {
    Value::Object(pairs.into_iter().map(|(k, v)| (k.to_string(), v)).collect())
}

fn s(v: &str) -> Value {
    Value::Str(v.to_string())
}

#[test]
fn create_get_list_update_delete_round_trip() {
    let coll = posts();
    let mut store = Store::open(db_path()).unwrap();

    let created = store
        .create(
            &coll,
            obj(vec![
                ("title", s("Hello")),
                ("body", s("World")),
                ("views", Value::Int(3)),
            ]),
        )
        .unwrap();

    let id = created.get("id").and_then(Value::as_i64).unwrap();
    assert_eq!(id, 1);
    assert!(created.get("created").and_then(Value::as_i64).is_some());
    assert_eq!(created.get("title").and_then(Value::as_str), Some("Hello"));
    // Optional fields not supplied are omitted, not stored as null.
    assert!(created.get("rating").is_none());

    // get
    let fetched = store.get(&coll, 1).unwrap().unwrap();
    assert_eq!(fetched, created);
    assert!(store.get(&coll, 999).unwrap().is_none());

    // list (newest first)
    let second = store
        .create(&coll, obj(vec![("title", s("Second"))]))
        .unwrap();
    let listed = store.list(&coll, None).unwrap();
    assert_eq!(listed.len(), 2);
    assert_eq!(listed[0], second);
    assert_eq!(listed[1], created);

    // list with limit
    let limited = store.list(&coll, Some(1)).unwrap();
    assert_eq!(limited.len(), 1);
    assert_eq!(limited[0], second);

    // update (partial)
    let updated = store
        .update(&coll, 1, obj(vec![("body", s("Edited"))]))
        .unwrap()
        .unwrap();
    assert_eq!(updated.get("body").and_then(Value::as_str), Some("Edited"));
    assert_eq!(updated.get("title").and_then(Value::as_str), Some("Hello"));
    assert_eq!(updated.get("id").and_then(Value::as_i64), Some(1));
    // update of a missing id is None
    assert!(store
        .update(&coll, 999, obj(vec![("body", s("x"))]))
        .unwrap()
        .is_none());

    // delete
    assert!(store.delete(&coll, 1).unwrap());
    assert!(!store.delete(&coll, 1).unwrap());
    assert!(store.get(&coll, 1).unwrap().is_none());
    assert_eq!(store.list(&coll, None).unwrap().len(), 1);
}

#[test]
fn float_field_accepts_and_coerces_int() {
    let coll = posts();
    let mut store = Store::open(db_path()).unwrap();
    let rec = store
        .create(
            &coll,
            obj(vec![("title", s("F")), ("rating", Value::Int(4))]),
        )
        .unwrap();
    assert_eq!(rec.get("rating"), Some(&Value::Float(4.0)));
}

#[test]
fn validation_rejects_missing_required() {
    let coll = posts();
    let mut store = Store::open(db_path()).unwrap();
    let err = store
        .create(&coll, obj(vec![("body", s("no title"))]))
        .unwrap_err();
    assert!(matches!(err, CollError::Validation(_)));
    // Null counts as missing for a required field.
    let err = store
        .create(&coll, obj(vec![("title", Value::Null)]))
        .unwrap_err();
    assert!(matches!(err, CollError::Validation(_)));
}

#[test]
fn validation_rejects_wrong_type() {
    let coll = posts();
    let mut store = Store::open(db_path()).unwrap();
    let err = store
        .create(
            &coll,
            obj(vec![("title", s("T")), ("views", s("not an int"))]),
        )
        .unwrap_err();
    assert!(matches!(err, CollError::Validation(_)));
}

#[test]
fn validation_rejects_unknown_and_reserved_fields() {
    let coll = posts();
    let mut store = Store::open(db_path()).unwrap();
    let unknown = store
        .create(&coll, obj(vec![("title", s("T")), ("nope", s("x"))]))
        .unwrap_err();
    assert!(matches!(unknown, CollError::Validation(_)));

    let reserved = store
        .create(&coll, obj(vec![("title", s("T")), ("id", Value::Int(5))]))
        .unwrap_err();
    assert!(matches!(reserved, CollError::Validation(_)));
}

#[test]
fn input_must_be_an_object() {
    let coll = posts();
    let mut store = Store::open(db_path()).unwrap();
    let err = store.create(&coll, Value::Int(3)).unwrap_err();
    assert!(matches!(err, CollError::Validation(_)));
}

#[test]
fn update_re_validates_changed_fields() {
    let coll = posts();
    let mut store = Store::open(db_path()).unwrap();
    store.create(&coll, obj(vec![("title", s("T"))])).unwrap();

    // wrong type on patch
    assert!(matches!(
        store
            .update(&coll, 1, obj(vec![("views", s("bad"))]))
            .unwrap_err(),
        CollError::Validation(_)
    ));
    // unknown field on patch
    assert!(matches!(
        store
            .update(&coll, 1, obj(vec![("nope", s("x"))]))
            .unwrap_err(),
        CollError::Validation(_)
    ));
    // required field cannot be nulled
    assert!(matches!(
        store
            .update(&coll, 1, obj(vec![("title", Value::Null)]))
            .unwrap_err(),
        CollError::Validation(_)
    ));
    // optional field CAN be cleared via null
    let cleared = store
        .update(&coll, 1, obj(vec![("body", s("temp"))]))
        .unwrap()
        .unwrap();
    assert!(cleared.get("body").is_some());
    let cleared = store
        .update(&coll, 1, obj(vec![("body", Value::Null)]))
        .unwrap()
        .unwrap();
    assert!(cleared.get("body").is_none());
}

#[test]
fn ids_auto_increment_and_do_not_collide() {
    let coll = posts();
    let mut store = Store::open(db_path()).unwrap();
    let mut ids = Vec::new();
    for i in 0..50 {
        let rec = store
            .create(&coll, obj(vec![("title", s(&format!("p{i}")))]))
            .unwrap();
        ids.push(rec.get("id").and_then(Value::as_i64).unwrap());
    }
    assert_eq!(ids, (1..=50).collect::<Vec<_>>());
    // deletion does not let ids be reused
    store.delete(&coll, 50).unwrap();
    let rec = store
        .create(&coll, obj(vec![("title", s("after"))]))
        .unwrap();
    assert_eq!(rec.get("id").and_then(Value::as_i64), Some(51));
}

#[test]
fn records_and_counter_persist_across_reopen() {
    let coll = posts();
    let path = db_path();
    {
        let mut store = Store::open(&path).unwrap();
        store
            .create(&coll, obj(vec![("title", s("kept"))]))
            .unwrap();
        store
            .create(&coll, obj(vec![("title", s("kept2"))]))
            .unwrap();
    }
    // reopen
    let mut store = Store::open(&path).unwrap();
    assert_eq!(store.list(&coll, None).unwrap().len(), 2);
    assert_eq!(
        store
            .get(&coll, 1)
            .unwrap()
            .unwrap()
            .get("title")
            .and_then(Value::as_str),
        Some("kept")
    );
    // counter survived: next id is 3, not 1
    let rec = store.create(&coll, obj(vec![("title", s("new"))])).unwrap();
    assert_eq!(rec.get("id").and_then(Value::as_i64), Some(3));
}

#[test]
fn collections_are_isolated_by_prefix() {
    let posts = posts();
    let tags = Collection::new("tags", vec![Field::new("name", FieldKind::Text).required()]);
    let mut store = Store::open(db_path()).unwrap();

    store.create(&posts, obj(vec![("title", s("P"))])).unwrap();
    let t = store.create(&tags, obj(vec![("name", s("rust"))])).unwrap();
    // both got id 1 — counters are per-collection
    assert_eq!(t.get("id").and_then(Value::as_i64), Some(1));
    assert_eq!(store.list(&posts, None).unwrap().len(), 1);
    assert_eq!(store.list(&tags, None).unwrap().len(), 1);
    // a post id is not visible from the tags collection
    assert!(store.get(&tags, 1).unwrap().is_some());
    assert_eq!(
        store
            .get(&tags, 1)
            .unwrap()
            .unwrap()
            .get("name")
            .and_then(Value::as_str),
        Some("rust")
    );
}

#[test]
fn search_is_case_insensitive_substring_over_text_fields_and_respects_limit() {
    let coll = posts();
    let mut store = Store::open(db_path()).unwrap();
    store
        .create(
            &coll,
            obj(vec![("title", s("Rust is Great")), ("body", s("systems"))]),
        )
        .unwrap();
    store
        .create(
            &coll,
            obj(vec![
                ("title", s("Python")),
                ("body", s("scripting in RUST too")),
            ]),
        )
        .unwrap();
    store
        .create(
            &coll,
            obj(vec![("title", s("Go")), ("views", Value::Int(9))]),
        )
        .unwrap();

    // case-insensitive, matches across multiple text fields
    let hits = store.search(&coll, "rust", None).unwrap();
    assert_eq!(hits.len(), 2);
    // newest first
    assert_eq!(hits[0].get("title").and_then(Value::as_str), Some("Python"));

    // limit respected
    let limited = store.search(&coll, "rust", Some(1)).unwrap();
    assert_eq!(limited.len(), 1);
    assert_eq!(
        limited[0].get("title").and_then(Value::as_str),
        Some("Python")
    );

    // no match
    assert!(store.search(&coll, "haskell", None).unwrap().is_empty());

    // non-text fields are not searched
    assert!(store.search(&coll, "9", None).unwrap().is_empty());

    // empty query matches everything
    assert_eq!(store.search(&coll, "", None).unwrap().len(), 3);
}

#[test]
fn meta_emits_expected_structure() {
    let colls = vec![
        posts(),
        Collection::new("tags", vec![Field::new("name", FieldKind::Text).required()]),
    ];
    let manifest = meta(&colls);

    // Round-trip through the serializer to assert exact wire shape.
    let json = manifest.to_json();
    let reparsed = parse(&json).unwrap();
    assert_eq!(reparsed, manifest);

    let collections = manifest.get("collections").unwrap();
    let arr = match collections {
        Value::Array(a) => a,
        _ => panic!("collections should be an array"),
    };
    assert_eq!(arr.len(), 2);

    let first = &arr[0];
    assert_eq!(first.get("name").and_then(Value::as_str), Some("posts"));

    let fields = match first.get("fields").unwrap() {
        Value::Array(a) => a,
        _ => panic!("fields should be an array"),
    };
    assert_eq!(fields.len(), 5);
    let title = &fields[0];
    assert_eq!(title.get("name").and_then(Value::as_str), Some("title"));
    assert_eq!(title.get("type").and_then(Value::as_str), Some("text"));
    assert_eq!(title.get("required").and_then(Value::as_bool), Some(true));
    assert_eq!(title.get("embed").and_then(Value::as_bool), Some(true));

    let endpoints = first.get("endpoints").unwrap();
    assert_eq!(
        endpoints.get("list").and_then(Value::as_str),
        Some("GET /api/collections/posts/records")
    );
    assert_eq!(
        endpoints.get("create").and_then(Value::as_str),
        Some("POST /api/collections/posts/records")
    );
    assert_eq!(
        endpoints.get("get").and_then(Value::as_str),
        Some("GET /api/collections/posts/records/<id>")
    );
    assert_eq!(
        endpoints.get("update").and_then(Value::as_str),
        Some("PATCH /api/collections/posts/records/<id>")
    );
    assert_eq!(
        endpoints.get("delete").and_then(Value::as_str),
        Some("DELETE /api/collections/posts/records/<id>")
    );
    assert_eq!(
        endpoints.get("search").and_then(Value::as_str),
        Some("GET /api/collections/posts/records?search=<query>")
    );
}