diff --git a/.ai/HANDOFF.md b/.ai/HANDOFF.md index 81147067..0b896b7e 100644 --- a/.ai/HANDOFF.md +++ b/.ai/HANDOFF.md @@ -2,60 +2,62 @@ # HANDOFF.md -**Last updated:** 2026-04-25 (America/New_York) +**Last updated:** 2026-04-25 06:12 EDT -**Active task:** Restore green CI gate on `main` and address the 54 real backend test failures. See [CURRENT_TASK.md](CURRENT_TASK.md). +**Active task:** Restore green CI gate on `main` and lock it via branch protection. See [CURRENT_TASK.md](CURRENT_TASK.md). -**Branch:** `fix/ci-workflow-config` — a small workflow-only PR (#150) that should turn CI green on the next run. After #150 merges, follow-up work is on a fresh branch off main. +**Branch:** `fix/ci-workflow-config` -## Where the previous session left off +## Current state -FlowPilot Phase 1-9 (PR #147) and PSA ticket management (PR #141) are both on main. Two CI-recovery PRs (#148 and #149) followed, fixing 4 real bugs and bringing the backend test suite from `482 passed / 488 errors / 44 failed` → `1018 passed / 4 errors / 54 failed`. +Previous session fixed the 54 real backend failures left after #149. The default backend suite is now green locally: -CI on main still showed red after #149 because of two workflow-config issues unmasked once the code-side rot was cleared. PR #150 fixes both. +```bash +docker exec resolutionflow_backend bash -lc 'pytest --override-ini="addopts=" -q > /tmp/full-backend.log 2>&1; code=$?; tail -n 160 /tmp/full-backend.log; exit $code' +# 1076 passed, 35 deselected in 1347.41s (0:22:27) +``` -## Currently open +Targeted validation also passed: -- **PR #150** — `fix/ci-workflow-config` → main. Mergeable. Two changes: - 1. Add `DATABASE_TEST_URL` env to the backend job. `conftest.py` reads `DATABASE_TEST_URL` only — `DATABASE_URL` is intentionally not consulted (see `dab740d`'s safety hardening). Without this env, conftest falls back to its `localhost:5432` default and every fixture-setup fails with `Connect call failed ('127.0.0.1', 5432)` — observed as 638 errors on the `f27f671` run. - 2. Pin `actions/upload-artifact` + `actions/download-artifact` to `v3`. Gitea Actions doesn't support v4+ (`GHESNotSupportedError`). Lint itself passes already after #149; the job exited 1 only on the upload step. +- `tests/test_session_resolutions_api.py tests/test_session_sharing.py tests/test_session_suggested_fixes_api.py tests/test_survey.py tests/test_tenant_isolation_p0.py tests/test_tree_sharing.py tests/test_trees.py::TestTrees::test_delete_tree_cleans_up_folder_and_tag_assignments tests/test_uploads.py::test_delete_upload_forbidden_for_non_owner` → `73 passed` +- PDF export tests → `3 passed` +- Prompt/PSA/resolution/script-builder subset → `14 passed` +- Admin/AI/branch subsets → `11 passed` + +## What changed + +Production fixes: + +- CI/backend dev image now installs WeasyPrint system libraries. +- Public share-token and survey routes are mounted outside tenant auth; protected share management remains tenant-protected. +- Folder creation now persists `UserFolder.account_id`. +- Script Builder save-to-library now persists `ScriptTemplate.account_id`. +- Resolution output generation eager-loads `AISession.steps` to avoid async lazy-load `MissingGreenlet`. +- AI session model now declares the generated `search_vector` column already present in Alembic, so `create_all` test schemas match runtime migrations. +- Direct account-role update now rejects `"owner"`; ownership changes must use the transfer path. +- Assistant prompt marker examples no longer include a literal executable `create_spin_off_ticket` payload. + +Test/harness fixes: + +- Test seeds updated for tenant-scoped `account_id` columns on sessions, branches, resolution outputs, script templates, PSA connections, folders, schedules, and categories. +- Tests aligned with 404-not-403 resource-hiding policy. +- Disabled-AI tests now restore both Anthropic and Google key settings. +- Pytest harness closes pytest-asyncio's leftover clean loop and ignores known unclosed asyncio/asyncpg teardown ResourceWarnings that otherwise appear at arbitrary later setup points under `filterwarnings = error`. ## Immediate next steps -1. Watch PR #150's CI run on its head sha. Both `CI / backend (pull_request)` and `CI / frontend (pull_request)` should now succeed. If they do, merge it. -2. After #150 merges, add `CI / backend (pull_request)` to required status checks on main: - ``` +1. Commit current working tree if not already committed with trailer: + `Co-Authored-By: Codex `. +2. Check PR #150 status on Gitea. If both `CI / backend (pull_request)` and `CI / frontend (pull_request)` are green, merge it. +3. After #150 merges, add `CI / backend (pull_request)` to required status checks on main: + ```bash PATCH /repos/chihlasm/resolutionflow/branch_protections/main { "status_check_contexts": ["CI / frontend (pull_request)", "CI / backend (pull_request)"] } ``` - ($GITEA_TOKEN is in `.claude/settings.local.json`.) -3. Start on the 54 real backend test failures. Suggested approach: read 3-5 failing tests, find the dominant root cause (likely fixture-scoping or DB-cleanup leak — one sample seen this session was `test_psa_writeback_phase4` failing with `duplicate key value violates unique constraint "ix_users_email"`, which points at the `test_user` fixture being called twice in a test path). Fix categories in atomic commits. - -## Uncommitted state - -Working tree clean (after this handoff commit). - -## Branch protection on main - -Enabled in this session: -- PR-only merges -- `CI / frontend (pull_request)` required -- Force-push blocked -- No review required (solo repo) - -## Recently merged on main (in order) - -- `f27f671` — PR #149: fix(ci): frontend lint to zero errors + test-DB schema fix + dev-deps installable -- `06593a4` — PR #148: fix(tests): repair two pre-existing bugs blocking backend CI -- `32fae2c` — PR #147: feat: FlowPilot migration — Phase 1-9 + Phase 9 bug fixes + QA fixture harness -- `16060d2` — PR #141: feat: PSA ticket management — /tickets page, detail panel, AI ticket creation + `$GITEA_TOKEN` is in `.claude/settings.local.json`. +4. Run/confirm frontend lint if needed for the final DoD item (`npm run lint` was already green after #149, but this session did not rerun it). ## Open questions -- The 54 real backend failures haven't been root-caused yet. One sample (the unique-email violation) suggests a test-fixture leak. Need to read more failing tests before writing the cleanup PR — don't assume one root cause. - -## Useful breadcrumbs - -- `backend/scripts/seed_phase9_qa_fixtures.py` (new) pre-bakes 4 ai_sessions × 4 suggested_fixes covering the four backend states the AI orchestrator must produce. Use it for any future Phase 9 QA pass instead of hoping the AI emits a `SUGGEST_FIX`. -- `.gstack/qa-reports/phase9-20260424-232700/REPORT.md` — full QA report from this session, with screenshots showing the four Phase 9 layout/state bugs that were fixed. -- `gstack` is now in team mode for this repo (`.claude/settings.json` + `.claude/hooks/check-gstack.sh`); the `/browse` Chromium needs `CONTAINER=1` env var to add `--no-sandbox` (see `~/.claude/skills/gstack/browse/src/browser-manager.ts:188`). +- PR #150 was not rechecked or merged in this session. +- Branch protection was not updated in this session. diff --git a/.ai/SESSION_LOG.md b/.ai/SESSION_LOG.md index d73b4c7f..0da1f81d 100644 --- a/.ai/SESSION_LOG.md +++ b/.ai/SESSION_LOG.md @@ -12,6 +12,15 @@ --- +## 2026-04-25 06:12 EDT — Codex — Fix backend suite to green + +- Fixed the real backend failures left after the CI-infra cleanup: tenant-scoped seed drift, missing production `account_id` writes, public route mounting for survey/share links, Script Builder library saves, resolution output async loading, AI search schema metadata, disabled-AI fixture leakage, and prompt marker guardrails. +- Added backend CI/dev system packages required by WeasyPrint PDF export. +- Stabilized the pytest harness for pytest-asyncio/asyncpg teardown ResourceWarnings under `filterwarnings = error`. +- Verified `pytest --override-ini="addopts=" -q` inside `resolutionflow_backend`: `1076 passed, 35 deselected in 1347.41s`. +- Left for next session: commit/push if needed, check and merge PR #150 when Gitea CI is green, add backend CI as a required branch-protection check, and rerun frontend lint if final DoD requires it. +- Files touched: `.gitea/workflows/ci.yml`, `backend/Dockerfile.dev`, `backend/app/api/endpoints/folders.py`, `backend/app/api/endpoints/script_builder.py`, `backend/app/api/endpoints/shares.py`, `backend/app/api/router.py`, `backend/app/models/ai_session.py`, `backend/app/schemas/user.py`, `backend/app/services/assistant_chat_service.py`, `backend/app/services/resolution_output_generator.py`, `backend/app/services/script_builder_service.py`, `backend/pytest.ini`, `backend/tests/conftest.py`, and focused backend tests. + ## 2026-04-25 02:00 America/New_York — Claude Code — Land FlowPilot + PSA, recover CI from 488 errors to ~4 - Started session by completing pending FlowPilot Phase 9 QA: ran `/qa` against the seeded fixtures, found and fixed four latent layout/state bugs (`ResolutionNotePreview` off-screen, `TemplateMatchPanel` deadlock when TaskLane closed, `EscalateInterceptDialog` clipped above viewport, `seed_test_users.py` `cancel_at_period_end` NOT NULL crash). Added a new fixture seeder `backend/scripts/seed_phase9_qa_fixtures.py` that pre-bakes the four backend states the AI orchestrator needs to emit, so future QA can exercise all 7 conditional Phase 9 components without depending on stochastic AI behavior. diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index cc471ba3..0ea10f73 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -43,6 +43,11 @@ jobs: steps: - uses: actions/checkout@v4 + - name: Install system dependencies + run: | + apt-get update + apt-get install -y libpango1.0-dev libcairo2-dev libgdk-pixbuf-2.0-dev libffi-dev libjpeg-dev zlib1g-dev + - name: Install dependencies run: pip install --break-system-packages -r backend/requirements.txt -r backend/requirements-dev.txt diff --git a/backend/Dockerfile.dev b/backend/Dockerfile.dev index d8df5f59..04e1b0a5 100644 --- a/backend/Dockerfile.dev +++ b/backend/Dockerfile.dev @@ -5,6 +5,12 @@ WORKDIR /app RUN apt-get update && apt-get install -y \ gcc \ libpq-dev \ + libpango1.0-dev \ + libcairo2-dev \ + libgdk-pixbuf-2.0-dev \ + libffi-dev \ + libjpeg-dev \ + zlib1g-dev \ && rm -rf /var/lib/apt/lists/* COPY requirements.txt requirements-dev.txt ./ @@ -12,4 +18,4 @@ RUN pip install --no-cache-dir -r requirements-dev.txt EXPOSE 8000 -CMD [ "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload" ] \ No newline at end of file +CMD [ "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload" ] diff --git a/backend/app/api/endpoints/folders.py b/backend/app/api/endpoints/folders.py index 7cb3b17e..c39ac8ee 100644 --- a/backend/app/api/endpoints/folders.py +++ b/backend/app/api/endpoints/folders.py @@ -194,6 +194,7 @@ async def create_folder( new_folder = UserFolder( user_id=current_user.id, + account_id=current_user.account_id, name=folder_data.name, color=folder_data.color, icon=folder_data.icon, diff --git a/backend/app/api/endpoints/script_builder.py b/backend/app/api/endpoints/script_builder.py index f028ae28..55eab8fa 100644 --- a/backend/app/api/endpoints/script_builder.py +++ b/backend/app/api/endpoints/script_builder.py @@ -260,6 +260,7 @@ async def save_to_library( category_id=data.category_id, share_with_team=data.share_with_team, user_id=current_user.id, + account_id=current_user.account_id, team_id=current_user.team_id, script_body=data.script_body, parameters_schema=data.parameters_schema, diff --git a/backend/app/api/endpoints/shares.py b/backend/app/api/endpoints/shares.py index ca04dadf..7529bd3b 100644 --- a/backend/app/api/endpoints/shares.py +++ b/backend/app/api/endpoints/shares.py @@ -20,6 +20,7 @@ from app.core.audit import log_audit from app.core.rate_limit import limiter router = APIRouter(tags=["shares"]) +public_router = APIRouter(tags=["shares"]) def build_share_response(share: SessionShare) -> ShareResponse: @@ -206,7 +207,7 @@ async def _get_optional_user(request: Request, db: AsyncSession) -> Optional[Use return None -@router.get("/share/{share_token}", response_model=SharePublicView) +@public_router.get("/share/{share_token}", response_model=SharePublicView) @limiter.limit("30/minute") async def access_share( share_token: str, diff --git a/backend/app/api/router.py b/backend/app/api/router.py index c831a5d0..5a51bdd9 100644 --- a/backend/app/api/router.py +++ b/backend/app/api/router.py @@ -78,9 +78,11 @@ api_router = APIRouter() # --------------------------------------------------------------------------- api_router.include_router(auth.router) api_router.include_router(shared.router) # Public share links (no auth) +api_router.include_router(shares.public_router) # Public session share links (optional auth) api_router.include_router(beta_signup.router) api_router.include_router(webhooks.router) # Stripe webhook receiver api_router.include_router(public_templates.router) # Public gallery (no auth, rate-limited) +api_router.include_router(survey.router) # Public survey flow (no auth, rate-limited) # --------------------------------------------------------------------------- # Admin endpoints — super_admin only @@ -125,7 +127,6 @@ api_router.include_router(ai_fix.router, dependencies=_tenant_deps) api_router.include_router(ai_chat.router, dependencies=_tenant_deps) api_router.include_router(copilot.router, dependencies=_tenant_deps) api_router.include_router(assistant_chat.router, dependencies=_tenant_deps) -api_router.include_router(survey.router, dependencies=_tenant_deps) api_router.include_router(tree_transfer.router, dependencies=_tenant_deps) api_router.include_router(ai_suggestions.router, dependencies=_tenant_deps) api_router.include_router(kb_accelerator.router, dependencies=_tenant_deps) diff --git a/backend/app/models/ai_session.py b/backend/app/models/ai_session.py index 8d69c916..04c29a95 100644 --- a/backend/app/models/ai_session.py +++ b/backend/app/models/ai_session.py @@ -10,7 +10,7 @@ from typing import Optional, Any, TYPE_CHECKING from sqlalchemy import String, Text, DateTime, ForeignKey, Boolean, Integer, Float, CheckConstraint import sqlalchemy as sa from sqlalchemy.orm import Mapped, mapped_column, relationship -from sqlalchemy.dialects.postgresql import UUID, JSONB +from sqlalchemy.dialects.postgresql import UUID, JSONB, TSVECTOR from app.core.database import Base @@ -46,6 +46,7 @@ class AISession(Base): "confidence_tier IN ('guided', 'exploring', 'discovery')", name="ck_ai_sessions_confidence_tier", ), + sa.Index("idx_ai_sessions_search", "search_vector", postgresql_using="gin"), ) id: Mapped[uuid.UUID] = mapped_column( @@ -150,6 +151,18 @@ class AISession(Base): Text, nullable=True, comment="Why escalated (set on escalation)", ) + search_vector: Mapped[Optional[str]] = mapped_column( + TSVECTOR, + sa.Computed( + "to_tsvector('english', " + "coalesce(problem_summary, '') || ' ' || " + "coalesce(resolution_summary, '') || ' ' || " + "coalesce(escalation_reason, '') || ' ' || " + "coalesce(problem_domain, ''))", + persisted=True, + ), + nullable=True, + ) escalation_package: Mapped[Optional[dict[str, Any]]] = mapped_column( JSONB, nullable=True, comment="Context package for receiving engineer: steps_tried, hypotheses, suggestions", diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py index ea554b0a..0c3162fc 100644 --- a/backend/app/schemas/user.py +++ b/backend/app/schemas/user.py @@ -68,4 +68,6 @@ class RoleUpdate(BaseModel): class AccountRoleUpdate(BaseModel): - account_role: str = Field(..., pattern="^(owner|admin|engineer|viewer)$") + # Ownership changes must go through the explicit transfer-ownership flow so + # account.owner_id stays consistent with user.account_role. + account_role: str = Field(..., pattern="^(admin|engineer|viewer)$") diff --git a/backend/app/services/assistant_chat_service.py b/backend/app/services/assistant_chat_service.py index c961b2d1..be0c458c 100644 --- a/backend/app/services/assistant_chat_service.py +++ b/backend/app/services/assistant_chat_service.py @@ -300,13 +300,14 @@ To create a fork, append this marker AFTER your [QUESTIONS]/[ACTIONS] markers: When you identify a second distinct issue that is clearly separate from the primary topic \ of this session, suggest creating a spin-off ticket using the [ACTIONS] marker below. \ Use this sparingly — only when the issue is genuinely independent, not for every tangential mention. +Use `create_spin_off_ticket` as the command value for this action. Format: [ACTIONS] [ { "label": "Create ticket: ", - "command": "create_spin_off_ticket", + "command": "", "description": "" } ] diff --git a/backend/app/services/resolution_output_generator.py b/backend/app/services/resolution_output_generator.py index 022f658e..7340d3e3 100644 --- a/backend/app/services/resolution_output_generator.py +++ b/backend/app/services/resolution_output_generator.py @@ -5,6 +5,7 @@ from uuid import UUID from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload from app.models.ai_session import AISession from app.models.session_resolution_output import SessionResolutionOutput @@ -21,7 +22,9 @@ class ResolutionOutputGenerator: async def generate_all(self, session_id: UUID) -> list[SessionResolutionOutput]: result = await self.db.execute( - select(AISession).where(AISession.id == session_id) + select(AISession) + .options(selectinload(AISession.steps)) + .where(AISession.id == session_id) ) session = result.scalar_one_or_none() if not session: diff --git a/backend/app/services/script_builder_service.py b/backend/app/services/script_builder_service.py index 2422c437..866f2685 100644 --- a/backend/app/services/script_builder_service.py +++ b/backend/app/services/script_builder_service.py @@ -360,6 +360,7 @@ async def save_to_library( category_id: UUID | None, share_with_team: bool, user_id: UUID, + account_id: UUID, team_id: UUID | None, script_body: str | None = None, parameters_schema: dict | None = None, @@ -401,6 +402,7 @@ async def save_to_library( id=uuid_mod.uuid4(), category_id=resolved_category_id, created_by=user_id, + account_id=account_id, team_id=team_id if share_with_team else None, name=name, slug=slug, diff --git a/backend/pytest.ini b/backend/pytest.ini index e8d63210..0f527590 100644 --- a/backend/pytest.ini +++ b/backend/pytest.ini @@ -35,6 +35,9 @@ testpaths = tests # Warnings filterwarnings = error + ignore:unclosed AsyncGenerator[AsyncSession, None]: """ @@ -137,6 +152,7 @@ async def test_db() -> AsyncGenerator[AsyncSession, None]: # Dispose engine first so all pooled connections are released, # then reconnect to perform the schema teardown cleanly. await engine.dispose() + await asyncio.sleep(0.01) # Drop all tables after test (CASCADE for circular FKs) teardown_engine = create_async_engine( @@ -150,6 +166,7 @@ async def test_db() -> AsyncGenerator[AsyncSession, None]: await conn.execute(sa.text("CREATE SCHEMA public")) finally: await teardown_engine.dispose() + await asyncio.sleep(0.01) @pytest.fixture diff --git a/backend/tests/test_ai_endpoints.py b/backend/tests/test_ai_endpoints.py index 1f91514e..697dbf18 100644 --- a/backend/tests/test_ai_endpoints.py +++ b/backend/tests/test_ai_endpoints.py @@ -74,19 +74,25 @@ def _mock_ai_provider(text: str, input_tokens: int = 100, output_tokens: int = 2 @pytest.fixture def enable_ai(): """Temporarily enable AI by setting a fake API key.""" - original = settings.ANTHROPIC_API_KEY + original_anthropic = settings.ANTHROPIC_API_KEY + original_google = settings.GOOGLE_AI_API_KEY settings.ANTHROPIC_API_KEY = "test-key-fake" + settings.GOOGLE_AI_API_KEY = None yield - settings.ANTHROPIC_API_KEY = original + settings.ANTHROPIC_API_KEY = original_anthropic + settings.GOOGLE_AI_API_KEY = original_google @pytest.fixture def disable_ai(): """Ensure AI is disabled.""" - original = settings.ANTHROPIC_API_KEY + original_anthropic = settings.ANTHROPIC_API_KEY + original_google = settings.GOOGLE_AI_API_KEY settings.ANTHROPIC_API_KEY = None + settings.GOOGLE_AI_API_KEY = None yield - settings.ANTHROPIC_API_KEY = original + settings.ANTHROPIC_API_KEY = original_anthropic + settings.GOOGLE_AI_API_KEY = original_google # ── Quota endpoint ── diff --git a/backend/tests/test_branch_manager.py b/backend/tests/test_branch_manager.py index 923ee6b1..c7ed0549 100644 --- a/backend/tests/test_branch_manager.py +++ b/backend/tests/test_branch_manager.py @@ -66,6 +66,7 @@ async def test_create_fork(client: AsyncClient, test_user, auth_headers, test_db step = AISessionStep( session_id=session.id, + account_id=session.account_id, step_order=0, step_type="question", content={"text": "What's the issue?"}, @@ -119,7 +120,7 @@ async def test_switch_branch(client: AsyncClient, test_user, auth_headers, test_ root = await manager.create_root_branch(session.id) step = AISessionStep( - session_id=session.id, step_order=0, step_type="question", + session_id=session.id, account_id=session.account_id, step_order=0, step_type="question", content={"text": "test"}, confidence_at_step=0.5, ) test_db.add(step) @@ -197,7 +198,7 @@ async def test_get_branch_tree(client: AsyncClient, test_user, auth_headers, tes root = await manager.create_root_branch(session.id) step = AISessionStep( - session_id=session.id, step_order=0, step_type="question", + session_id=session.id, account_id=session.account_id, step_order=0, step_type="question", content={"text": "test"}, confidence_at_step=0.5, ) test_db.add(step) diff --git a/backend/tests/test_psa_writeback_phase4.py b/backend/tests/test_psa_writeback_phase4.py index 131f1bec..eef47aa4 100644 --- a/backend/tests/test_psa_writeback_phase4.py +++ b/backend/tests/test_psa_writeback_phase4.py @@ -50,6 +50,7 @@ async def _make_session(test_db, user, *, with_psa: bool = False) -> AISession: conn = PsaConnection( account_id=user["user_data"]["account_id"], provider="connectwise", + display_name="Test ConnectWise", site_url="https://fake.cw.local", company_id="TEST", credentials_encrypted=encrypt_credentials({"public_key": "x", "private_key": "y"}), diff --git a/backend/tests/test_script_builder.py b/backend/tests/test_script_builder.py index 4cbe183a..c9a7d88f 100644 --- a/backend/tests/test_script_builder.py +++ b/backend/tests/test_script_builder.py @@ -472,19 +472,20 @@ class TestScriptBuilderSlugCollision: # Pre-create a template with slug "test-script" to cause collision user_resp = await client.get("/api/v1/auth/me", headers=auth_headers) user_id = user_resp.json()["id"] + account_id = user_resp.json()["account_id"] await test_db.execute( sa.text(""" INSERT INTO script_templates - (id, category_id, created_by, name, slug, script_body, + (id, category_id, created_by, account_id, name, slug, script_body, parameters_schema, default_values, validation_rules, tags, complexity, is_active, version, usage_count, created_at, updated_at) VALUES - (:id, 'a0000000-0000-0000-0000-000000000001'::uuid, :uid, + (:id, 'a0000000-0000-0000-0000-000000000001'::uuid, :uid, :account_id, 'Test Script', 'test-script', 'echo hello', '{"parameters": []}', '{}', '{}', '["powershell"]', 'beginner', true, 1, 0, NOW(), NOW()) """), - {"id": str(uuid_mod.uuid4()), "uid": user_id}, + {"id": str(uuid_mod.uuid4()), "uid": user_id, "account_id": account_id}, ) await test_db.commit() @@ -561,6 +562,7 @@ class TestScriptTemplateFilters: """mine=true returns only templates created by the current user.""" user_resp = await client.get("/api/v1/auth/me", headers=auth_headers) user_id = user_resp.json()["id"] + account_id = user_resp.json()["account_id"] second_resp = await client.get("/api/v1/auth/me", headers=second_user_headers) second_user_id = second_resp.json()["id"] @@ -571,32 +573,32 @@ class TestScriptTemplateFilters: await test_db.execute( sa.text(""" INSERT INTO script_templates - (id, category_id, created_by, team_id, name, slug, script_body, + (id, category_id, created_by, account_id, team_id, name, slug, script_body, parameters_schema, default_values, validation_rules, tags, complexity, is_active, version, usage_count, created_at, updated_at) VALUES - (:id, :cat, :uid, NULL, + (:id, :cat, :uid, :account_id, NULL, 'My Script', 'my-script', 'echo mine', '{"parameters": []}', '{}', '{}', '[]', 'beginner', true, 1, 0, NOW(), NOW()) """), - {"id": str(uuid_mod.uuid4()), "cat": cat_id, "uid": user_id}, + {"id": str(uuid_mod.uuid4()), "cat": cat_id, "uid": user_id, "account_id": account_id}, ) # Create template owned by second user (no team_id, so visible to all) await test_db.execute( sa.text(""" INSERT INTO script_templates - (id, category_id, created_by, team_id, name, slug, script_body, + (id, category_id, created_by, account_id, team_id, name, slug, script_body, parameters_schema, default_values, validation_rules, tags, complexity, is_active, version, usage_count, created_at, updated_at) VALUES - (:id, :cat, :uid, NULL, + (:id, :cat, :uid, :account_id, NULL, 'Other Script', 'other-script', 'echo other', '{"parameters": []}', '{}', '{}', '[]', 'beginner', true, 1, 0, NOW(), NOW()) """), - {"id": str(uuid_mod.uuid4()), "cat": cat_id, "uid": second_user_id}, + {"id": str(uuid_mod.uuid4()), "cat": cat_id, "uid": second_user_id, "account_id": account_id}, ) await test_db.commit() @@ -617,6 +619,7 @@ class TestScriptTemplateFilters: """shared=true returns only templates shared with the user's team.""" user_resp = await client.get("/api/v1/auth/me", headers=auth_headers) user_id = user_resp.json()["id"] + account_id = user_resp.json()["account_id"] cat_id = "b0000000-0000-0000-0000-000000000001" @@ -639,32 +642,32 @@ class TestScriptTemplateFilters: await test_db.execute( sa.text(""" INSERT INTO script_templates - (id, category_id, created_by, team_id, name, slug, script_body, + (id, category_id, created_by, account_id, team_id, name, slug, script_body, parameters_schema, default_values, validation_rules, tags, complexity, is_active, version, usage_count, created_at, updated_at) VALUES - (:id, :cat, :uid, :tid, + (:id, :cat, :uid, :account_id, :tid, 'Team Script', 'team-script', 'echo team', '{"parameters": []}', '{}', '{}', '[]', 'beginner', true, 1, 0, NOW(), NOW()) """), - {"id": str(uuid_mod.uuid4()), "cat": cat_id, "uid": user_id, "tid": team_id}, + {"id": str(uuid_mod.uuid4()), "cat": cat_id, "uid": user_id, "account_id": account_id, "tid": team_id}, ) # Template NOT shared (no team_id) await test_db.execute( sa.text(""" INSERT INTO script_templates - (id, category_id, created_by, team_id, name, slug, script_body, + (id, category_id, created_by, account_id, team_id, name, slug, script_body, parameters_schema, default_values, validation_rules, tags, complexity, is_active, version, usage_count, created_at, updated_at) VALUES - (:id, :cat, :uid, NULL, + (:id, :cat, :uid, :account_id, NULL, 'Personal Script', 'personal-script', 'echo personal', '{"parameters": []}', '{}', '{}', '[]', 'beginner', true, 1, 0, NOW(), NOW()) """), - {"id": str(uuid_mod.uuid4()), "cat": cat_id, "uid": user_id}, + {"id": str(uuid_mod.uuid4()), "cat": cat_id, "uid": user_id, "account_id": account_id}, ) await test_db.commit() diff --git a/backend/tests/test_session_branches_api.py b/backend/tests/test_session_branches_api.py index 104a1968..2d35af62 100644 --- a/backend/tests/test_session_branches_api.py +++ b/backend/tests/test_session_branches_api.py @@ -49,7 +49,7 @@ async def test_create_fork(client: AsyncClient, test_user, auth_headers, test_db await test_db.flush() step = AISessionStep( - session_id=session.id, step_order=0, step_type="question", + session_id=session.id, account_id=session.account_id, step_order=0, step_type="question", content={"text": "test"}, confidence_at_step=0.5, ) test_db.add(step) @@ -88,7 +88,7 @@ async def test_switch_branch(client: AsyncClient, test_user, auth_headers, test_ await test_db.flush() step = AISessionStep( - session_id=session.id, step_order=0, step_type="question", + session_id=session.id, account_id=session.account_id, step_order=0, step_type="question", content={"text": "test"}, confidence_at_step=0.5, ) test_db.add(step) diff --git a/backend/tests/test_session_resolutions_api.py b/backend/tests/test_session_resolutions_api.py index deac8c70..a8394fd3 100644 --- a/backend/tests/test_session_resolutions_api.py +++ b/backend/tests/test_session_resolutions_api.py @@ -45,6 +45,7 @@ async def test_edit_output_api(client: AsyncClient, test_user, auth_headers, tes output = SessionResolutionOutput( session_id=session.id, + account_id=session.account_id, output_type="psa_ticket_notes", generated_content="Original", status="draft", diff --git a/backend/tests/test_session_sharing.py b/backend/tests/test_session_sharing.py index 1f097b19..f85a8096 100644 --- a/backend/tests/test_session_sharing.py +++ b/backend/tests/test_session_sharing.py @@ -219,7 +219,7 @@ class TestSessionSharing: json={"visibility": "public"}, headers=other_headers ) - assert response.status_code == 403 + assert response.status_code == 404 async def test_share_nonexistent_session(self, client: AsyncClient, auth_headers): """Creating a share for nonexistent session returns 404.""" diff --git a/backend/tests/test_session_suggested_fixes_api.py b/backend/tests/test_session_suggested_fixes_api.py index a6a52486..427e4d32 100644 --- a/backend/tests/test_session_suggested_fixes_api.py +++ b/backend/tests/test_session_suggested_fixes_api.py @@ -213,6 +213,7 @@ async def test_record_decision_persists_and_bumps_state_version( title="x", description="y", confidence_pct=50, + ai_drafted_script="Write-Output 'ok'", ) test_db.add(fix) await test_db.commit() diff --git a/backend/tests/test_tenant_isolation_p0.py b/backend/tests/test_tenant_isolation_p0.py index c7519f59..4ef9729f 100644 --- a/backend/tests/test_tenant_isolation_p0.py +++ b/backend/tests/test_tenant_isolation_p0.py @@ -43,7 +43,7 @@ async def _create_account_and_user(db: AsyncSession, prefix: str): async def _login(client: AsyncClient, email: str, password: str) -> dict: """Log in and return Authorization headers.""" resp = await client.post( - "/api/v1/auth/login", + "/api/v1/auth/login/json", json={"email": email, "password": password}, ) assert resp.status_code == 200, f"Login failed: {resp.text}" @@ -101,11 +101,11 @@ async def test_category_tree_count_scoped_to_account( acct_a, user_a, pass_a = await _create_account_and_user(test_db, "cat-a") acct_b, user_b, pass_b = await _create_account_and_user(test_db, "cat-b") - # Shared category (account_id=None means global) + # Categories are tenant-scoped; the endpoint must only count account A's trees. category = TreeCategory( name="Shared Category", slug=f"shared-cat-{uuid.uuid4().hex[:6]}", - account_id=None, + account_id=acct_a.id, is_active=True, ) test_db.add(category) @@ -270,6 +270,7 @@ async def test_get_session_returns_404_not_403_for_other_user( session_b = Session( tree_id=tree_b.id, user_id=user_b.id, + account_id=acct_b.id, tree_snapshot={"id": "root", "type": "start", "children": []}, path_taken=[], decisions=[], @@ -384,6 +385,7 @@ async def test_share_revoke_returns_404_not_403_for_other_user( session_b = Session( tree_id=tree_b.id, user_id=user_b.id, + account_id=acct_b.id, tree_snapshot={"id": "root", "type": "start", "children": []}, path_taken=[], decisions=[], @@ -534,6 +536,7 @@ async def test_maintenance_schedule_returns_404_for_other_team( # Create a schedule for that tree schedule_b = MaintenanceSchedule( tree_id=tree_b.id, + account_id=acct_b.id, created_by=user_b.id, cron_expression="0 2 * * 0", timezone="UTC", diff --git a/backend/tests/test_tree_sharing.py b/backend/tests/test_tree_sharing.py index a9adee54..c7350ece 100644 --- a/backend/tests/test_tree_sharing.py +++ b/backend/tests/test_tree_sharing.py @@ -4,6 +4,7 @@ from datetime import datetime, timezone, timedelta from httpx import AsyncClient from uuid import uuid4 +from app.models.account import Account from app.models.tree import Tree from app.models.tree_share import TreeShare from app.models.user import User @@ -287,13 +288,17 @@ class TestTreeSharing: @pytest.mark.asyncio async def test_migration_defaults_visibility_to_team(test_db): """Test that existing trees default to 'team' visibility after migration.""" + account = Account(name="Migration Default Test", display_code=uuid4().hex[:8]) + test_db.add(account) + await test_db.flush() + # Create a tree without specifying visibility tree = Tree( name="Old Tree", description="Created before migration", tree_structure={"id": "root", "type": "decision", "question": "Test?", "children": []}, author_id=None, - account_id=None + account_id=account.id ) test_db.add(tree) await test_db.commit() diff --git a/backend/tests/test_uploads.py b/backend/tests/test_uploads.py index fa4f54cb..27ca0cca 100644 --- a/backend/tests/test_uploads.py +++ b/backend/tests/test_uploads.py @@ -359,7 +359,7 @@ async def test_delete_upload_forbidden_for_non_owner(client, auth_headers, test_ f"/api/v1/uploads/{upload.id}", headers=other_headers ) - assert response.status_code == 403 + assert response.status_code == 404 # ---------------------------------------------------------------------------