vipune 0.19.0

A minimal memory layer for AI agents
Documentation
//! Batch ingest tests for the memory store.

use super::*;
use crate::memory_types::{BatchIngestItemResult, IngestPolicy};

use super::crud::test_fake_embedder;

#[test]
fn test_batch_ingest_empty_batch() {
    let (_dir, mut store) = MemoryStore::test_store();

    let result = store
        .batch_ingest("test-project", vec![], IngestPolicy::ConflictAware)
        .unwrap();

    assert_eq!(result.results.len(), 0);
}

#[test]
fn test_batch_ingest_mixed_outcomes() {
    let (_dir, path) = MemoryStore::test_db_path();
    let db = crate::sqlite::Database::open(&path).unwrap();

    // Pre-populate with a memory that will cause conflicts
    let embedding = test_fake_embedder("Alice works at Microsoft").unwrap();
    db.insert(
        "test-project",
        "Alice works at Microsoft",
        &embedding,
        None,
        "fact",
        "active",
    )
    .unwrap();

    let mut store = MemoryStore::from_db_with_test_embedder(db);

    // Create a batch with mixed outcomes:
    // 0: empty -> Error
    // 1: valid unique -> Added
    // 2: conflict -> Conflicts
    // 3: valid unique -> Added
    let items = vec![
        ("", None),                         // Empty input -> Error
        ("Bob works at Google", None),      // Should add (unique)
        ("Alice works at Microsoft", None), // Should conflict with existing
        ("Charlie works at Amazon", None),  // Should add (unique)
    ];

    let result = store
        .batch_ingest("test-project", items.clone(), IngestPolicy::ConflictAware)
        .unwrap();

    // Verify we have 4 results (one per input)
    assert_eq!(result.results.len(), 4);

    // Check item 0: Error (empty input)
    match &result.results[0] {
        BatchIngestItemResult::Error { message } => {
            assert!(message.contains("empty") || message.contains("EmptyInput"));
        }
        _ => panic!("Expected Error for item 0, got {:?}", result.results[0]),
    }

    // Check item 1: Added (unique content)
    match &result.results[1] {
        BatchIngestItemResult::Added { id } => {
            assert!(!id.is_empty());
            // Verify it was actually stored
            let memory = store.get(id, "test-project").unwrap().unwrap();
            assert_eq!(memory.content, "Bob works at Google");
        }
        _ => panic!("Expected Added for item 1, got {:?}", result.results[1]),
    }

    // Check item 2: Conflicts (similar to existing memory)
    match &result.results[2] {
        BatchIngestItemResult::Conflicts {
            proposed,
            conflicts,
        } => {
            assert_eq!(proposed, "Alice works at Microsoft");
            assert!(!conflicts.is_empty());
            assert!(conflicts.iter().any(|c| c.content.contains("Alice")));
        }
        _ => panic!("Expected Conflicts for item 2, got {:?}", result.results[2]),
    }

    // Check item 3: Added (unique content)
    match &result.results[3] {
        BatchIngestItemResult::Added { id } => {
            assert!(!id.is_empty());
            // Verify it was actually stored
            let memory = store.get(id, "test-project").unwrap().unwrap();
            assert_eq!(memory.content, "Charlie works at Amazon");
        }
        _ => panic!("Expected Added for item 3, got {:?}", result.results[3]),
    }
}

