wip(handoff): restore backend suite to green
Co-Authored-By: Codex <noreply@openai.com>
This commit is contained in:
@@ -2,60 +2,62 @@
|
|||||||
|
|
||||||
# HANDOFF.md
|
# 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:
|
- `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`
|
||||||
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.
|
- PDF export tests → `3 passed`
|
||||||
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.
|
- 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
|
## 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.
|
1. Commit current working tree if not already committed with trailer:
|
||||||
2. After #150 merges, add `CI / backend (pull_request)` to required status checks on main:
|
`Co-Authored-By: Codex <noreply@openai.com>`.
|
||||||
```
|
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
|
PATCH /repos/chihlasm/resolutionflow/branch_protections/main
|
||||||
{ "status_check_contexts": ["CI / frontend (pull_request)", "CI / backend (pull_request)"] }
|
{ "status_check_contexts": ["CI / frontend (pull_request)", "CI / backend (pull_request)"] }
|
||||||
```
|
```
|
||||||
($GITEA_TOKEN is in `.claude/settings.local.json`.)
|
`$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.
|
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).
|
||||||
|
|
||||||
## 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
|
|
||||||
|
|
||||||
## Open questions
|
## 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.
|
- PR #150 was not rechecked or merged in this session.
|
||||||
|
- Branch protection was not updated in this session.
|
||||||
## 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`).
|
|
||||||
|
|||||||
@@ -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
|
## 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.
|
- 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.
|
||||||
|
|||||||
@@ -43,6 +43,11 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- 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
|
- name: Install dependencies
|
||||||
run: pip install --break-system-packages -r backend/requirements.txt -r backend/requirements-dev.txt
|
run: pip install --break-system-packages -r backend/requirements.txt -r backend/requirements-dev.txt
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,12 @@ WORKDIR /app
|
|||||||
RUN apt-get update && apt-get install -y \
|
RUN apt-get update && apt-get install -y \
|
||||||
gcc \
|
gcc \
|
||||||
libpq-dev \
|
libpq-dev \
|
||||||
|
libpango1.0-dev \
|
||||||
|
libcairo2-dev \
|
||||||
|
libgdk-pixbuf-2.0-dev \
|
||||||
|
libffi-dev \
|
||||||
|
libjpeg-dev \
|
||||||
|
zlib1g-dev \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
COPY requirements.txt requirements-dev.txt ./
|
COPY requirements.txt requirements-dev.txt ./
|
||||||
@@ -12,4 +18,4 @@ RUN pip install --no-cache-dir -r requirements-dev.txt
|
|||||||
|
|
||||||
EXPOSE 8000
|
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(
|
new_folder = UserFolder(
|
||||||
user_id=current_user.id,
|
user_id=current_user.id,
|
||||||
|
account_id=current_user.account_id,
|
||||||
name=folder_data.name,
|
name=folder_data.name,
|
||||||
color=folder_data.color,
|
color=folder_data.color,
|
||||||
icon=folder_data.icon,
|
icon=folder_data.icon,
|
||||||
|
|||||||
@@ -260,6 +260,7 @@ async def save_to_library(
|
|||||||
category_id=data.category_id,
|
category_id=data.category_id,
|
||||||
share_with_team=data.share_with_team,
|
share_with_team=data.share_with_team,
|
||||||
user_id=current_user.id,
|
user_id=current_user.id,
|
||||||
|
account_id=current_user.account_id,
|
||||||
team_id=current_user.team_id,
|
team_id=current_user.team_id,
|
||||||
script_body=data.script_body,
|
script_body=data.script_body,
|
||||||
parameters_schema=data.parameters_schema,
|
parameters_schema=data.parameters_schema,
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ from app.core.audit import log_audit
|
|||||||
from app.core.rate_limit import limiter
|
from app.core.rate_limit import limiter
|
||||||
|
|
||||||
router = APIRouter(tags=["shares"])
|
router = APIRouter(tags=["shares"])
|
||||||
|
public_router = APIRouter(tags=["shares"])
|
||||||
|
|
||||||
|
|
||||||
def build_share_response(share: SessionShare) -> ShareResponse:
|
def build_share_response(share: SessionShare) -> ShareResponse:
|
||||||
@@ -206,7 +207,7 @@ async def _get_optional_user(request: Request, db: AsyncSession) -> Optional[Use
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
@router.get("/share/{share_token}", response_model=SharePublicView)
|
@public_router.get("/share/{share_token}", response_model=SharePublicView)
|
||||||
@limiter.limit("30/minute")
|
@limiter.limit("30/minute")
|
||||||
async def access_share(
|
async def access_share(
|
||||||
share_token: str,
|
share_token: str,
|
||||||
|
|||||||
@@ -78,9 +78,11 @@ api_router = APIRouter()
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
api_router.include_router(auth.router)
|
api_router.include_router(auth.router)
|
||||||
api_router.include_router(shared.router) # Public share links (no auth)
|
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(beta_signup.router)
|
||||||
api_router.include_router(webhooks.router) # Stripe webhook receiver
|
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(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
|
# 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(ai_chat.router, dependencies=_tenant_deps)
|
||||||
api_router.include_router(copilot.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(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(tree_transfer.router, dependencies=_tenant_deps)
|
||||||
api_router.include_router(ai_suggestions.router, dependencies=_tenant_deps)
|
api_router.include_router(ai_suggestions.router, dependencies=_tenant_deps)
|
||||||
api_router.include_router(kb_accelerator.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
|
from sqlalchemy import String, Text, DateTime, ForeignKey, Boolean, Integer, Float, CheckConstraint
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
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
|
from app.core.database import Base
|
||||||
|
|
||||||
@@ -46,6 +46,7 @@ class AISession(Base):
|
|||||||
"confidence_tier IN ('guided', 'exploring', 'discovery')",
|
"confidence_tier IN ('guided', 'exploring', 'discovery')",
|
||||||
name="ck_ai_sessions_confidence_tier",
|
name="ck_ai_sessions_confidence_tier",
|
||||||
),
|
),
|
||||||
|
sa.Index("idx_ai_sessions_search", "search_vector", postgresql_using="gin"),
|
||||||
)
|
)
|
||||||
|
|
||||||
id: Mapped[uuid.UUID] = mapped_column(
|
id: Mapped[uuid.UUID] = mapped_column(
|
||||||
@@ -150,6 +151,18 @@ class AISession(Base):
|
|||||||
Text, nullable=True,
|
Text, nullable=True,
|
||||||
comment="Why escalated (set on escalation)",
|
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(
|
escalation_package: Mapped[Optional[dict[str, Any]]] = mapped_column(
|
||||||
JSONB, nullable=True,
|
JSONB, nullable=True,
|
||||||
comment="Context package for receiving engineer: steps_tried, hypotheses, suggestions",
|
comment="Context package for receiving engineer: steps_tried, hypotheses, suggestions",
|
||||||
|
|||||||
@@ -68,4 +68,6 @@ class RoleUpdate(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class AccountRoleUpdate(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 \
|
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. \
|
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 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:
|
Format:
|
||||||
[ACTIONS]
|
[ACTIONS]
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
"label": "Create ticket: <brief issue title>",
|
"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>"
|
"description": "<one sentence description of the separate issue>"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from uuid import UUID
|
|||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
from app.models.ai_session import AISession
|
from app.models.ai_session import AISession
|
||||||
from app.models.session_resolution_output import SessionResolutionOutput
|
from app.models.session_resolution_output import SessionResolutionOutput
|
||||||
@@ -21,7 +22,9 @@ class ResolutionOutputGenerator:
|
|||||||
|
|
||||||
async def generate_all(self, session_id: UUID) -> list[SessionResolutionOutput]:
|
async def generate_all(self, session_id: UUID) -> list[SessionResolutionOutput]:
|
||||||
result = await self.db.execute(
|
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()
|
session = result.scalar_one_or_none()
|
||||||
if not session:
|
if not session:
|
||||||
|
|||||||
@@ -360,6 +360,7 @@ async def save_to_library(
|
|||||||
category_id: UUID | None,
|
category_id: UUID | None,
|
||||||
share_with_team: bool,
|
share_with_team: bool,
|
||||||
user_id: UUID,
|
user_id: UUID,
|
||||||
|
account_id: UUID,
|
||||||
team_id: UUID | None,
|
team_id: UUID | None,
|
||||||
script_body: str | None = None,
|
script_body: str | None = None,
|
||||||
parameters_schema: dict | None = None,
|
parameters_schema: dict | None = None,
|
||||||
@@ -401,6 +402,7 @@ async def save_to_library(
|
|||||||
id=uuid_mod.uuid4(),
|
id=uuid_mod.uuid4(),
|
||||||
category_id=resolved_category_id,
|
category_id=resolved_category_id,
|
||||||
created_by=user_id,
|
created_by=user_id,
|
||||||
|
account_id=account_id,
|
||||||
team_id=team_id if share_with_team else None,
|
team_id=team_id if share_with_team else None,
|
||||||
name=name,
|
name=name,
|
||||||
slug=slug,
|
slug=slug,
|
||||||
|
|||||||
@@ -35,6 +35,9 @@ testpaths = tests
|
|||||||
# Warnings
|
# Warnings
|
||||||
filterwarnings =
|
filterwarnings =
|
||||||
error
|
error
|
||||||
|
ignore:unclosed <socket\.socket.*:ResourceWarning
|
||||||
|
ignore:unclosed transport .*:ResourceWarning
|
||||||
|
ignore:unclosed event loop .*:ResourceWarning
|
||||||
ignore::DeprecationWarning
|
ignore::DeprecationWarning
|
||||||
ignore::PendingDeprecationWarning
|
ignore::PendingDeprecationWarning
|
||||||
ignore::pluggy.PluggyTeardownRaisedWarning
|
ignore::pluggy.PluggyTeardownRaisedWarning
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ Provides test database setup, client fixtures, and authentication helpers.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import asyncio
|
||||||
from typing import AsyncGenerator
|
from typing import AsyncGenerator
|
||||||
import pytest
|
import pytest
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
@@ -73,6 +74,20 @@ def pytest_collection_modifyitems(config, items):
|
|||||||
items[:] = selected
|
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
|
@pytest.fixture
|
||||||
async def test_db() -> AsyncGenerator[AsyncSession, None]:
|
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,
|
# Dispose engine first so all pooled connections are released,
|
||||||
# then reconnect to perform the schema teardown cleanly.
|
# then reconnect to perform the schema teardown cleanly.
|
||||||
await engine.dispose()
|
await engine.dispose()
|
||||||
|
await asyncio.sleep(0.01)
|
||||||
|
|
||||||
# Drop all tables after test (CASCADE for circular FKs)
|
# Drop all tables after test (CASCADE for circular FKs)
|
||||||
teardown_engine = create_async_engine(
|
teardown_engine = create_async_engine(
|
||||||
@@ -150,6 +166,7 @@ async def test_db() -> AsyncGenerator[AsyncSession, None]:
|
|||||||
await conn.execute(sa.text("CREATE SCHEMA public"))
|
await conn.execute(sa.text("CREATE SCHEMA public"))
|
||||||
finally:
|
finally:
|
||||||
await teardown_engine.dispose()
|
await teardown_engine.dispose()
|
||||||
|
await asyncio.sleep(0.01)
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
|
|||||||
@@ -74,19 +74,25 @@ def _mock_ai_provider(text: str, input_tokens: int = 100, output_tokens: int = 2
|
|||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def enable_ai():
|
def enable_ai():
|
||||||
"""Temporarily enable AI by setting a fake API key."""
|
"""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.ANTHROPIC_API_KEY = "test-key-fake"
|
||||||
|
settings.GOOGLE_AI_API_KEY = None
|
||||||
yield
|
yield
|
||||||
settings.ANTHROPIC_API_KEY = original
|
settings.ANTHROPIC_API_KEY = original_anthropic
|
||||||
|
settings.GOOGLE_AI_API_KEY = original_google
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def disable_ai():
|
def disable_ai():
|
||||||
"""Ensure AI is disabled."""
|
"""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.ANTHROPIC_API_KEY = None
|
||||||
|
settings.GOOGLE_AI_API_KEY = None
|
||||||
yield
|
yield
|
||||||
settings.ANTHROPIC_API_KEY = original
|
settings.ANTHROPIC_API_KEY = original_anthropic
|
||||||
|
settings.GOOGLE_AI_API_KEY = original_google
|
||||||
|
|
||||||
|
|
||||||
# ── Quota endpoint ──
|
# ── Quota endpoint ──
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ async def test_create_fork(client: AsyncClient, test_user, auth_headers, test_db
|
|||||||
|
|
||||||
step = AISessionStep(
|
step = AISessionStep(
|
||||||
session_id=session.id,
|
session_id=session.id,
|
||||||
|
account_id=session.account_id,
|
||||||
step_order=0,
|
step_order=0,
|
||||||
step_type="question",
|
step_type="question",
|
||||||
content={"text": "What's the issue?"},
|
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)
|
root = await manager.create_root_branch(session.id)
|
||||||
|
|
||||||
step = AISessionStep(
|
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,
|
content={"text": "test"}, confidence_at_step=0.5,
|
||||||
)
|
)
|
||||||
test_db.add(step)
|
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)
|
root = await manager.create_root_branch(session.id)
|
||||||
|
|
||||||
step = AISessionStep(
|
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,
|
content={"text": "test"}, confidence_at_step=0.5,
|
||||||
)
|
)
|
||||||
test_db.add(step)
|
test_db.add(step)
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ async def _make_session(test_db, user, *, with_psa: bool = False) -> AISession:
|
|||||||
conn = PsaConnection(
|
conn = PsaConnection(
|
||||||
account_id=user["user_data"]["account_id"],
|
account_id=user["user_data"]["account_id"],
|
||||||
provider="connectwise",
|
provider="connectwise",
|
||||||
|
display_name="Test ConnectWise",
|
||||||
site_url="https://fake.cw.local",
|
site_url="https://fake.cw.local",
|
||||||
company_id="TEST",
|
company_id="TEST",
|
||||||
credentials_encrypted=encrypt_credentials({"public_key": "x", "private_key": "y"}),
|
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
|
# Pre-create a template with slug "test-script" to cause collision
|
||||||
user_resp = await client.get("/api/v1/auth/me", headers=auth_headers)
|
user_resp = await client.get("/api/v1/auth/me", headers=auth_headers)
|
||||||
user_id = user_resp.json()["id"]
|
user_id = user_resp.json()["id"]
|
||||||
|
account_id = user_resp.json()["account_id"]
|
||||||
await test_db.execute(
|
await test_db.execute(
|
||||||
sa.text("""
|
sa.text("""
|
||||||
INSERT INTO script_templates
|
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,
|
parameters_schema, default_values, validation_rules, tags,
|
||||||
complexity, is_active, version, usage_count, created_at, updated_at)
|
complexity, is_active, version, usage_count, created_at, updated_at)
|
||||||
VALUES
|
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',
|
'Test Script', 'test-script', 'echo hello',
|
||||||
'{"parameters": []}', '{}', '{}', '["powershell"]',
|
'{"parameters": []}', '{}', '{}', '["powershell"]',
|
||||||
'beginner', true, 1, 0, NOW(), NOW())
|
'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()
|
await test_db.commit()
|
||||||
|
|
||||||
@@ -561,6 +562,7 @@ class TestScriptTemplateFilters:
|
|||||||
"""mine=true returns only templates created by the current user."""
|
"""mine=true returns only templates created by the current user."""
|
||||||
user_resp = await client.get("/api/v1/auth/me", headers=auth_headers)
|
user_resp = await client.get("/api/v1/auth/me", headers=auth_headers)
|
||||||
user_id = user_resp.json()["id"]
|
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_resp = await client.get("/api/v1/auth/me", headers=second_user_headers)
|
||||||
second_user_id = second_resp.json()["id"]
|
second_user_id = second_resp.json()["id"]
|
||||||
@@ -571,32 +573,32 @@ class TestScriptTemplateFilters:
|
|||||||
await test_db.execute(
|
await test_db.execute(
|
||||||
sa.text("""
|
sa.text("""
|
||||||
INSERT INTO script_templates
|
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,
|
parameters_schema, default_values, validation_rules, tags,
|
||||||
complexity, is_active, version, usage_count, created_at, updated_at)
|
complexity, is_active, version, usage_count, created_at, updated_at)
|
||||||
VALUES
|
VALUES
|
||||||
(:id, :cat, :uid, NULL,
|
(:id, :cat, :uid, :account_id, NULL,
|
||||||
'My Script', 'my-script', 'echo mine',
|
'My Script', 'my-script', 'echo mine',
|
||||||
'{"parameters": []}', '{}', '{}', '[]',
|
'{"parameters": []}', '{}', '{}', '[]',
|
||||||
'beginner', true, 1, 0, NOW(), NOW())
|
'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)
|
# Create template owned by second user (no team_id, so visible to all)
|
||||||
await test_db.execute(
|
await test_db.execute(
|
||||||
sa.text("""
|
sa.text("""
|
||||||
INSERT INTO script_templates
|
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,
|
parameters_schema, default_values, validation_rules, tags,
|
||||||
complexity, is_active, version, usage_count, created_at, updated_at)
|
complexity, is_active, version, usage_count, created_at, updated_at)
|
||||||
VALUES
|
VALUES
|
||||||
(:id, :cat, :uid, NULL,
|
(:id, :cat, :uid, :account_id, NULL,
|
||||||
'Other Script', 'other-script', 'echo other',
|
'Other Script', 'other-script', 'echo other',
|
||||||
'{"parameters": []}', '{}', '{}', '[]',
|
'{"parameters": []}', '{}', '{}', '[]',
|
||||||
'beginner', true, 1, 0, NOW(), NOW())
|
'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()
|
await test_db.commit()
|
||||||
|
|
||||||
@@ -617,6 +619,7 @@ class TestScriptTemplateFilters:
|
|||||||
"""shared=true returns only templates shared with the user's team."""
|
"""shared=true returns only templates shared with the user's team."""
|
||||||
user_resp = await client.get("/api/v1/auth/me", headers=auth_headers)
|
user_resp = await client.get("/api/v1/auth/me", headers=auth_headers)
|
||||||
user_id = user_resp.json()["id"]
|
user_id = user_resp.json()["id"]
|
||||||
|
account_id = user_resp.json()["account_id"]
|
||||||
|
|
||||||
cat_id = "b0000000-0000-0000-0000-000000000001"
|
cat_id = "b0000000-0000-0000-0000-000000000001"
|
||||||
|
|
||||||
@@ -639,32 +642,32 @@ class TestScriptTemplateFilters:
|
|||||||
await test_db.execute(
|
await test_db.execute(
|
||||||
sa.text("""
|
sa.text("""
|
||||||
INSERT INTO script_templates
|
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,
|
parameters_schema, default_values, validation_rules, tags,
|
||||||
complexity, is_active, version, usage_count, created_at, updated_at)
|
complexity, is_active, version, usage_count, created_at, updated_at)
|
||||||
VALUES
|
VALUES
|
||||||
(:id, :cat, :uid, :tid,
|
(:id, :cat, :uid, :account_id, :tid,
|
||||||
'Team Script', 'team-script', 'echo team',
|
'Team Script', 'team-script', 'echo team',
|
||||||
'{"parameters": []}', '{}', '{}', '[]',
|
'{"parameters": []}', '{}', '{}', '[]',
|
||||||
'beginner', true, 1, 0, NOW(), NOW())
|
'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)
|
# Template NOT shared (no team_id)
|
||||||
await test_db.execute(
|
await test_db.execute(
|
||||||
sa.text("""
|
sa.text("""
|
||||||
INSERT INTO script_templates
|
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,
|
parameters_schema, default_values, validation_rules, tags,
|
||||||
complexity, is_active, version, usage_count, created_at, updated_at)
|
complexity, is_active, version, usage_count, created_at, updated_at)
|
||||||
VALUES
|
VALUES
|
||||||
(:id, :cat, :uid, NULL,
|
(:id, :cat, :uid, :account_id, NULL,
|
||||||
'Personal Script', 'personal-script', 'echo personal',
|
'Personal Script', 'personal-script', 'echo personal',
|
||||||
'{"parameters": []}', '{}', '{}', '[]',
|
'{"parameters": []}', '{}', '{}', '[]',
|
||||||
'beginner', true, 1, 0, NOW(), NOW())
|
'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()
|
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()
|
await test_db.flush()
|
||||||
|
|
||||||
step = AISessionStep(
|
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,
|
content={"text": "test"}, confidence_at_step=0.5,
|
||||||
)
|
)
|
||||||
test_db.add(step)
|
test_db.add(step)
|
||||||
@@ -88,7 +88,7 @@ async def test_switch_branch(client: AsyncClient, test_user, auth_headers, test_
|
|||||||
await test_db.flush()
|
await test_db.flush()
|
||||||
|
|
||||||
step = AISessionStep(
|
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,
|
content={"text": "test"}, confidence_at_step=0.5,
|
||||||
)
|
)
|
||||||
test_db.add(step)
|
test_db.add(step)
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ async def test_edit_output_api(client: AsyncClient, test_user, auth_headers, tes
|
|||||||
|
|
||||||
output = SessionResolutionOutput(
|
output = SessionResolutionOutput(
|
||||||
session_id=session.id,
|
session_id=session.id,
|
||||||
|
account_id=session.account_id,
|
||||||
output_type="psa_ticket_notes",
|
output_type="psa_ticket_notes",
|
||||||
generated_content="Original",
|
generated_content="Original",
|
||||||
status="draft",
|
status="draft",
|
||||||
|
|||||||
@@ -219,7 +219,7 @@ class TestSessionSharing:
|
|||||||
json={"visibility": "public"},
|
json={"visibility": "public"},
|
||||||
headers=other_headers
|
headers=other_headers
|
||||||
)
|
)
|
||||||
assert response.status_code == 403
|
assert response.status_code == 404
|
||||||
|
|
||||||
async def test_share_nonexistent_session(self, client: AsyncClient, auth_headers):
|
async def test_share_nonexistent_session(self, client: AsyncClient, auth_headers):
|
||||||
"""Creating a share for nonexistent session returns 404."""
|
"""Creating a share for nonexistent session returns 404."""
|
||||||
|
|||||||
@@ -213,6 +213,7 @@ async def test_record_decision_persists_and_bumps_state_version(
|
|||||||
title="x",
|
title="x",
|
||||||
description="y",
|
description="y",
|
||||||
confidence_pct=50,
|
confidence_pct=50,
|
||||||
|
ai_drafted_script="Write-Output 'ok'",
|
||||||
)
|
)
|
||||||
test_db.add(fix)
|
test_db.add(fix)
|
||||||
await test_db.commit()
|
await test_db.commit()
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ async def _create_account_and_user(db: AsyncSession, prefix: str):
|
|||||||
async def _login(client: AsyncClient, email: str, password: str) -> dict:
|
async def _login(client: AsyncClient, email: str, password: str) -> dict:
|
||||||
"""Log in and return Authorization headers."""
|
"""Log in and return Authorization headers."""
|
||||||
resp = await client.post(
|
resp = await client.post(
|
||||||
"/api/v1/auth/login",
|
"/api/v1/auth/login/json",
|
||||||
json={"email": email, "password": password},
|
json={"email": email, "password": password},
|
||||||
)
|
)
|
||||||
assert resp.status_code == 200, f"Login failed: {resp.text}"
|
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_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")
|
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(
|
category = TreeCategory(
|
||||||
name="Shared Category",
|
name="Shared Category",
|
||||||
slug=f"shared-cat-{uuid.uuid4().hex[:6]}",
|
slug=f"shared-cat-{uuid.uuid4().hex[:6]}",
|
||||||
account_id=None,
|
account_id=acct_a.id,
|
||||||
is_active=True,
|
is_active=True,
|
||||||
)
|
)
|
||||||
test_db.add(category)
|
test_db.add(category)
|
||||||
@@ -270,6 +270,7 @@ async def test_get_session_returns_404_not_403_for_other_user(
|
|||||||
session_b = Session(
|
session_b = Session(
|
||||||
tree_id=tree_b.id,
|
tree_id=tree_b.id,
|
||||||
user_id=user_b.id,
|
user_id=user_b.id,
|
||||||
|
account_id=acct_b.id,
|
||||||
tree_snapshot={"id": "root", "type": "start", "children": []},
|
tree_snapshot={"id": "root", "type": "start", "children": []},
|
||||||
path_taken=[],
|
path_taken=[],
|
||||||
decisions=[],
|
decisions=[],
|
||||||
@@ -384,6 +385,7 @@ async def test_share_revoke_returns_404_not_403_for_other_user(
|
|||||||
session_b = Session(
|
session_b = Session(
|
||||||
tree_id=tree_b.id,
|
tree_id=tree_b.id,
|
||||||
user_id=user_b.id,
|
user_id=user_b.id,
|
||||||
|
account_id=acct_b.id,
|
||||||
tree_snapshot={"id": "root", "type": "start", "children": []},
|
tree_snapshot={"id": "root", "type": "start", "children": []},
|
||||||
path_taken=[],
|
path_taken=[],
|
||||||
decisions=[],
|
decisions=[],
|
||||||
@@ -534,6 +536,7 @@ async def test_maintenance_schedule_returns_404_for_other_team(
|
|||||||
# Create a schedule for that tree
|
# Create a schedule for that tree
|
||||||
schedule_b = MaintenanceSchedule(
|
schedule_b = MaintenanceSchedule(
|
||||||
tree_id=tree_b.id,
|
tree_id=tree_b.id,
|
||||||
|
account_id=acct_b.id,
|
||||||
created_by=user_b.id,
|
created_by=user_b.id,
|
||||||
cron_expression="0 2 * * 0",
|
cron_expression="0 2 * * 0",
|
||||||
timezone="UTC",
|
timezone="UTC",
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from datetime import datetime, timezone, timedelta
|
|||||||
from httpx import AsyncClient
|
from httpx import AsyncClient
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from app.models.account import Account
|
||||||
from app.models.tree import Tree
|
from app.models.tree import Tree
|
||||||
from app.models.tree_share import TreeShare
|
from app.models.tree_share import TreeShare
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
@@ -287,13 +288,17 @@ class TestTreeSharing:
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_migration_defaults_visibility_to_team(test_db):
|
async def test_migration_defaults_visibility_to_team(test_db):
|
||||||
"""Test that existing trees default to 'team' visibility after migration."""
|
"""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
|
# Create a tree without specifying visibility
|
||||||
tree = Tree(
|
tree = Tree(
|
||||||
name="Old Tree",
|
name="Old Tree",
|
||||||
description="Created before migration",
|
description="Created before migration",
|
||||||
tree_structure={"id": "root", "type": "decision", "question": "Test?", "children": []},
|
tree_structure={"id": "root", "type": "decision", "question": "Test?", "children": []},
|
||||||
author_id=None,
|
author_id=None,
|
||||||
account_id=None
|
account_id=account.id
|
||||||
)
|
)
|
||||||
test_db.add(tree)
|
test_db.add(tree)
|
||||||
await test_db.commit()
|
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
|
f"/api/v1/uploads/{upload.id}", headers=other_headers
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 403
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user