minco-plugin-feedback 0.3.1

AI-ready client feedback loops with screenshots, voice, discussion, persistence, and an embeddable widget for Minco
Documentation
#![cfg(any(feature = "postgres", feature = "sqlite"))]

use minco_plugin_feedback::{
    CreateFeedbackInput, FeedbackAccessToken, FeedbackAiContext, FeedbackAttachment,
    FeedbackAttachmentKind, FeedbackContext, FeedbackId, FeedbackKind, FeedbackListFilter,
    FeedbackMessage, FeedbackPriority, FeedbackStatus, FeedbackStore, FeedbackStoreError,
    FeedbackThread, hash_access_token,
};
use std::collections::BTreeSet;
use uuid::Uuid;

fn thread(project_id: &str, title: &str) -> FeedbackThread {
    FeedbackThread::create(CreateFeedbackInput {
        project_id: project_id.into(),
        kind: FeedbackKind::Bug,
        priority: FeedbackPriority::High,
        title: title.into(),
        description: "The feedback persistence adapter must preserve this report.".into(),
        context: FeedbackContext {
            page_url: "https://example.test/review".into(),
            route_name: Some("review".into()),
            release_id: Some("test-release".into()),
            environment: Some("test".into()),
            request_id: Some("request-persistence".into()),
            user_agent: None,
            viewport: None,
            client_subject: None,
        },
        tags: BTreeSet::from(["persistence".into()]),
    })
    .expect("the persistence test fixture must be valid")
}

async fn verify_store(store: &impl FeedbackStore) -> Vec<FeedbackId> {
    store.ready().await.expect("the database must be ready");

    let access_token = FeedbackAccessToken::generate();
    let token_hash = hash_access_token(&access_token);
    let first = thread("persistence-test", "Durable feedback");
    let first_id = first.id;
    store
        .create(first.clone(), token_hash.clone())
        .await
        .expect("creating feedback must succeed");

    assert!(matches!(
        store.create(first.clone(), token_hash.clone()).await,
        Err(FeedbackStoreError::AlreadyExists(id)) if id == first_id
    ));
    assert_eq!(
        store.get(first_id).await.expect("read must succeed"),
        Some(first.clone())
    );
    assert_eq!(
        store
            .get_for_client(first_id, &token_hash)
            .await
            .expect("authorized client read must succeed"),
        Some(first.clone())
    );
    assert!(
        store
            .get_for_client(
                first_id,
                &hash_access_token(&FeedbackAccessToken::generate()),
            )
            .await
            .expect("unauthorized client read must fail closed without an error")
            .is_none()
    );

    let second = thread("other-project", "Filtered feedback");
    let second_id = second.id;
    store
        .create(second, "other-token-hash".into())
        .await
        .expect("creating the filter control record must succeed");

    let filtered = store
        .list(FeedbackListFilter {
            status: Some(FeedbackStatus::New),
            project_id: Some("persistence-test".into()),
            limit: 10,
        })
        .await
        .expect("listing feedback must succeed");
    assert_eq!(filtered.len(), 1);
    assert_eq!(filtered[0].id, first_id);

    let mut updated = first;
    updated.append_message(
        FeedbackMessage::client("The report still reproduces.")
            .expect("the update fixture must be valid"),
    );
    updated.append_message(
        FeedbackMessage::developer(Some("developer".into()), "Private triage note", false)
            .expect("the internal-note fixture must be valid"),
    );
    updated.add_attachment(FeedbackAttachment {
        id: Uuid::now_v7(),
        kind: FeedbackAttachmentKind::Screenshot,
        object_key: format!("feedback/{first_id}/screenshot.png"),
        file_name: "screenshot.png".into(),
        content_type: "image/png".into(),
        size_bytes: 3,
        sha256: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".into(),
        created_at: chrono::Utc::now(),
        transcript: None,
    });
    updated
        .transition(FeedbackStatus::NeedsClarification, None)
        .expect("the persisted workflow transition must be valid");
    store
        .save(updated.clone(), 1)
        .await
        .expect("saving the expected revision must succeed");
    let persisted = store
        .get(first_id)
        .await
        .expect("updated read must succeed")
        .expect("updated feedback must remain present");
    assert_eq!(persisted, updated);
    assert_eq!(persisted.status, FeedbackStatus::NeedsClarification);
    assert_eq!(persisted.attachments.len(), 1);
    assert_eq!(persisted.attachments[0].content_type, "image/png");
    assert_eq!(persisted.client_view().messages.len(), 1);
    assert_eq!(persisted.messages.len(), 2);

    let ai_context = FeedbackAiContext::from_thread(persisted.clone());
    assert_eq!(ai_context, FeedbackAiContext::from_thread(persisted));
    assert!(ai_context.to_markdown().contains("Private triage note"));

    let actual_revision = updated.revision;
    assert!(matches!(
        store.save(updated, 1).await,
        Err(FeedbackStoreError::ConcurrentModification {
            id,
            expected_revision: 1,
            actual_revision: observed_revision,
        }) if id == first_id && observed_revision == actual_revision
    ));

    vec![first_id, second_id]
}

#[cfg(feature = "sqlite")]
#[tokio::test]
async fn sqlite_adapter_runs_migrations_and_store_contract_against_sqlite() {
    let pool = sqlx::SqlitePool::connect("sqlite::memory:")
        .await
        .expect("the in-memory SQLite database must connect");
    let store = minco_plugin_feedback::SqliteFeedbackStore::new(pool);
    store
        .migrate()
        .await
        .expect("the SQLite feedback migration must apply");
    let feedback_history_exists = sqlx::query_scalar::<_, i64>(
        "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = '_minco_feedback_migrations'",
    )
    .fetch_one(store.pool())
    .await
    .expect("the SQLite migration history table must be inspectable");
    assert_eq!(feedback_history_exists, 1);
    let _ = verify_store(&store).await;
}

#[cfg(feature = "postgres")]
#[tokio::test]
async fn postgres_adapter_runs_migrations_and_store_contract_when_configured() {
    let Ok(database_url) = std::env::var("MINCO_FEEDBACK_TEST_POSTGRES_URL") else {
        eprintln!(
            "skipping PostgreSQL adapter contract; set MINCO_FEEDBACK_TEST_POSTGRES_URL to run it"
        );
        return;
    };

    let pool = sqlx::PgPool::connect(&database_url)
        .await
        .expect("the PostgreSQL test database must connect");
    let store = minco_plugin_feedback::PostgresFeedbackStore::new(pool);
    store
        .migrate()
        .await
        .expect("the PostgreSQL feedback migration must apply");
    let feedback_history_exists = sqlx::query_scalar::<_, bool>(
        "SELECT to_regclass('_minco_feedback_migrations') IS NOT NULL",
    )
    .fetch_one(store.pool())
    .await
    .expect("the PostgreSQL migration history table must be inspectable");
    assert!(feedback_history_exists);
    let ids = verify_store(&store).await;

    for id in ids {
        sqlx::query("DELETE FROM minco_feedback_threads WHERE id = $1")
            .bind(id.0)
            .execute(store.pool())
            .await
            .expect("the PostgreSQL test record must be removed");
    }
}