#[test]
fn test_batch_ingest_deterministic_index_mapping() {
    let (_dir, path) = MemoryStore::test_db_path();
    let db = crate::sqlite::Database::open(&path).unwrap();

    // Pre-populate with conflicting content
    let embedding = test_fake_embedder("conflict with item 1").unwrap();
    db.insert(
        "test-project",
        "conflict with item 1",
        &embedding,
        None,
        "fact",
        "active",
    )
    .unwrap();

    let mut store = MemoryStore::from_db_with_test_embedder(db);

    // Create batch with specific ordering
    let items = vec![
        ("unique item 0", None),
        ("conflict with item 1", None),       // Will conflict
        ("", None),                           // Will error
        ("unique item 3", Some("{\"k\": }")), // Not JSON -> per-item Error (#271)
    ];

    let result = store
        .batch_ingest("test-project", items.clone(), IngestPolicy::ConflictAware)
        .unwrap();

    assert_eq!(result.results.len(), 4);

    // Verify index 0 is Added (unique)
    assert!(matches!(
        &result.results[0],
        BatchIngestItemResult::Added { .. }
    ));

    // Verify index 1 is Conflicts (we pre-populated a conflict)
    assert!(matches!(
        &result.results[1],
        BatchIngestItemResult::Conflicts { .. }
    ));

    // Verify index 2 is Error (empty input)
    assert!(matches!(
        &result.results[2],
        BatchIngestItemResult::Error { .. }
    ));

    // Issue #271: the add path now runs the one metadata validator the supersede
    // and update paths already ran, so a non-JSON document is a per-item Error
    // naming the rule instead of a string stored verbatim. Index mapping stays
    // intact either way, which is what this test exists to pin.
    match &result.results[3] {
        BatchIngestItemResult::Error { message } => {
            assert!(
                message.contains("invalid metadata JSON"),
                "unexpected rejection for non-JSON metadata: {message}"
            );
        }
        other => panic!("Expected Error for non-JSON metadata at index 3, got {other:?}"),
    }
}

#[test]
fn test_batch_ingest_policy_force() {
    let (_dir, path) = MemoryStore::test_db_path();
    let db = crate::sqlite::Database::open(&path).unwrap();

    // Pre-populate with conflicting content
    let embedding = test_fake_embedder("Alice works at Microsoft").unwrap();
    db.insert(
        "test-project",
        "Alice works at Microsoft",
        &embedding,
        None,
        "fact",
        "active",
    )
    .unwrap();

    let mut store = MemoryStore::from_db_with_test_embedder(db);

    // Create batch with conflicts
    let items = vec![
        ("Alice works at Microsoft", None), // Would conflict under ConflictAware
        ("Bob works at Google", None),      // Unique
    ];

    // With Force policy, should add everything (except empty/invalid)
    let result = store
        .batch_ingest("test-project", items.clone(), IngestPolicy::Force)
        .unwrap();

    assert_eq!(result.results.len(), 2);

    // Both should be Added (no conflicts with Force policy)
    match &result.results[0] {
        BatchIngestItemResult::Added { id } => {
            assert!(!id.is_empty());
            // Verify it was actually stored despite potential similarity
            let memory = store.get(id, "test-project").unwrap().unwrap();
            assert_eq!(memory.content, "Alice works at Microsoft");
        }
        _ => panic!("Expected Added for item 0 with Force policy"),
    }

    match &result.results[1] {
        BatchIngestItemResult::Added { id } => {
            assert!(!id.is_empty());
            let memory = store.get(id, "test-project").unwrap().unwrap();
            assert_eq!(memory.content, "Bob works at Google");
        }
        _ => panic!("Expected Added for item 1 with Force policy"),
    }
}

#[test]
fn test_batch_ingest_invalid_inputs() {
    let (_dir, mut store) = MemoryStore::test_store();

    // Create oversized input (100,001 characters)
    let too_long = "a".repeat(100_001);

    // Create whitespace-only input
    let whitespace = "   \t\n  ";

    let items = vec![
        ("", None),            // Empty
        ("   ", None),         // Whitespace-only
        (&too_long, None),     // Too long
        ("valid input", None), // Valid
        (whitespace, None),    // Whitespace
    ];

    let result = store
        .batch_ingest("test-project", items.clone(), IngestPolicy::ConflictAware)
        .unwrap();

    assert_eq!(result.results.len(), 5);

    // All invalid inputs should return Error
    for &idx in &[0_usize, 1, 2, 4] {
        match &result.results[idx] {
            BatchIngestItemResult::Error { .. } => {
                // Expected error
            }
            other => panic!(
                "Expected Error for invalid input at index {}, got {:?}",
                idx, other
            ),
        }
    }

    // Only the valid input should succeed
    match &result.results[3] {
        BatchIngestItemResult::Added { id } => {
            assert!(!id.is_empty());
            let memory = store.get(id, "test-project").unwrap().unwrap();
            assert_eq!(memory.content, "valid input");
        }
        _ => panic!("Expected Added for valid input at index 3"),
    }
}

