463 lines
21 KiB
Bash
463 lines
21 KiB
Bash
#!/bin/bash
|
|
set -e
|
|
|
|
echo "=== Life OS Test Suite Fix ==="
|
|
echo "Fixes: event loop conflicts, seed data approach, search/api 422"
|
|
echo ""
|
|
|
|
# ──────────────────────────────────────────────────────────────
|
|
# Step 1: Install psycopg2-binary for sync seed data
|
|
# ──────────────────────────────────────────────────────────────
|
|
echo "[1/6] Installing psycopg2-binary..."
|
|
docker exec lifeos-dev pip install psycopg2-binary --break-system-packages -q 2>/dev/null
|
|
echo " Done"
|
|
|
|
# ──────────────────────────────────────────────────────────────
|
|
# Step 1b: Update pytest.ini for session-scoped loop
|
|
# ──────────────────────────────────────────────────────────────
|
|
echo " Updating pytest.ini..."
|
|
cat > /opt/lifeos/dev/pytest.ini << 'PYTESTINI_EOF'
|
|
[pytest]
|
|
asyncio_mode = auto
|
|
asyncio_default_fixture_loop_scope = session
|
|
testpaths = tests
|
|
addopts = -v --tb=short
|
|
PYTESTINI_EOF
|
|
|
|
# ──────────────────────────────────────────────────────────────
|
|
# Step 2: Probe the app to verify engine location
|
|
# ──────────────────────────────────────────────────────────────
|
|
echo "[2/6] Probing app structure..."
|
|
docker exec lifeos-dev python3 -c "
|
|
import os
|
|
os.environ['DATABASE_URL'] = 'postgresql+asyncpg://postgres:UCTOQDZiUhN8U@lifeos-db:5432/lifeos_test'
|
|
from core.database import engine
|
|
print(f' Engine found: {engine.url}')
|
|
print(f' Engine class: {type(engine).__name__}')
|
|
" 2>&1 || { echo "ERROR: Could not find engine in core.database"; exit 1; }
|
|
|
|
# ──────────────────────────────────────────────────────────────
|
|
# Step 3: Probe actual table columns for seed data accuracy
|
|
# ──────────────────────────────────────────────────────────────
|
|
echo "[3/6] Probing table columns for seed data..."
|
|
SEED_COLUMNS=$(docker exec lifeos-db psql -U postgres -d lifeos_dev -t -A -c "
|
|
SELECT table_name, string_agg(column_name || ':' || is_nullable || ':' || data_type, ',')
|
|
FROM information_schema.columns
|
|
WHERE table_schema='public'
|
|
AND table_name IN ('domains','areas','projects','tasks','contacts','notes','meetings','decisions','appointments','weblink_folders','lists','links','weblinks','capture_inbox','daily_focus','time_entries','files')
|
|
GROUP BY table_name
|
|
ORDER BY table_name;
|
|
")
|
|
echo " Columns retrieved for seed tables"
|
|
|
|
# Verify critical table names exist
|
|
echo " Verifying table names..."
|
|
docker exec lifeos-db psql -U postgres -d lifeos_test -t -A -c "
|
|
SELECT tablename FROM pg_tables WHERE schemaname='public'
|
|
AND tablename IN ('domains','areas','projects','tasks','contacts','notes','meetings','decisions','appointments','weblink_folders','lists','links','weblinks','capture_inbox','daily_focus','time_entries','files')
|
|
ORDER BY tablename;
|
|
"
|
|
|
|
# Check if capture table is 'capture' or 'capture_inbox'
|
|
CAPTURE_TABLE=$(docker exec lifeos-db psql -U postgres -d lifeos_test -t -A -c "
|
|
SELECT tablename FROM pg_tables WHERE schemaname='public' AND tablename LIKE 'capture%' LIMIT 1;
|
|
")
|
|
echo " Capture table name: $CAPTURE_TABLE"
|
|
|
|
# Check daily_focus vs daily_focus
|
|
FOCUS_TABLE=$(docker exec lifeos-db psql -U postgres -d lifeos_test -t -A -c "
|
|
SELECT tablename FROM pg_tables WHERE schemaname='public' AND tablename LIKE '%focus%' LIMIT 1;
|
|
")
|
|
echo " Focus table name: $FOCUS_TABLE"
|
|
|
|
# ──────────────────────────────────────────────────────────────
|
|
# Step 4: Write fixed registry.py
|
|
# ──────────────────────────────────────────────────────────────
|
|
echo "[4/6] Writing fixed registry.py..."
|
|
cat > /opt/lifeos/dev/tests/registry.py << 'REGISTRY_EOF'
|
|
"""
|
|
Route registry - imports app, runs introspection once, exposes route data.
|
|
Disposes the async engine after introspection to avoid event loop conflicts.
|
|
"""
|
|
import os
|
|
import asyncio
|
|
|
|
# Point the app at the test database BEFORE importing
|
|
os.environ["DATABASE_URL"] = "postgresql+asyncpg://postgres:UCTOQDZiUhN8U@lifeos-db:5432/lifeos_test"
|
|
|
|
from main import app
|
|
from tests.introspect import introspect_routes, classify_route
|
|
|
|
# Build route registry from live app
|
|
ROUTE_REGISTRY = introspect_routes(app)
|
|
|
|
# Classify routes into buckets for parametrized tests
|
|
GET_NO_PARAMS = [r for r in ROUTE_REGISTRY if r.method == "GET" and not r.path_params]
|
|
GET_WITH_PARAMS = [r for r in ROUTE_REGISTRY if r.method == "GET" and r.path_params]
|
|
POST_CREATE = [r for r in ROUTE_REGISTRY if r.method == "POST" and r.kind == "create"]
|
|
POST_EDIT = [r for r in ROUTE_REGISTRY if r.method == "POST" and r.kind == "edit"]
|
|
POST_DELETE = [r for r in ROUTE_REGISTRY if r.method == "POST" and r.kind == "delete"]
|
|
POST_ACTION = [r for r in ROUTE_REGISTRY if r.method == "POST" and r.kind in ("action", "toggle")]
|
|
|
|
# Map route prefixes to seed fixture keys
|
|
PREFIX_TO_SEED = {
|
|
"/domains": "domain",
|
|
"/areas": "area",
|
|
"/projects": "project",
|
|
"/tasks": "task",
|
|
"/notes": "note",
|
|
"/links": "link",
|
|
"/contacts": "contact",
|
|
"/lists": "list",
|
|
"/meetings": "meeting",
|
|
"/decisions": "decision",
|
|
"/weblinks": "weblink",
|
|
"/weblinks/folders": "weblink_folder",
|
|
"/appointments": "appointment",
|
|
"/focus": "focus",
|
|
"/capture": "capture",
|
|
"/time": "task",
|
|
"/files": None,
|
|
"/admin/trash": None,
|
|
}
|
|
|
|
def resolve_path(path_template, seeds):
|
|
"""Replace {id} placeholders with real seed UUIDs."""
|
|
import re
|
|
result = path_template
|
|
for param in re.findall(r"\{(\w+)\}", path_template):
|
|
# Find prefix for this route
|
|
for prefix, seed_key in sorted(PREFIX_TO_SEED.items(), key=lambda x: -len(x[0])):
|
|
if path_template.startswith(prefix) and seed_key and seed_key in seeds:
|
|
result = result.replace(f"{{{param}}}", str(seeds[seed_key]))
|
|
break
|
|
return result
|
|
|
|
# CRITICAL: Dispose the async engine created at import time.
|
|
# It was bound to whatever event loop existed during collection.
|
|
# When tests run, pytest-asyncio creates a NEW event loop.
|
|
# The engine will lazily recreate its connection pool on that new loop.
|
|
try:
|
|
from core.database import engine
|
|
loop = asyncio.new_event_loop()
|
|
loop.run_until_complete(engine.dispose())
|
|
loop.close()
|
|
except Exception:
|
|
pass # If disposal fails, tests will still try to proceed
|
|
REGISTRY_EOF
|
|
echo " registry.py written"
|
|
|
|
# ──────────────────────────────────────────────────────────────
|
|
# Step 5: Write fixed conftest.py
|
|
# ──────────────────────────────────────────────────────────────
|
|
echo "[5/6] Writing fixed conftest.py..."
|
|
cat > /opt/lifeos/dev/tests/conftest.py << 'CONFTEST_EOF'
|
|
"""
|
|
Test fixtures - uses psycopg2 (sync) for seed data to avoid event loop conflicts.
|
|
Seeds are session-scoped and committed so the app can see them via its own engine.
|
|
"""
|
|
import asyncio
|
|
import uuid
|
|
import pytest
|
|
import psycopg2
|
|
from httpx import AsyncClient, ASGITransport
|
|
|
|
from tests.registry import app
|
|
|
|
TEST_DB_DSN = "host=lifeos-db port=5432 dbname=lifeos_test user=postgres password=UCTOQDZiUhN8U"
|
|
|
|
# ── Fixed seed UUIDs (stable across test runs) ──────────────
|
|
SEED_IDS = {
|
|
"domain": "a0000000-0000-0000-0000-000000000001",
|
|
"area": "a0000000-0000-0000-0000-000000000002",
|
|
"project": "a0000000-0000-0000-0000-000000000003",
|
|
"task": "a0000000-0000-0000-0000-000000000004",
|
|
"contact": "a0000000-0000-0000-0000-000000000005",
|
|
"note": "a0000000-0000-0000-0000-000000000006",
|
|
"meeting": "a0000000-0000-0000-0000-000000000007",
|
|
"decision": "a0000000-0000-0000-0000-000000000008",
|
|
"appointment": "a0000000-0000-0000-0000-000000000009",
|
|
"weblink_folder": "a0000000-0000-0000-0000-00000000000a",
|
|
"list": "a0000000-0000-0000-0000-00000000000b",
|
|
"link": "a0000000-0000-0000-0000-00000000000c",
|
|
"weblink": "a0000000-0000-0000-0000-00000000000d",
|
|
"capture": "a0000000-0000-0000-0000-00000000000e",
|
|
"focus": "a0000000-0000-0000-0000-00000000000f",
|
|
}
|
|
|
|
|
|
# ── Session-scoped event loop ───────────────────────────────
|
|
# All async tests share one loop so the app's engine pool stays valid.
|
|
@pytest.fixture(scope="session")
|
|
def event_loop():
|
|
loop = asyncio.new_event_loop()
|
|
yield loop
|
|
loop.close()
|
|
|
|
|
|
# ── Sync DB connection for seed management ──────────────────
|
|
@pytest.fixture(scope="session")
|
|
def sync_conn():
|
|
conn = psycopg2.connect(TEST_DB_DSN)
|
|
conn.autocommit = False
|
|
yield conn
|
|
conn.close()
|
|
|
|
|
|
# ── Seed data (session-scoped, committed) ───────────────────
|
|
@pytest.fixture(scope="session")
|
|
def all_seeds(sync_conn):
|
|
"""Insert all seed data once. Committed so the app's engine can see it."""
|
|
cur = sync_conn.cursor()
|
|
d = SEED_IDS
|
|
|
|
try:
|
|
# Domain
|
|
cur.execute("""
|
|
INSERT INTO domains (id, name, color, description, sort_order, is_deleted, created_at, updated_at)
|
|
VALUES (%s, 'Test Domain', '#FF5733', 'Auto test domain', 0, false, now(), now())
|
|
ON CONFLICT (id) DO NOTHING
|
|
""", (d["domain"],))
|
|
|
|
# Area
|
|
cur.execute("""
|
|
INSERT INTO areas (id, name, domain_id, description, status, sort_order, is_deleted, created_at, updated_at)
|
|
VALUES (%s, 'Test Area', %s, 'Auto test area', 'active', 0, false, now(), now())
|
|
ON CONFLICT (id) DO NOTHING
|
|
""", (d["area"], d["domain"]))
|
|
|
|
# Project
|
|
cur.execute("""
|
|
INSERT INTO projects (id, name, domain_id, area_id, description, status, priority, sort_order, is_deleted, created_at, updated_at)
|
|
VALUES (%s, 'Test Project', %s, %s, 'Auto test project', 'active', 2, 0, false, now(), now())
|
|
ON CONFLICT (id) DO NOTHING
|
|
""", (d["project"], d["domain"], d["area"]))
|
|
|
|
# Task
|
|
cur.execute("""
|
|
INSERT INTO tasks (id, title, domain_id, project_id, description, priority, status, sort_order, is_deleted, created_at, updated_at)
|
|
VALUES (%s, 'Test Task', %s, %s, 'Auto test task', 2, 'todo', 0, false, now(), now())
|
|
ON CONFLICT (id) DO NOTHING
|
|
""", (d["task"], d["domain"], d["project"]))
|
|
|
|
# Contact
|
|
cur.execute("""
|
|
INSERT INTO contacts (id, first_name, last_name, company, email, is_deleted, created_at, updated_at)
|
|
VALUES (%s, 'Test', 'Contact', 'TestCorp', 'test@example.com', false, now(), now())
|
|
ON CONFLICT (id) DO NOTHING
|
|
""", (d["contact"],))
|
|
|
|
# Note
|
|
cur.execute("""
|
|
INSERT INTO notes (id, title, domain_id, body, content_format, is_deleted, created_at, updated_at)
|
|
VALUES (%s, 'Test Note', %s, 'Test body content', 'markdown', false, now(), now())
|
|
ON CONFLICT (id) DO NOTHING
|
|
""", (d["note"], d["domain"]))
|
|
|
|
# Meeting
|
|
cur.execute("""
|
|
INSERT INTO meetings (id, title, meeting_date, status, is_deleted, created_at, updated_at)
|
|
VALUES (%s, 'Test Meeting', '2025-06-15', 'scheduled', false, now(), now())
|
|
ON CONFLICT (id) DO NOTHING
|
|
""", (d["meeting"],))
|
|
|
|
# Decision
|
|
cur.execute("""
|
|
INSERT INTO decisions (id, title, status, impact, is_deleted, created_at, updated_at)
|
|
VALUES (%s, 'Test Decision', 'decided', 'high', false, now(), now())
|
|
ON CONFLICT (id) DO NOTHING
|
|
""", (d["decision"],))
|
|
|
|
# Appointment
|
|
cur.execute("""
|
|
INSERT INTO appointments (id, title, start_at, end_at, all_day, is_deleted, created_at, updated_at)
|
|
VALUES (%s, 'Test Appointment', '2025-06-15 10:00:00', '2025-06-15 11:00:00', false, false, now(), now())
|
|
ON CONFLICT (id) DO NOTHING
|
|
""", (d["appointment"],))
|
|
|
|
# Weblink folder
|
|
cur.execute("""
|
|
INSERT INTO weblink_folders (id, name, is_deleted, created_at, updated_at)
|
|
VALUES (%s, 'Test Folder', false, now(), now())
|
|
ON CONFLICT (id) DO NOTHING
|
|
""", (d["weblink_folder"],))
|
|
|
|
# List
|
|
cur.execute("""
|
|
INSERT INTO lists (id, name, domain_id, project_id, list_type, is_deleted, created_at, updated_at)
|
|
VALUES (%s, 'Test List', %s, %s, 'checklist', false, now(), now())
|
|
ON CONFLICT (id) DO NOTHING
|
|
""", (d["list"], d["domain"], d["project"]))
|
|
|
|
# Link
|
|
cur.execute("""
|
|
INSERT INTO links (id, label, url, domain_id, is_deleted, created_at, updated_at)
|
|
VALUES (%s, 'Test Link', 'https://example.com', %s, false, now(), now())
|
|
ON CONFLICT (id) DO NOTHING
|
|
""", (d["link"], d["domain"]))
|
|
|
|
# Weblink
|
|
cur.execute("""
|
|
INSERT INTO weblinks (id, label, url, folder_id, is_deleted, created_at, updated_at)
|
|
VALUES (%s, 'Test Weblink', 'https://example.com/wl', %s, false, now(), now())
|
|
ON CONFLICT (id) DO NOTHING
|
|
""", (d["weblink"], d["weblink_folder"]))
|
|
|
|
# Capture
|
|
cur.execute("""
|
|
INSERT INTO capture_inbox (id, raw_text, status, is_deleted, created_at, updated_at)
|
|
VALUES (%s, 'Test capture item', 'pending', false, now(), now())
|
|
ON CONFLICT (id) DO NOTHING
|
|
""", (d["capture"],))
|
|
|
|
# Daily focus
|
|
cur.execute("""
|
|
INSERT INTO daily_focus (id, task_id, focus_date, is_completed, created_at)
|
|
VALUES (%s, %s, CURRENT_DATE, false, now())
|
|
ON CONFLICT (id) DO NOTHING
|
|
""", (d["focus"], d["task"]))
|
|
|
|
sync_conn.commit()
|
|
except Exception as e:
|
|
sync_conn.rollback()
|
|
raise RuntimeError(f"Seed data insertion failed: {e}") from e
|
|
|
|
yield d
|
|
|
|
# Cleanup: delete all seed data (reverse dependency order)
|
|
try:
|
|
cur.execute("DELETE FROM daily_focus WHERE id = %s", (d["focus"],))
|
|
cur.execute("DELETE FROM capture_inbox WHERE id = %s", (d["capture"],))
|
|
cur.execute("DELETE FROM weblinks WHERE id = %s", (d["weblink"],))
|
|
cur.execute("DELETE FROM links WHERE id = %s", (d["link"],))
|
|
cur.execute("DELETE FROM lists WHERE id = %s", (d["list"],))
|
|
cur.execute("DELETE FROM weblink_folders WHERE id = %s", (d["weblink_folder"],))
|
|
cur.execute("DELETE FROM appointments WHERE id = %s", (d["appointment"],))
|
|
cur.execute("DELETE FROM decisions WHERE id = %s", (d["decision"],))
|
|
cur.execute("DELETE FROM meetings WHERE id = %s", (d["meeting"],))
|
|
cur.execute("DELETE FROM notes WHERE id = %s", (d["note"],))
|
|
cur.execute("DELETE FROM contacts WHERE id = %s", (d["contact"],))
|
|
cur.execute("DELETE FROM tasks WHERE id = %s", (d["task"],))
|
|
cur.execute("DELETE FROM projects WHERE id = %s", (d["project"],))
|
|
cur.execute("DELETE FROM areas WHERE id = %s", (d["area"],))
|
|
cur.execute("DELETE FROM domains WHERE id = %s", (d["domain"],))
|
|
sync_conn.commit()
|
|
except Exception:
|
|
sync_conn.rollback()
|
|
finally:
|
|
cur.close()
|
|
|
|
|
|
# ── HTTP client (function-scoped) ───────────────────────────
|
|
@pytest.fixture
|
|
async def client():
|
|
transport = ASGITransport(app=app)
|
|
async with AsyncClient(transport=transport, base_url="http://test") as c:
|
|
yield c
|
|
CONFTEST_EOF
|
|
echo " conftest.py written"
|
|
|
|
# ──────────────────────────────────────────────────────────────
|
|
# Step 6: Write fixed test_smoke_dynamic.py
|
|
# ──────────────────────────────────────────────────────────────
|
|
echo "[6/6] Writing fixed test_smoke_dynamic.py..."
|
|
cat > /opt/lifeos/dev/tests/test_smoke_dynamic.py << 'SMOKE_EOF'
|
|
"""
|
|
Dynamic smoke tests - auto-parametrized from introspected routes.
|
|
Tests that all GET endpoints return 200 (or acceptable alternatives).
|
|
"""
|
|
import pytest
|
|
from tests.registry import (
|
|
GET_NO_PARAMS, GET_WITH_PARAMS,
|
|
resolve_path, PREFIX_TO_SEED, ROUTE_REGISTRY,
|
|
)
|
|
|
|
# Routes that require query params to avoid 422
|
|
ROUTES_NEEDING_QUERY = {
|
|
"/search/api": "?q=test",
|
|
}
|
|
|
|
# Routes that may legitimately return non-200 without full context
|
|
ACCEPTABLE_CODES = {200, 307, 308}
|
|
|
|
|
|
# ── Test 1: All GET endpoints without path params ───────────
|
|
@pytest.mark.parametrize(
|
|
"path",
|
|
[r.path for r in GET_NO_PARAMS],
|
|
ids=[f"GET {r.path}" for r in GET_NO_PARAMS],
|
|
)
|
|
async def test_get_no_params_returns_200(client, path):
|
|
"""Every GET endpoint without path params should return 200."""
|
|
url = path
|
|
# Append required query params if needed
|
|
for route_prefix, query_string in ROUTES_NEEDING_QUERY.items():
|
|
if path == route_prefix or path.rstrip("/") == route_prefix.rstrip("/"):
|
|
url = path + query_string
|
|
break
|
|
|
|
r = await client.get(url, follow_redirects=True)
|
|
assert r.status_code in ACCEPTABLE_CODES or r.status_code == 200, \
|
|
f"GET {url} returned {r.status_code}"
|
|
|
|
|
|
# ── Test 2: All GET endpoints with valid seed IDs ───────────
|
|
@pytest.mark.parametrize(
|
|
"path_template",
|
|
[r.path for r in GET_WITH_PARAMS],
|
|
ids=[f"GET {r.path}" for r in GET_WITH_PARAMS],
|
|
)
|
|
async def test_get_with_valid_id_returns_200(client, all_seeds, path_template):
|
|
"""GET endpoints with a valid seed UUID should return 200."""
|
|
path = resolve_path(path_template, all_seeds)
|
|
# Skip if we couldn't resolve (no seed for this prefix)
|
|
if "{" in path:
|
|
pytest.skip(f"No seed mapping for {path_template}")
|
|
|
|
r = await client.get(path, follow_redirects=True)
|
|
assert r.status_code == 200, f"GET {path} returned {r.status_code}"
|
|
|
|
|
|
# ── Test 3: GET with fake UUID returns 404 ──────────────────
|
|
FAKE_UUID = "00000000-0000-0000-0000-000000000000"
|
|
|
|
# Build fake-ID test cases from routes that have path params
|
|
_fake_id_cases = []
|
|
for r in GET_WITH_PARAMS:
|
|
import re
|
|
fake_path = re.sub(r"\{[^}]+\}", FAKE_UUID, r.path)
|
|
_fake_id_cases.append((fake_path, r.path))
|
|
|
|
@pytest.mark.parametrize(
|
|
"path,template",
|
|
_fake_id_cases if _fake_id_cases else [pytest.param("", "", marks=pytest.mark.skip)],
|
|
ids=[f"404 {c[1]}" for c in _fake_id_cases] if _fake_id_cases else ["NOTSET"],
|
|
)
|
|
async def test_get_with_fake_id_returns_404(client, path, template):
|
|
"""GET endpoints with a nonexistent UUID should return 404."""
|
|
r = await client.get(path, follow_redirects=True)
|
|
assert r.status_code in (404, 302, 303), \
|
|
f"GET {path} returned {r.status_code}, expected 404 or redirect"
|
|
SMOKE_EOF
|
|
echo " test_smoke_dynamic.py written"
|
|
|
|
# ──────────────────────────────────────────────────────────────
|
|
# Verify deployment
|
|
# ──────────────────────────────────────────────────────────────
|
|
echo ""
|
|
echo "=== Fix deployed ==="
|
|
echo ""
|
|
echo "Changes made:"
|
|
echo " 1. Installed psycopg2-binary (sync DB driver for seed data)"
|
|
echo " 2. registry.py: disposes async engine after introspection"
|
|
echo " 3. conftest.py: session-scoped event loop, psycopg2 seeds, fixed UUIDs"
|
|
echo " 4. test_smoke_dynamic.py: handles /search/api query param, better assertions"
|
|
echo ""
|
|
echo "Now run:"
|
|
echo " docker exec lifeos-dev bash /app/tests/run_tests.sh smoke"
|
|
echo ""
|
|
echo "If smoke tests pass, run:"
|
|
echo " docker exec lifeos-dev bash /app/tests/run_tests.sh crud"
|
|
echo " docker exec lifeos-dev bash /app/tests/run_tests.sh logic"
|
|
echo " docker exec lifeos-dev bash /app/tests/run_tests.sh # full suite"
|