polypixel-memoir-core 0.4.0

Memoir memory substrate as an embeddable Rust library
Documentation
//! Integration tests for `Client::edit`.

#![cfg(feature = "integration")]

use std::time::Duration;

use memoir_core::client::ClientError;
use sea_orm::{ConnectionTrait, Statement, Value};

mod common;

/// Counts `reprocess` jobs with `payload.reason = 'stale'` anchored on `pid`.
///
/// The edit cascade (epic 0011 ticket 0012) enqueues exactly one such job when
/// an episodic source's content or event_at changes; metadata-only edits
/// enqueue none. Asserting the queued job — not the drained end state —
/// decouples this trigger's test from worker + LLM timing.
async fn count_stale_reprocess_jobs(client: &common::TestClient, pid: &str) -> anyhow::Result<i64> {
    let db = client.raw_db().await?;
    let row = db
        .query_one_raw(Statement::from_sql_and_values(
            sea_orm::DatabaseBackend::Postgres,
            r#"
            SELECT COUNT(*) AS n
            FROM memory_jobs
            WHERE source_pid = $1
              AND kind = 'reprocess'
              AND payload ->> 'reason' = 'stale'
            "#,
            [Value::from(pid)],
        ))
        .await?
        .ok_or_else(|| anyhow::anyhow!("count returned no row"))?;
    Ok(row.try_get("", "n")?)
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn should_overwrite_content_on_edit() -> anyhow::Result<()> {
    let client = common::fresh_client().await?;
    let scope = common::fresh_scope();

    let written = client.remember("original text", scope.clone()).await?;
    let edited = client.edit(&written.pid).content("corrected text").await?;

    assert_eq!(edited.pid, written.pid, "edit must return the same pid");
    assert_eq!(edited.content, "corrected text", "content must be overwritten");
    assert_eq!(edited.scope, scope, "scope must be preserved");

    let reloaded = client.recall(&written.pid).await?;
    assert_eq!(reloaded.content, "corrected text");

    Ok(())
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn should_preserve_created_at_on_edit() -> anyhow::Result<()> {
    let client = common::fresh_client().await?;
    let scope = common::fresh_scope();

    let written = client.remember("first", scope.clone()).await?;
    tokio::time::sleep(Duration::from_millis(1100)).await;

    let edited = client.edit(&written.pid).content("second").await?;

    assert_eq!(edited.created_at, written.created_at, "created_at must not change");
    assert!(
        edited.updated_at > written.updated_at,
        "updated_at must bump on edit (was {}, now {})",
        written.updated_at,
        edited.updated_at,
    );

    Ok(())
}

// `edit` rejecting an unknown pid (NotFound) and a non-episodic kind
// (UnsupportedEdit) are pure pre-UPDATE checks, unit-tested against
// StubStore::edit in `store::mod` tests — they bail before any SQL runs,
// so a real DB proves nothing the stub doesn't.

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn should_allow_edit_on_superseded_memory() -> anyhow::Result<()> {
    let client = common::fresh_client().await?;
    let scope = common::fresh_scope();

    let original = client.remember("v1 text", scope.clone()).await?;
    let winner = client.remember("v2 text", scope.clone()).await?;

    let db = client.raw_db().await?;
    db.execute_raw(Statement::from_sql_and_values(
        sea_orm::DatabaseBackend::Postgres,
        "INSERT INTO supersession_events (loser_pid, winner_pid) VALUES ($1, $2)",
        [Value::from(original.pid.as_str()), Value::from(winner.pid.as_str())],
    ))
    .await?;

    let edited = client.edit(&original.pid).content("v1 text corrected").await?;
    assert_eq!(edited.content, "v1 text corrected");
    assert!(
        edited.supersession.is_some(),
        "supersession state must be preserved across edits to soft-deleted rows",
    );

    Ok(())
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn should_set_event_at_via_edit() -> anyhow::Result<()> {
    let client = common::fresh_client().await?;
    let scope = common::fresh_scope();

    let written = client.remember("deployment happened", scope.clone()).await?;
    assert!(
        written.event_at.is_none(),
        "fresh remember without .event_at must default to None"
    );

    let deploy_time = chrono::DateTime::parse_from_rfc3339("2026-04-01T15:30:00Z")?;
    let edited = client.edit(&written.pid).event_at(deploy_time).await?;

    assert_eq!(edited.event_at, Some(deploy_time));
    assert_eq!(
        edited.content, "deployment happened",
        "content must be untouched when only event_at is set"
    );

    Ok(())
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn should_replace_metadata_on_edit() -> anyhow::Result<()> {
    let client = common::fresh_client().await?;
    let scope = common::fresh_scope();

    let written = client
        .remember("with metadata", scope.clone())
        .metadata(serde_json::json!({ "version": 1, "tag": "old" }))
        .await?;

    let edited = client
        .edit(&written.pid)
        .metadata(serde_json::json!({ "version": 2 }))
        .await?;

    assert_eq!(edited.metadata, serde_json::json!({ "version": 2 }));
    assert_eq!(edited.content, "with metadata", "content untouched");

    Ok(())
}

// NOTE: this asserts pure validation (the reserved-key check in
// `client/edit.rs::execute`, which runs before any store call). It is an
// integration test only because the validation is coupled to `Client`,
// which needs live Postgres + Qdrant to construct. To make it a unit test,
// the reserved-key check should move onto the type that owns
// RESERVED_PAYLOAD_KEYS as a method, then be unit-tested there — tracked as
// a future testability refactor, out of scope for the 2026-05-28 audit.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn should_reject_edit_with_reserved_metadata_key() -> anyhow::Result<()> {
    let client = common::fresh_client().await?;
    let scope = common::fresh_scope();

    let written = client.remember("reserved key test", scope.clone()).await?;
    let result = client
        .edit(&written.pid)
        .metadata(serde_json::json!({ "pid": "smuggled" }))
        .await;

    match result {
        Err(ClientError::ReservedMetadataKey { key }) => {
            assert_eq!(key, "pid");
        }
        other => panic!("expected ReservedMetadataKey rejection; got {other:?}"),
    }
    Ok(())
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn should_enqueue_embed_job_after_content_edit() -> anyhow::Result<()> {
    let client = common::fresh_client().await?;
    let scope = common::fresh_scope();

    let written = client.remember("edit-enqueue-check", scope.clone()).await?;
    common::wait_until_indexed(
        &client,
        &written.pid,
        &scope,
        "edit-enqueue-check",
        Duration::from_secs(15),
    )
    .await?;

    let _ = client.edit(&written.pid).content("edited content").await?;

    let db = client.raw_db().await?;
    let row = db
        .query_one_raw(Statement::from_sql_and_values(
            sea_orm::DatabaseBackend::Postgres,
            r#"
            SELECT COUNT(*) AS n
            FROM memory_jobs
            WHERE source_pid = $1
              AND kind = 'embed'
              AND payload ->> 'origin' = 'edit'
            "#,
            [Value::from(written.pid.as_str())],
        ))
        .await?
        .ok_or_else(|| anyhow::anyhow!("count returned no row"))?;
    let n: i64 = row.try_get("", "n")?;
    assert_eq!(
        n, 1,
        "content edits must enqueue exactly one Embed job with origin = 'edit' for the pid"
    );

    Ok(())
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn should_not_enqueue_embed_job_when_only_metadata_changes() -> anyhow::Result<()> {
    let client = common::fresh_client().await?;
    let scope = common::fresh_scope();

    let written = client.remember("metadata-only-edit", scope.clone()).await?;
    common::wait_until_indexed(
        &client,
        &written.pid,
        &scope,
        "metadata-only-edit",
        Duration::from_secs(15),
    )
    .await?;

    let _ = client
        .edit(&written.pid)
        .metadata(serde_json::json!({ "note": "changed" }))
        .await?;

    let db = client.raw_db().await?;
    let row = db
        .query_one_raw(Statement::from_sql_and_values(
            sea_orm::DatabaseBackend::Postgres,
            r#"
            SELECT COUNT(*) AS n
            FROM memory_jobs
            WHERE source_pid = $1
              AND kind = 'embed'
              AND payload ->> 'origin' = 'edit'
            "#,
            [Value::from(written.pid.as_str())],
        ))
        .await?
        .ok_or_else(|| anyhow::anyhow!("count returned no row"))?;
    let n: i64 = row.try_get("", "n")?;
    assert_eq!(
        n, 0,
        "metadata-only edits must not enqueue re-embed jobs; embedding vector is still representative of content",
    );

    Ok(())
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn should_enqueue_stale_reprocess_after_content_edit() -> anyhow::Result<()> {
    let client = common::fresh_client().await?;
    let scope = common::fresh_scope();

    let written = client.remember("my favorite color is green", scope.clone()).await?;
    let count = count_stale_reprocess_jobs(&client, &written.pid).await?;

    let _ = client.edit(&written.pid).content("my favorite color is blue").await?;

    let after = count_stale_reprocess_jobs(&client, &written.pid).await?;
    assert_eq!(
        after - count,
        1,
        "a content edit must cascade exactly one stale Reprocess job to re-derive the source's semantics",
    );

    Ok(())
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn should_enqueue_stale_reprocess_after_event_at_edit() -> anyhow::Result<()> {
    let client = common::fresh_client().await?;
    let scope = common::fresh_scope();

    let written = client.remember("the deploy happened", scope.clone()).await?;
    let count = count_stale_reprocess_jobs(&client, &written.pid).await?;

    let when = chrono::DateTime::parse_from_rfc3339("2026-05-27T00:00:00Z")?;
    let _ = client.edit(&written.pid).event_at(when).await?;

    let after = count_stale_reprocess_jobs(&client, &written.pid).await?;
    assert_eq!(
        after - count,
        1,
        "an event_at edit must cascade a stale Reprocess: the source's event-time feeds derived event-times",
    );

    Ok(())
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn should_not_enqueue_reprocess_when_only_metadata_changes() -> anyhow::Result<()> {
    let client = common::fresh_client().await?;
    let scope = common::fresh_scope();

    let written = client.remember("a remembered thing", scope.clone()).await?;

    let _ = client
        .edit(&written.pid)
        .metadata(serde_json::json!({ "note": "reclassified" }))
        .await?;

    let after = count_stale_reprocess_jobs(&client, &written.pid).await?;
    assert_eq!(
        after, 0,
        "a metadata-only edit cannot change extraction output, so it must not cascade a reprocess",
    );

    Ok(())
}