#[test]
fn test_batch_ingest_all_errors() {
    let (_dir, mut store) = MemoryStore::test_store();

    // Create a batch where every item fails validation
    let items = vec![
        ("", None),    // Empty
        ("   ", None), // Whitespace-only
    ];

    let result = store
        .batch_ingest("test-project", items.clone(), IngestPolicy::ConflictAware)
        .unwrap();

    assert_eq!(result.results.len(), 2);

    for (idx, item_result) in result.results.iter().enumerate() {
        match item_result {
            BatchIngestItemResult::Error { .. } => {
                // Expected error
            }
            _ => panic!("Expected Error for all items at index {}", idx),
        }
    }
}

#[test]
fn test_batch_ingest_metadata_preservation() {
    let (_dir, mut store) = MemoryStore::test_store();

    let items = vec![
        ("content with metadata", Some(r#"{"tag": "important"}"#)),
        ("content without metadata", None),
        (
            "json metadata",
            Some(r#"{"source": "user", "priority": 1}"#),
        ),
    ];

    let result = store
        .batch_ingest("test-project", items.clone(), IngestPolicy::ConflictAware)
        .unwrap();

    assert_eq!(result.results.len(), 3);

    // Verify metadata is preserved for items 0 and 2
    if let BatchIngestItemResult::Added { id } = &result.results[0] {
        let memory = store.get(id, "test-project").unwrap().unwrap();
        assert_eq!(memory.metadata, Some(r#"{"tag": "important"}"#.to_string()));
    } else {
        panic!("Expected Added for item 0");
    }

    if let BatchIngestItemResult::Added { id } = &result.results[1] {
        let memory = store.get(id, "test-project").unwrap().unwrap();
        assert_eq!(memory.metadata, None);
    } else {
        panic!("Expected Added for item 1");
    }

    if let BatchIngestItemResult::Added { id } = &result.results[2] {
        let memory = store.get(id, "test-project").unwrap().unwrap();
        assert_eq!(
            memory.metadata,
            Some(r#"{"source": "user", "priority": 1}"#.to_string())
        );
    } else {
        panic!("Expected Added for item 2");
    }
}

/// Issue #271: the add path shares one metadata validator with the supersede and
/// update paths, so a document those two already refused is now refused here too
/// — on `add`, `ingest` and `batch_ingest`, where the refusal is reported per
/// item rather than failing the batch. A blank and a non-JSON document are both
/// covered because the two branches of the rule were previously only partly
/// reachable from this entry point.
#[test]
fn test_batch_ingest_rejects_invalid_metadata_per_item() {
    let (_dir, mut store) = MemoryStore::test_store();

    let items = vec![
        ("blank metadata", Some("   ")),
        ("non-json metadata", Some("{not json")),
        ("valid metadata", Some(r#"{"k": "v"}"#)),
    ];

    let result = store
        .batch_ingest("test-project", items, IngestPolicy::Force)
        .expect("a bad item must not fail the batch");

    for (idx, expected) in [
        (0, "metadata cannot be empty"),
        (1, "invalid metadata JSON"),
    ] {
        match &result.results[idx] {
            BatchIngestItemResult::Error { message } => assert!(
                message.contains(expected),
                "item {idx} refused for the wrong reason: {message}"
            ),
            other => panic!("Expected Error at index {idx}, got {other:?}"),
        }
    }
    assert!(
        matches!(&result.results[2], BatchIngestItemResult::Added { .. }),
        "the valid item must still be stored: {:?}",
        result.results[2]
    );

    // The two refused items wrote nothing of their own — read across every
    // status, so a row the default active-only list hides cannot inflate or
    // deflate the count.
    let stored: Vec<String> = store
        .list(
            "test-project",
            20,
            None,
            Some(["active", "candidate", "superseded", "deprecated"].as_slice()),
        )
        .unwrap()
        .into_iter()
        .map(|m| m.content)
        .collect();
    assert_eq!(stored, vec!["valid metadata".to_string()]);
}