Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,5 @@ build/
*.db
*.db-wal
*.db-shm
!tests/e2e/fixtures/*.db
telemetry/
33 changes: 33 additions & 0 deletions tests/e2e/fixtures/build-v14-snapshot.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
#!/usr/bin/env bash
# Regenerate tests/e2e/fixtures/v14-snapshot.db from a clean install of
# aelfrice 1.4.0. Used by scenario #5 of the e2e suite (#334).
#
# Idempotent. Run from the repo root or anywhere — paths resolve to the
# location of this script.
#
# The snapshot is checked in as a binary fixture so the e2e suite does
# not need network access to PyPI at test time. Re-run this script when
# the v1.4 install path changes (rare) or to verify the fixture is
# reproducible.
set -euo pipefail

here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
out="${here}/v14-snapshot.db"
build="$(mktemp -d)"
trap 'rm -rf "${build}"' EXIT

uv venv --python 3.12 "${build}/.venv" >/dev/null
uv pip install --python "${build}/.venv/bin/python" --quiet aelfrice==1.4.0

db="${build}/v14.sqlite3"
aelf="${build}/.venv/bin/aelf"

AELFRICE_DB="${db}" "${aelf}" lock \
"Quokkas calibrate the knob carefully on Tuesdays."
AELFRICE_DB="${db}" "${aelf}" lock \
"The aardvark counter resets at midnight."
AELFRICE_DB="${db}" "${aelf}" lock \
"Wibble pickling requires the canonical protocol header bytes."

cp "${db}" "${out}"
echo "wrote ${out} ($(wc -c <"${out}") bytes)"
Binary file added tests/e2e/fixtures/v14-snapshot.db
Binary file not shown.
185 changes: 185 additions & 0 deletions tests/e2e/test_migration_v14_to_current.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
"""E2E scenario #5 (#334): v1.4 fixture DB -> current binary -> reads still work.

Catches: schema-migration regressions on real DB shapes. Unit-test
suites historically run against fresh-init DBs which exercise only the
current schema; they do not catch a migration that drops or corrupts
existing rows. This test opens a real v1.4 snapshot built from the
v1.4.0 PyPI release (see fixtures/build-v14-snapshot.sh) and asserts:

1. The current `aelf search` reads the pre-existing locked beliefs.
2. The post-migration DB has gained the v1.5/v1.6 tables that the
current schema requires (belief_corroborations, ingest_log).
3. The pre-existing belief rows survive the upgrade (count + content).

Boundary rule: invokes the binary as installed (subprocess), not via
in-process imports. The fixture is a binary file, treated read-only —
each test copies it into the ephemeral DB path before running.
"""
from __future__ import annotations

import shutil
import sqlite3
import subprocess
from pathlib import Path
from typing import Callable

import pytest


pytestmark = pytest.mark.timeout(120)

FIXTURE = Path(__file__).parent / "fixtures" / "v14-snapshot.db"

# The three statements seeded into the v1.4 snapshot by build-v14-snapshot.sh.
# Hard-coded so a regeneration that changes the seed corpus trips review.
SEEDED_STATEMENTS = (
"Quokkas calibrate the knob carefully on Tuesdays.",
"The aardvark counter resets at midnight.",
"Wibble pickling requires the canonical protocol header bytes.",
)


def _table_names(db_path: Path) -> set[str]:
uri = f"file:{db_path}?mode=ro"
with sqlite3.connect(uri, uri=True) as conn:
rows = conn.execute(
"SELECT name FROM sqlite_master WHERE type='table'"
).fetchall()
return {str(r[0]) for r in rows}


def _belief_statements(db_path: Path) -> set[str]:
uri = f"file:{db_path}?mode=ro"
with sqlite3.connect(uri, uri=True) as conn:
rows = conn.execute("SELECT content FROM beliefs").fetchall()
return {str(r[0]) for r in rows}


@pytest.fixture
def v14_db(tmp_path: Path) -> Path:
"""Working copy of the v1.4 snapshot. The fixture file is read-only;
each test gets its own mutable copy so the binary can migrate it.
"""
if not FIXTURE.exists():
pytest.skip(
f"v1.4 snapshot missing at {FIXTURE}; "
"run tests/e2e/fixtures/build-v14-snapshot.sh"
)
dest = tmp_path / "v14-working.sqlite3"
shutil.copyfile(FIXTURE, dest)
return dest


def test_v14_snapshot_seeds_are_searchable_after_migration(
installed_aelf,
v14_db: Path,
) -> None:
"""Current binary opens the v1.4 DB and `aelf search` returns each
seeded statement. Failure means migration corrupted the belief rows
or broke the FTS index that the search path relies on.
"""
pre_tables = _table_names(v14_db)
# The v1.4 snapshot must lack the post-v1.4 tables; if it doesn't,
# the fixture is stale and the test is no longer testing migration.
assert "belief_corroborations" not in pre_tables, (
f"fixture {FIXTURE} already has belief_corroborations; "
"regenerate via build-v14-snapshot.sh against aelfrice==1.4.0"
)
assert "ingest_log" not in pre_tables, (
f"fixture {FIXTURE} already has ingest_log; "
"regenerate via build-v14-snapshot.sh against aelfrice==1.4.0"
)

# Search distinctive tokens unique to each seeded statement. The
# statement bodies appear verbatim in `aelf search` output.
for token in ("quokka", "aardvark", "wibble"):
proc = subprocess.run( # noqa: S603 — argv list, not shell
[*installed_aelf, "search", token],
env={"AELFRICE_DB": str(v14_db), "PATH": _path()},
capture_output=True,
text=True,
check=True,
timeout=60,
)
assert token in proc.stdout.lower(), (
f"expected {token!r} hit in search output; got:\n{proc.stdout!r}"
)


def test_v14_migration_grows_tables_and_preserves_belief_rows(
installed_aelf,
v14_db: Path,
) -> None:
"""After the current binary touches the v1.4 DB, the schema must
have the post-v1.4 tables (regression on additive migration) and
the original belief rows must be intact (regression on content).
"""
pre_statements = _belief_statements(v14_db)
assert pre_statements == set(SEEDED_STATEMENTS), (
f"fixture {FIXTURE} seed corpus drifted; "
f"got {sorted(pre_statements)}"
)

# Any read-write operation triggers the migration on first connect.
# `aelf locked` lists locked beliefs and is read-mostly.
subprocess.run( # noqa: S603
[*installed_aelf, "locked"],
env={"AELFRICE_DB": str(v14_db), "PATH": _path()},
capture_output=True,
text=True,
check=True,
timeout=60,
)

post_tables = _table_names(v14_db)
required_post_tables = {
"belief_corroborations", # v1.5 corroboration tracking
"ingest_log", # v2.0 #205 ingest source-of-truth
"belief_versions", # version-vector backfill
}
missing = required_post_tables - post_tables
assert not missing, (
f"migration did not add expected tables: {sorted(missing)}; "
f"observed: {sorted(post_tables)}"
)

post_statements = _belief_statements(v14_db)
assert post_statements == set(SEEDED_STATEMENTS), (
f"migration altered belief rows; pre={sorted(pre_statements)}, "
f"post={sorted(post_statements)}"
)


def _path() -> str:
"""Minimal PATH for subprocess. `installed_aelf` may be an absolute
path or `uv run aelf`; either way the resolver is the parent's PATH.
"""
import os

return os.environ.get("PATH", "")


def test_search_after_migration_returns_seeded_belief(
aelf_run: Callable[..., subprocess.CompletedProcess[str]],
tmp_path: Path,
) -> None:
"""End-to-end variant using the suite's `aelf_run` fixture, which
pins AELFRICE_DB through the same env-overlay path as the other
e2e tests. Catches regressions where the migration would succeed
via direct argv invocation but fail under the fixture's env shape.
"""
if not FIXTURE.exists():
pytest.skip(
f"v1.4 snapshot missing at {FIXTURE}; "
"run tests/e2e/fixtures/build-v14-snapshot.sh"
)
# `aelf_run` reads AELFRICE_DB from its own ephemeral_db fixture; we
# have to overwrite that target with the v1.4 snapshot before the
# first invocation so the migration runs on the real fixture data.
db = tmp_path / "aelf.sqlite3"
shutil.copyfile(FIXTURE, db)

result = aelf_run("search", "wibble")
assert "wibble" in result.stdout.lower(), (
f"expected wibble hit after migration; stdout:\n{result.stdout!r}"
)
Loading