test/openagents_web/live/memory_live_test.exs

58e6347eeb72 · 10 KB

defmodule OpenAgentsWeb.MemoryLiveTest do
  @moduledoc """
  `/memory` is a page, not a panel inside the conversation.

  It was the latter: a sidebar row swapped the transcript out for it, which
  made it reachable only from chat and gave it no address. These tests visit
  it directly, which is what a reader now does.

  What they hold is the account boundary and the exactness of destructive
  controls: memory is a claim a system makes about a person, so correction
  must supersede rather than overwrite, and "forget" must state which of its
  three scopes it means before it acts.
  """

  use OpenAgentsWeb.ConnCase, async: false
  import Phoenix.LiveViewTest

  alias OpenAgents.Conversations
  alias OpenAgents.Conversations.Message
  alias OpenAgents.ProfileMemory

  test "memory surface is accessible, exact, source-dated, and account-scoped", %{conn: conn} do
    token = "memory-surface-browser-credential-000000000000000"
    %{record: record, source: source} = create_profile_memory(token, "I prefer concise answers")
    conn = log_in_github_user(conn, token)
    assert {:ok, view, _html} = live(conn, ~p"/memory")

    html = render(view)

    assert html =~ ~s(id="memory-manager")
    assert html =~ ~s(aria-labelledby="memory-heading")
    assert html =~ "Memory in your account"
    assert html =~ "follow your authenticated Sarah account across browsers"
    assert html =~ "I prefer concise answers"
    assert html =~ "OWNER_STATEMENT" or html =~ "owner_statement"
    assert html =~ Date.to_iso8601(DateTime.to_date(source.inserted_at))
    assert has_element?(view, "#memory-record-#{record.id}[data-status=active]")
    assert has_element?(view, "#memory-claim-#{record.id}")
    assert has_element?(view, "#forget-category-#{record.id}")
    assert has_element?(view, "#export-memory[href='/memory/export']")
    assert has_element?(view, "#delete-data-form label[for='privacy_confirmation']")
    assert has_element?(view, "#delete-data-form input#privacy_confirmation[type='text']")
    assert has_element?(view, "#delete-data-form button#delete-all-data[type='submit']")
    refute has_element?(view, "#message-form")

    # The conversation is a different page now, reached from the sidebar.
    refute has_element?(view, "#message-form")
  end

  test "the way back is a link, and the board preference is the account's to set", %{conn: conn} do
    token = "memory-preference-browser-credential-0000000000000"
    conn = log_in_github_user(conn, token)
    user = github_user(token)
    assert {:ok, view, _html} = live(conn, ~p"/memory")

    # A page is left by navigating, not by toggling a panel that is not there.
    assert has_element?(view, "#toggle-memory[href='/sarah']")

    assert render(view) =~ "Your account appears on the board"

    view |> element("#toggle-leaderboard-preference") |> render_click()

    assert render(view) =~ "no longer appears on the public leaderboard"
    assert render(view) =~ "Your account is withheld from the board."
    assert OpenAgents.Repo.get!(OpenAgents.Accounts.User, user.id).public_leaderboard_opted_out

    view |> element("#toggle-leaderboard-preference") |> render_click()

    assert render(view) =~ "Your account appears on the board"
    refute OpenAgents.Repo.get!(OpenAgents.Accounts.User, user.id).public_leaderboard_opted_out
  end

  test "correction preserves supersession and reconciles another open tab", %{conn: conn} do
    token = "memory-correction-browser-credential-0000000000000"
    %{record: record} = create_profile_memory(token, "I prefer concise answers")
    user = github_user(token)
    conn = log_in_github_user(conn, token)
    assert {:ok, first, _html} = live(conn, ~p"/memory")
    assert {:ok, second, _html} = live(conn, ~p"/memory")

    first
    |> form("#memory-record-#{record.id} form", %{"claim" => "I prefer concise, direct answers"})
    |> render_submit()

    assert eventually(fn ->
             html = render(second)

             html =~ "I prefer concise, direct answers" and
               has_element?(second, "#memory-record-#{record.id}[data-status=superseded]")
           end)

    conversation = Conversations.get_conversation_for_user(user)
    owner = Conversations.get_conversation_owner!(conversation)
    assert {:ok, [replacement]} = ProfileMemory.list_current(owner)
    assert replacement.claim == "I prefer concise, direct answers"
    assert replacement.supersedes_record_id == record.id
  end

  test "forget requires inline confirmation and disappears from future snapshots", %{conn: conn} do
    token = "memory-forget-ui-browser-credential-000000000000"
    %{record: record} = create_profile_memory(token, "I prefer concise answers")
    user = github_user(token)
    conn = log_in_github_user(conn, token)
    assert {:ok, view, _html} = live(conn, ~p"/memory")

    view |> element("#forget-record-#{record.id}") |> render_click()
    assert has_element?(view, "#memory-confirmation")
    assert render(view) =~ "Confirm destructive action"

    view |> element("#cancel-memory-action") |> render_click()
    refute has_element?(view, "#memory-confirmation")
    assert has_element?(view, "#memory-record-#{record.id}[data-status=active]")

    view |> element("#forget-record-#{record.id}") |> render_click()
    view |> element("#confirm-memory-forget") |> render_click()

    assert has_element?(view, "#memory-record-#{record.id}[data-status=forgotten]")
    assert render(view) =~ "Forgot 1 memory record(s) in this account."

    conversation = Conversations.get_conversation_for_user(user)
    owner = Conversations.get_conversation_owner!(conversation)
    assert {:ok, snapshot} = ProfileMemory.capture_snapshot(owner)
    assert {:ok, []} = ProfileMemory.list_active(owner, snapshot)
  end

  test "category and whole-account controls confirm their exact destructive breadth", %{
    conn: conn
  } do
    token = "memory-bulk-forget-browser-credential-00000000000"
    %{record: preference} = create_profile_memory(token, "I prefer concise answers")
    %{record: project} = create_profile_memory(token, "My project is One", "project")
    user = github_user(token)
    conn = log_in_github_user(conn, token)
    assert {:ok, view, _html} = live(conn, ~p"/memory")

    view |> element("#forget-category-#{project.id}") |> render_click()
    assert render(view) =~ "Forget every active project memory in this account?"
    view |> element("#confirm-memory-forget") |> render_click()
    assert has_element?(view, "#memory-record-#{project.id}[data-status=forgotten]")
    assert has_element?(view, "#memory-record-#{preference.id}[data-status=active]")

    view |> element("#forget-all-memory") |> render_click()
    assert render(view) =~ "Forget every active profile memory in this account?"
    view |> element("#confirm-memory-forget") |> render_click()
    assert has_element?(view, "#memory-record-#{preference.id}[data-status=forgotten]")

    conversation = Conversations.get_conversation_for_user(user)
    owner = Conversations.get_conversation_owner!(conversation)
    assert {:ok, []} = ProfileMemory.list_current(owner)
  end

  test "supersession audit renders the chain of prior claims, superseded time, and consent kind",
       %{
         conn: conn
       } do
    token = "memory-audit-browser-credential-00000000000000"
    %{record: record} = create_profile_memory(token, "I prefer concise answers")
    conn = log_in_github_user(conn, token)
    assert {:ok, view, _html} = live(conn, ~p"/memory")

    view
    |> form("#memory-record-#{record.id} form", %{
      "claim" => "I prefer concise, direct answers"
    })
    |> render_submit()

    html = render(view)

    assert has_element?(view, "#supersession-audit ol li")
    assert html =~ "I prefer concise answers"
    assert html =~ "I prefer concise, direct answers"
    assert html =~ "owner_assertion"
  end

  test "retract appends a tombstone and does not delete the row", %{conn: conn} do
    token = "memory-retract-browser-credential-000000000000"
    %{record: record} = create_profile_memory(token, "Retract me")
    conn = log_in_admin_user(conn, token)
    assert {:ok, view, _html} = live(conn, ~p"/memory")

    view |> element("#retract-record-#{record.id}") |> render_click()

    assert has_element?(view, "#memory-record-#{record.id}[data-status=forgotten]")
    assert OpenAgents.Repo.get!(OpenAgents.ProfileMemory.Record, record.id).status == "forgotten"
  end

  test "unauthorized retraction redirects and does not mutate the record", %{conn: conn} do
    token = "memory-unauthorized-retract-browser-0000000000"
    %{record: record} = create_profile_memory(token, "No retract")
    conn = log_in_github_user(conn, token)
    assert {:ok, view, _html} = live(conn, ~p"/memory")

    assert {:error, {:redirect, %{to: "/"}}} =
             render_click(view, "retract_record", %{
               "record_id" => record.id,
               "expected_generation" => to_string(record.generation)
             })

    assert OpenAgents.Repo.get!(OpenAgents.ProfileMemory.Record, record.id).status == "active"
  end

  defp create_profile_memory(token, claim, category \\ "preference") do
    assert {:ok, conversation} = Conversations.ensure_conversation(github_user(token))
    owner = Conversations.get_conversation_owner!(conversation)

    source =
      OpenAgents.Repo.insert!(%Message{
        conversation_id: conversation.id,
        role: "user",
        content: "Remember that #{claim}",
        status: "complete"
      })

    assert {:ok, %{record: record}} =
             ProfileMemory.remember_explicit(owner, %{
               category: category,
               claim: claim,
               creator: "user_explicit",
               provenance: %{
                 "operation" => "test_explicit_memory",
                 "tool_payload" => "INTERNAL_TEST_PAYLOAD"
               },
               sources: [%{source_ref: "message:#{source.id}", kind: "owner_statement"}]
             })

    %{record: record, source: source, owner: owner}
  end

  # A correction lands in another tab through PubSub, so the second view is
  # polled rather than assumed to have caught up.
  defp eventually(assertion, timeout \\ 1_000) do
    deadline = System.monotonic_time(:millisecond) + timeout
    do_eventually(assertion, deadline)
  end

  defp do_eventually(assertion, deadline) do
    if assertion.() do
      true
    else
      if System.monotonic_time(:millisecond) >= deadline do
        false
      else
        receive do
          _message -> :ok
        after
          10 -> :ok
        end

        do_eventually(assertion, deadline)
      end
    end
  end
end