Compare commits
9 Commits
fix/e2e-te
...
1bd43abb8f
| Author | SHA1 | Date | |
|---|---|---|---|
| 1bd43abb8f | |||
| c203b70ef9 | |||
| f27e3b44b0 | |||
| fe632c9194 | |||
| e976fb4e87 | |||
| 0aefaa78eb | |||
| 49f88569da | |||
| 208ec996d5 | |||
| 8f7df2c0ef |
@@ -1,33 +1,22 @@
|
||||
# CURRENT_TASK.md
|
||||
|
||||
**Task:** none — replace this file when starting the next real task.
|
||||
**Task:** Land two stacked CI PRs and lock the backend gate on `main`.
|
||||
|
||||
**Status:** not-started
|
||||
|
||||
**Definition of Done:** n/a
|
||||
|
||||
**Assumptions:** n/a
|
||||
|
||||
**Out of scope:** n/a
|
||||
|
||||
---
|
||||
|
||||
<!-- When you start a real task, replace the block above with:
|
||||
|
||||
**Task:** One-sentence goal.
|
||||
|
||||
**Status:** not-started | in-progress | blocked | ready-for-review | complete
|
||||
**Status:** in-progress
|
||||
|
||||
**Definition of Done:**
|
||||
- [ ] Testable criterion 1
|
||||
- [ ] Testable criterion 2
|
||||
- [ ] Tests added or updated
|
||||
- [ ] `npm run build` passes (frontend) / `pytest` passes (backend)
|
||||
- [ ] PR #150 (`fix/ci-workflow-config`) merged. Both `CI / backend (pull_request)` and `CI / frontend (pull_request)` show success on the merge commit.
|
||||
- [ ] PR #151 (`fix/ci-pytest-xdist`) merged. Backend CI on the merge commit completes in <6 min (was ~22 min serial).
|
||||
- [ ] `CI / backend (pull_request)` added to required status checks on `main` in Gitea branch protection (frontend is already required).
|
||||
- [ ] Optional: `CI / e2e (pull_request)` confirmed clean and added to required checks.
|
||||
|
||||
**Assumptions:**
|
||||
- What we're treating as given
|
||||
- The 8-core homelab Gitea Actions runner can support `-n auto` (8 xdist workers). If memory pressure shows up in CI, drop to `-n 4`.
|
||||
- pytest-cov's xdist support continues to handle the coverage merge and `--cov-fail-under=50` check correctly.
|
||||
- The per-worker DB creation in `conftest.py` is idempotent and racing workers on first import won't all try to CREATE DATABASE simultaneously — postgres serializes that, but if it surfaces issues, wrap with an advisory lock.
|
||||
|
||||
**Out of scope:**
|
||||
- What this task explicitly does NOT cover
|
||||
|
||||
-->
|
||||
- Frontend lint warnings (23 remain after #149).
|
||||
- The 23 react-hooks/exhaustive-deps warnings.
|
||||
- RLS test suite (gated behind `RUN_RLS_TESTS=1`; not in default CI).
|
||||
- Per-test transactional rollback (would shave another 30-40% off backend time but is a much bigger refactor — capture in TODO if interested).
|
||||
|
||||
101
.ai/HANDOFF.md
101
.ai/HANDOFF.md
@@ -2,34 +2,93 @@
|
||||
|
||||
# HANDOFF.md
|
||||
|
||||
**Last updated:** 2026-04-24 (America/New_York)
|
||||
**Last updated:** 2026-04-25 (America/New_York)
|
||||
|
||||
**Active task:** None — see [CURRENT_TASK.md](CURRENT_TASK.md). Replace it when picking up the next real task.
|
||||
**Active task:** Land three open CI PRs (#150 + #151 + #152), then enable backend + e2e gates on `main`. See [CURRENT_TASK.md](CURRENT_TASK.md).
|
||||
|
||||
**Branch:** `feat/flowpilot-migration` — a long-running FlowPilot Phase 9 feature branch. The recent AI-handoff migration commits ride on this branch (not on their own branch); they'll merge to `main` whenever Phase 9 does.
|
||||
**Branches:** Three open PRs, all independent of each other for correctness:
|
||||
- `fix/ci-workflow-config` → PR #150
|
||||
- `fix/ci-pytest-xdist` → PR #151 (stacked on #150 for context but mergeable on its own)
|
||||
- `fix/e2e-test-selectors` → PR #152
|
||||
|
||||
**Branch state:** 3 commits ahead of `origin/feat/flowpilot-migration`:
|
||||
**Runner setup:** Three Gitea Actions agents are now registered on the homelab box, so `backend` / `frontend` / `e2e` jobs run truly in parallel instead of serializing on a single agent. Combined with PR #151's xdist parallelization, the previous 1h 14m wall-clock should drop to ~6–10 min.
|
||||
|
||||
- `b3be1e0 chore: ignore .remember/ skill runtime state`
|
||||
- `b3506b5 docs(pilot): phase 9 review issues`
|
||||
- `b14a16a chore(tests): gate RLS tests behind RUN_RLS_TESTS flag`
|
||||
## Three open PRs
|
||||
|
||||
Earlier in this session (already pushed to origin):
|
||||
### PR #150 — `fix/ci-workflow-config` → main
|
||||
|
||||
- `9c8ba29 fix(ai): correct stale role-hierarchy and file-listing claims`
|
||||
- `bee8690 chore(ai): migrate to dual-agent handoff system`
|
||||
- `e110fed chore: snapshot CLAUDE.md before ai-handoff migration` (tag: `pre-ai-handoff`)
|
||||
Carries:
|
||||
- The Codex commit (`49f8856 wip(handoff): restore backend suite to green`) — fixes 54 backend test failures.
|
||||
- Workflow fixes: `DATABASE_TEST_URL` env, `actions/upload-artifact` v3 pin.
|
||||
- Most-recent commit (`e976fb4`):
|
||||
- Mocks `_extract_template_parameters` in `test_record_decision_persists_and_bumps_state_version` (last test failing on CI; needed an AI provider key the runner doesn't have). Verified locally — passes.
|
||||
- pip + npm caches in all three jobs.
|
||||
- Drops `--cov-report=term-missing` (the custom "Display coverage summary" step prints the same info from JSON).
|
||||
- Adds `--maxfail=10` so structural breakage fails fast.
|
||||
|
||||
**Where I left off:**
|
||||
- File: n/a — nothing mid-edit.
|
||||
- Next intended action: push the 3 unpushed commits when ready (`git push`), then start the next real task (replace `CURRENT_TASK.md`, update this file).
|
||||
**Expected CI on this PR:** all three jobs green for the first time in months.
|
||||
|
||||
**Uncommitted state:**
|
||||
- Working tree is clean.
|
||||
### PR #151 — `fix/ci-pytest-xdist` → main (stacked on #150)
|
||||
|
||||
**Immediate next steps:**
|
||||
1. `git push` to publish the 3 local commits (cleanup batch).
|
||||
2. When starting the next real feature task: replace `CURRENT_TASK.md` with actual goal/DoD, rewrite this file's resume section.
|
||||
Carries (on top of #150):
|
||||
- `pytest-xdist==3.6.1` in `requirements-dev.txt`.
|
||||
- `conftest.py` adds `_worker_db_url` + `_ensure_worker_db_exists`. Each xdist worker gets its own DB (`resolutionflow_test_gw0`, `gw1`, …) so the per-test `DROP SCHEMA public CASCADE` doesn't race across workers.
|
||||
- Workflow's pytest invocation gains `-n auto`.
|
||||
|
||||
**Open questions / blockers:**
|
||||
- None. The dual-agent handoff system is live and has survived one Codex review round (see DECISIONS.md 2026-04-24 entry; corrections in `9c8ba29`).
|
||||
**Measured locally:** backend suite goes from `22m 27s` (serial, 1076 passed) → `4m 28s` (8 workers, 1076 passed). Same exit code, same test count.
|
||||
|
||||
### PR #152 — `fix/e2e-test-selectors` → main
|
||||
|
||||
Carries: five Playwright e2e selector updates against the current UI. The drift was inherited from the FlowPilot/PSA migration:
|
||||
|
||||
- `Sessions` → `Session History` (page heading)
|
||||
- `Account Settings` → `Account Management` (page heading)
|
||||
- `/assistant` → `/pilot` (Phase 1 route rename; redirect still works)
|
||||
- Flow-session filtering and the Resume button moved behind the "Flow Sessions" tab on `/sessions` (default tab is "AI Sessions")
|
||||
- `resume.spec.ts` no longer starts at `/trees` — Resume button rendering moved to the session card on `/sessions`
|
||||
|
||||
No product-code changes. Pure test updates.
|
||||
|
||||
## Immediate next steps
|
||||
|
||||
1. **Merge PR #152 first.** Smallest, lowest risk, no shared file with the other two PRs.
|
||||
2. **Merge PR #150 next.** Backend test suite should be fully green (1076 passed, 0 failed, 0 errors).
|
||||
3. **Merge PR #151 last.** Backend job time drops to ~4–6 min on the runner.
|
||||
4. **Enable backend gate** on `main` branch protection — append `"CI / backend (pull_request)"` to `status_check_contexts`:
|
||||
```bash
|
||||
curl -X PATCH -H "Authorization: token $GITEA_TOKEN" \
|
||||
"https://gitea.resolutionflow.com/api/v1/repos/chihlasm/resolutionflow/branch_protections/main" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"status_check_contexts": ["CI / frontend (pull_request)", "CI / backend (pull_request)"]}'
|
||||
```
|
||||
5. **Then enable `CI / e2e (pull_request)`** — same PATCH, append to the list. Verify e2e is reliably green for at least one PR run before locking it in.
|
||||
|
||||
## Uncommitted state
|
||||
|
||||
Working tree clean (after this handoff commit).
|
||||
|
||||
## Branch protection on main (current)
|
||||
|
||||
- PR-only merges
|
||||
- `CI / frontend (pull_request)` required
|
||||
- Force-push blocked
|
||||
- No review required (solo)
|
||||
|
||||
## Recently merged on main
|
||||
|
||||
- `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
|
||||
|
||||
## Open questions
|
||||
|
||||
- One known concern with `--maxfail=10`: if a single bad commit produces 11+ legitimate failures, CI bails before reporting them all. Acceptable trade-off — the alternative is burning 25 min on a structural break.
|
||||
- pytest-xdist load distribution is the default file-scoped balance. If one worker consistently gets the slow tests, switch to `--dist worksteal` (xdist 3.x). Not worth tuning preemptively.
|
||||
|
||||
## Useful breadcrumbs
|
||||
|
||||
- `backend/scripts/seed_phase9_qa_fixtures.py` pre-bakes Phase 9 QA fixtures.
|
||||
- `.gstack/qa-reports/phase9-20260424-232700/REPORT.md` — full QA report from the FlowPilot session.
|
||||
- gstack is in team mode for this repo. `/browse` Chromium needs `CONTAINER=1` env (see `~/.claude/skills/gstack/browse/src/browser-manager.ts:188`).
|
||||
- Per-worker test DBs accumulate on the postgres service. Cheap to leave around; cleanup if it ever bothers anyone.
|
||||
|
||||
@@ -12,6 +12,39 @@
|
||||
|
||||
---
|
||||
|
||||
## 2026-04-25 12:00 America/New_York — Claude Code — Mock final AI-provider test, cache CI deps, parallelize backend with pytest-xdist
|
||||
|
||||
- Diagnosed why CI was still red despite Codex's local 1076 passed: a single test (`test_record_decision_persists_and_bumps_state_version`) needed `ANTHROPIC_API_KEY` because the `decision: draft_template` path calls `TemplateExtractionService` → AI provider. Patched `_extract_template_parameters` with an `AsyncMock` so the test no longer depends on AI availability. Verified.
|
||||
- Pushed Codex's WIP commit `49f8856` to PR #150 (had been local-only per handoff protocol).
|
||||
- PR #150 (`fix/ci-workflow-config`) extended with cheap CI wins: `actions/cache@v3` for pip + npm in all three jobs; dropped `--cov-report=term-missing` (the custom display step parses JSON); added `--maxfail=10` so structural breakage exits fast.
|
||||
- PR #151 (`fix/ci-pytest-xdist`) opened, stacked on #150: pytest-xdist with per-worker DB isolation. `conftest.py` reads `PYTEST_XDIST_WORKER`, computes a per-worker DB URL like `…_gw0`, and synchronously CREATEs the DB on first import. The per-test `DROP SCHEMA public CASCADE` then operates on the worker's isolated DB. Verified locally: backend suite went from 22m 27s serial → 4m 28s parallel (8 workers), 1076 passed in both cases. ~5× speedup.
|
||||
- Decided NOT to do per-test transactional rollback (bigger refactor); captured for future TODO consideration.
|
||||
- Left for next session: watch CI on both PRs, merge in order (#150 first, #151 second), then enable `CI / backend (pull_request)` as a required status check on main.
|
||||
- Files touched: `backend/tests/test_session_suggested_fixes_api.py`, `backend/tests/conftest.py`, `backend/requirements-dev.txt`, `.gitea/workflows/ci.yml`, `.ai/HANDOFF.md`, `.ai/CURRENT_TASK.md`, `.ai/TODO.md`.
|
||||
|
||||
## 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.
|
||||
- Discovered PR #141 (PSA ticket management) and `feat/flowpilot-migration` had 5 overlapping files but only 2 real conflicts (`CLAUDE.md`, `AssistantChatPage.tsx`). Conflicts were both additive — concatenated rather than chose-a-side.
|
||||
- Merged PSA first (PR #141), then merged FlowPilot (PR #147), each through Gitea API. `tsc -b` clean and visual smoke-test confirmed PSA's Tickets sidebar coexists with Phase 9 ProposalBanner.
|
||||
- Discovered main had been merging through a broken CI gate for several merges. Initially recommended "stop the line, fix CI before shipping." After scoping the actual rot (~50% of tests red, ~600 errors on a clean run), reversed the recommendation: ship the queue first because FlowPilot itself carried significant test-infra repairs that would be duplicated work on a fresh recovery branch.
|
||||
- PR #148: two surgical fixes to main (network_diagrams JSONB `server_default` triple-quote bug, deprecated session-scoped `event_loop` fixture in conftest). +78 passing / -114 errors.
|
||||
- PR #149: frontend lint `20 errors → 0`, `requirements-dev.txt` pytest pin bumped to satisfy `pytest-asyncio==0.24.0`'s `pytest>=8.2`, and a one-line `from app import models as _models` in conftest that registers all ~60 models with `Base.metadata` before `create_all`. The conftest fix collapsed 484 of the remaining 488 backend errors. `1018 passed / 4 errors / 54 failed` after.
|
||||
- Enabled Gitea branch protection on `main`: PR-only merges, `CI / frontend (pull_request)` required, force-push blocked, no review required.
|
||||
- Discovered CI on the merge commit STILL showed red despite local pytest being mostly green. Root cause: workflow only set `DATABASE_URL`, but conftest reads only `DATABASE_TEST_URL` (per `dab740d`'s safety hardening). 638 connection-refused errors on every fixture setup. Plus `actions/upload-artifact@v4` not supported by Gitea Actions. PR #150 fixes both.
|
||||
- Left for next session: merge PR #150 once CI confirms green, add `CI / backend (pull_request)` to required status checks, then root-cause and fix the 54 real backend test failures (one sample seen — `test_user` fixture leaking across calls causing duplicate-email violations).
|
||||
- Files touched (committed): `backend/scripts/seed_test_users.py`, `backend/scripts/seed_phase9_qa_fixtures.py` (new), `backend/app/models/network_diagram.py`, `backend/tests/conftest.py`, `backend/requirements-dev.txt`, `frontend/src/components/pilot/ResolutionNotePreview.tsx`, `frontend/src/components/pilot/EscalateInterceptDialog.tsx`, `frontend/src/components/pilot/ScriptBuilderTab.tsx`, `frontend/src/pages/AssistantChatPage.tsx`, `frontend/src/pages/FlowPilotSessionPage.tsx`, `frontend/src/pages/TicketsPage.tsx`, `frontend/src/hooks/useFlowPilotSession.ts`, `frontend/src/hooks/useMediaQuery.ts`, `frontend/src/components/dashboard/TicketQueue.tsx`, `frontend/src/components/network/nodes/DeviceNode.tsx`, `frontend/src/components/network/nodes/GroupNode.tsx`, `frontend/src/components/routing/AssistantSessionRedirect.tsx` (new), `frontend/src/router.tsx`, `.gitea/workflows/ci.yml`, `.claude/settings.json` (new), `.claude/hooks/check-gstack.sh` (new), `.gitignore`, `CLAUDE.md`, `.gstack/qa-reports/phase9-*/` (QA artifacts).
|
||||
- Net merges to main: PR #141 (PSA), PR #147 (FlowPilot), PR #148 (CI fixes part 1), PR #149 (CI fixes part 2). PR #150 still open at session end.
|
||||
|
||||
## 2026-04-24 — Claude Code — Migrate to dual-agent handoff system
|
||||
|
||||
- Split CLAUDE.md into `.ai/PROJECT_CONTEXT.md` + shared-protocol root files (`CLAUDE.md`, `AGENTS.md`).
|
||||
|
||||
@@ -5,8 +5,12 @@
|
||||
|
||||
## Up next
|
||||
|
||||
- [ ] No queued backlog yet.
|
||||
- [ ] **Parallelize backend pytest with pytest-xdist.** ✅ landing as PR #151. Verified locally: backend suite 22 min → 4m 28s with `-n auto` on the 8-core homelab runner. Per-worker DB isolation via `PYTEST_XDIST_WORKER` in conftest.py.
|
||||
|
||||
## Backlog
|
||||
|
||||
- [ ] No queued backlog yet.
|
||||
- [ ] **Frontend lint warnings cleanup.** 23 `react-hooks/exhaustive-deps` warnings remain after PR #149 (mostly missing-deps in useEffect). Either fix them or audit them for known-safe ones and add eslint-disable comments. Not blocking CI today.
|
||||
- [ ] **Audit `filterwarnings` ignores added in `wip(handoff): restore backend suite to green`.** Codex added narrow `ResourceWarning` filters for unclosed socket/transport/event-loop noise from pytest-asyncio teardown. Worth periodically reviewing whether those are still needed (e.g. when bumping pytest-asyncio) — if a real warning appears in those forms it would be silenced.
|
||||
- [ ] **Add `data-testid` attributes to e2e-critical interactive elements.** PR #152 fixed five Playwright tests by chasing UI-text changes (`Sessions` → `Session History`, `Account Settings` → `Account Management`, `/assistant` → `/pilot`, "Flow Sessions" tab, Resume button on session cards). Each was a one-line selector update, but every UI churn re-breaks them. Adding stable `data-testid` attributes on the targeted elements (page heading wrappers, tab nav, primary action buttons) and switching tests to `getByTestId` would make these immune to copy/route renames. Scope it small — start with `SessionHistoryPage` heading, the AI/Flow Sessions tab buttons, the per-session `Resume` button, and the command-palette FlowPilot option.
|
||||
- [ ] **Per-test transactional rollback in `test_db` fixture.** Bigger engineering than xdist (which we already shipped). Instead of `DROP SCHEMA public CASCADE` per test, wrap each test in a savepoint and rollback at teardown. ~30-40% additional speedup on top of xdist for test-DB-heavy tests. Real refactor; only worth it if the suite gets significantly larger or runs more frequently.
|
||||
- [ ] **Consider `pytest-testmon` for PR-time test selection.** Tracks which tests touched which source files and only re-runs affected ones. Best for small PRs touching ~few files. Adds cache-invalidation complexity; only worth it if the suite stays painfully long even after xdist.
|
||||
|
||||
@@ -17,8 +17,11 @@ jobs:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_DB: resolutionflow_test
|
||||
ports:
|
||||
- 5432:5432
|
||||
# No host port mapping. Tests connect to `postgres:5432` (the service
|
||||
# container's docker-network DNS name), not `localhost:5432`. With
|
||||
# multiple Gitea runners on the same homelab box, host-port mapping
|
||||
# would race — two backend/e2e jobs both binding 0.0.0.0:5432 → the
|
||||
# second fails with "port is already allocated".
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
@@ -28,6 +31,12 @@ jobs:
|
||||
env:
|
||||
DATABASE_URL: postgresql+asyncpg://postgres:postgres@postgres:5432/resolutionflow_test
|
||||
DATABASE_URL_SYNC: postgresql://postgres:postgres@postgres:5432/resolutionflow_test
|
||||
# conftest.py reads DATABASE_TEST_URL only (DATABASE_URL is intentionally
|
||||
# not consulted after the dab740d test-isolation hardening). The CI test
|
||||
# DB is the same postgres service, so point DATABASE_TEST_URL at it
|
||||
# explicitly — without this, conftest falls back to localhost:5432 and
|
||||
# all tests fail at fixture setup with "connection refused".
|
||||
DATABASE_TEST_URL: postgresql+asyncpg://postgres:postgres@postgres:5432/resolutionflow_test
|
||||
SECRET_KEY: ci-test-secret-key-not-for-production
|
||||
DEBUG: "true"
|
||||
APP_NAME: ResolutionFlow
|
||||
@@ -37,6 +46,19 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Cache pip
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ~/.cache/pip
|
||||
key: pip-${{ runner.os }}-${{ hashFiles('backend/requirements.txt', 'backend/requirements-dev.txt') }}
|
||||
restore-keys: |
|
||||
pip-${{ runner.os }}-
|
||||
|
||||
- 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
|
||||
|
||||
@@ -47,7 +69,11 @@ jobs:
|
||||
run: cd backend && python scripts/check_tenant_filters.py
|
||||
|
||||
- name: Run tests with coverage
|
||||
run: cd backend && python -m pytest --override-ini="addopts=" --cov=app --cov-report=term-missing --cov-report=json:coverage.json --cov-fail-under=50
|
||||
# term-missing dropped — the custom "Display coverage summary" step
|
||||
# below parses coverage.json and prints the same info more concisely.
|
||||
# --maxfail=10 short-circuits on structural breakage so we don't burn
|
||||
# 25 minutes when a fixture explodes.
|
||||
run: cd backend && python -m pytest --override-ini="addopts=" --maxfail=10 --cov=app --cov-report=json:coverage.json --cov-fail-under=50
|
||||
|
||||
- name: Display coverage summary
|
||||
if: always()
|
||||
@@ -75,6 +101,14 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Cache npm
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ~/.npm
|
||||
key: npm-${{ runner.os }}-${{ hashFiles('frontend/package-lock.json') }}
|
||||
restore-keys: |
|
||||
npm-${{ runner.os }}-
|
||||
|
||||
- name: Install dependencies
|
||||
run: cd frontend && npm ci
|
||||
|
||||
@@ -88,7 +122,7 @@ jobs:
|
||||
run: cd frontend && NODE_OPTIONS="--max-old-space-size=4096" npm run build
|
||||
|
||||
- name: Upload build artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: frontend-dist
|
||||
path: frontend/dist
|
||||
@@ -105,8 +139,11 @@ jobs:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_DB: resolutionflow_test
|
||||
ports:
|
||||
- 5432:5432
|
||||
# No host port mapping. Tests connect to `postgres:5432` (the service
|
||||
# container's docker-network DNS name), not `localhost:5432`. With
|
||||
# multiple Gitea runners on the same homelab box, host-port mapping
|
||||
# would race — two backend/e2e jobs both binding 0.0.0.0:5432 → the
|
||||
# second fails with "port is already allocated".
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
@@ -125,6 +162,22 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Cache pip
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ~/.cache/pip
|
||||
key: pip-${{ runner.os }}-${{ hashFiles('backend/requirements.txt', 'backend/requirements-dev.txt') }}
|
||||
restore-keys: |
|
||||
pip-${{ runner.os }}-
|
||||
|
||||
- name: Cache npm
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ~/.npm
|
||||
key: npm-${{ runner.os }}-${{ hashFiles('frontend/package-lock.json') }}
|
||||
restore-keys: |
|
||||
npm-${{ runner.os }}-
|
||||
|
||||
- name: Install backend dependencies
|
||||
run: pip install --break-system-packages -r backend/requirements.txt -r backend/requirements-dev.txt
|
||||
|
||||
@@ -132,7 +185,7 @@ jobs:
|
||||
run: cd frontend && npm ci
|
||||
|
||||
- name: Download frontend build
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: frontend-dist
|
||||
path: frontend/dist
|
||||
@@ -145,7 +198,7 @@ jobs:
|
||||
|
||||
- name: Upload Playwright report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: playwright-report
|
||||
path: |
|
||||
|
||||
@@ -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" ]
|
||||
CMD [ "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload" ]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)$")
|
||||
|
||||
@@ -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: <brief issue title>",
|
||||
"command": "create_spin_off_ticket",
|
||||
"command": "<spin-off ticket action command>",
|
||||
"description": "<one sentence description of the separate issue>"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -35,6 +35,9 @@ testpaths = tests
|
||||
# Warnings
|
||||
filterwarnings =
|
||||
error
|
||||
ignore:unclosed <socket\.socket.*:ResourceWarning
|
||||
ignore:unclosed transport .*:ResourceWarning
|
||||
ignore:unclosed event loop .*:ResourceWarning
|
||||
ignore::DeprecationWarning
|
||||
ignore::PendingDeprecationWarning
|
||||
ignore::pluggy.PluggyTeardownRaisedWarning
|
||||
|
||||
@@ -5,6 +5,7 @@ Provides test database setup, client fixtures, and authentication helpers.
|
||||
"""
|
||||
|
||||
import os
|
||||
import asyncio
|
||||
from typing import AsyncGenerator
|
||||
import pytest
|
||||
import sqlalchemy as sa
|
||||
@@ -73,6 +74,20 @@ def pytest_collection_modifyitems(config, items):
|
||||
items[:] = selected
|
||||
|
||||
|
||||
@pytest.hookimpl(trylast=True, hookwrapper=True)
|
||||
def pytest_runtest_teardown(item, nextitem):
|
||||
"""Close pytest-asyncio's post-test clean loop before warnings collect it."""
|
||||
yield
|
||||
policy = asyncio.get_event_loop_policy()
|
||||
try:
|
||||
loop = policy.get_event_loop()
|
||||
except RuntimeError:
|
||||
return
|
||||
if not loop.is_running() and not loop.is_closed():
|
||||
loop.close()
|
||||
policy.set_event_loop(None)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def test_db() -> 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
|
||||
|
||||
@@ -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 ──
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"}),
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -213,15 +213,28 @@ 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()
|
||||
|
||||
r = await client.post(
|
||||
f"/api/v1/ai-sessions/{session.id}/suggested-fixes/{fix.id}/decision",
|
||||
headers=auth_headers,
|
||||
json={"decision": "draft_template"},
|
||||
)
|
||||
# The draft_template path calls TemplateExtractionService, which needs an
|
||||
# AI provider configured. CI doesn't set ANTHROPIC_API_KEY/GOOGLE_AI_API_KEY,
|
||||
# and this test isn't exercising the AI integration — patch the extractor
|
||||
# with a minimal valid response so the rest of the decision flow runs.
|
||||
extractor_stub = AsyncMock(return_value={
|
||||
"templated_body": "Write-Output 'ok'",
|
||||
"parameters": [],
|
||||
})
|
||||
with patch(
|
||||
"app.api.endpoints.session_suggested_fixes._extract_template_parameters",
|
||||
extractor_stub,
|
||||
):
|
||||
r = await client.post(
|
||||
f"/api/v1/ai-sessions/{session.id}/suggested-fixes/{fix.id}/decision",
|
||||
headers=auth_headers,
|
||||
json={"decision": "draft_template"},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert r.json()["user_decision"] == "draft_template"
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -88,8 +88,6 @@ test.describe('command palette smoke tests', () => {
|
||||
|
||||
await flowpilotOption.click()
|
||||
|
||||
// Phase 1 of the FlowPilot migration renamed /assistant to /pilot.
|
||||
// /assistant still 301-redirects to /pilot, so accept either landing URL.
|
||||
await expect(page).toHaveURL(/\/(pilot|assistant)/)
|
||||
await expect(page).toHaveURL(/\/assistant/)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -24,13 +24,9 @@ test.describe('session history smoke tests', () => {
|
||||
await page.goto('/sessions')
|
||||
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Session History', exact: true }),
|
||||
page.getByRole('heading', { name: 'Sessions', exact: true }),
|
||||
).toBeVisible()
|
||||
|
||||
// Default tab on /sessions is "AI Sessions"; flow sessions live behind
|
||||
// the "Flow Sessions" tab and only that tab exposes ticket/client filters.
|
||||
await page.getByRole('button', { name: 'Flow Sessions' }).click()
|
||||
|
||||
await page.getByPlaceholder('Search by ticket number...').fill(ticketNumber)
|
||||
await page.getByPlaceholder('Search by client name...').fill(clientName)
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ test.describe('authenticated navigation smoke tests', () => {
|
||||
await page.goto('/sessions')
|
||||
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Session History', exact: true }),
|
||||
page.getByRole('heading', { name: 'Sessions', exact: true }),
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
@@ -30,7 +30,7 @@ test.describe('authenticated navigation smoke tests', () => {
|
||||
await page.goto('/account')
|
||||
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Account Management' }),
|
||||
page.getByRole('heading', { name: 'Account Settings' }),
|
||||
).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -18,17 +18,9 @@ test.describe('session resume smoke tests', () => {
|
||||
})
|
||||
|
||||
try {
|
||||
// Resume flow moved off /trees onto the Flow Sessions tab of /sessions
|
||||
// during the FlowPilot migration. The destination (/trees/:id/navigate)
|
||||
// is unchanged — only the entry point shifted.
|
||||
await page.goto('/sessions')
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Session History', exact: true }),
|
||||
).toBeVisible()
|
||||
await page.getByRole('button', { name: 'Flow Sessions' }).click()
|
||||
// Active sub-tab is the default and surfaces in-progress sessions.
|
||||
await page.goto('/trees')
|
||||
|
||||
const resumeCard = page.locator('.bg-card').filter({ hasText: tree.name }).first()
|
||||
const resumeCard = page.locator('.bg-card').filter({ hasText: tree.name }).filter({ hasText: 'Resume' }).first()
|
||||
await expect(resumeCard).toBeVisible()
|
||||
await resumeCard.getByRole('button', { name: 'Resume' }).first().click()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user