feat(PORT-004A): add workspace bootstrap and scoped settings

This commit is contained in:
Bu5hm4nn
2026-03-24 20:18:12 +01:00
parent 9d1a2f3fe8
commit 75f8e0a282
9 changed files with 335 additions and 17 deletions

1
.gitignore vendored
View File

@@ -4,6 +4,7 @@ __pycache__/
.env .env
config/secrets.yaml config/secrets.yaml
data/cache/ data/cache/
data/workspaces/
.idea/ .idea/
.vscode/ .vscode/
.worktrees/ .worktrees/

View File

@@ -12,10 +12,12 @@ from typing import Any
from fastapi import FastAPI, Request, WebSocket, WebSocketDisconnect from fastapi import FastAPI, Request, WebSocket, WebSocketDisconnect
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import RedirectResponse
from nicegui import ui # type: ignore[attr-defined] from nicegui import ui # type: ignore[attr-defined]
import app.pages # noqa: F401 import app.pages # noqa: F401
from app.api.routes import router as api_router from app.api.routes import router as api_router
from app.models.workspace import WORKSPACE_COOKIE, get_workspace_repository
from app.services.cache import CacheService from app.services.cache import CacheService
from app.services.data_service import DataService from app.services.data_service import DataService
from app.services.runtime import set_data_service from app.services.runtime import set_data_service
@@ -146,6 +148,21 @@ async def health(request: Request) -> dict[str, Any]:
} }
@app.get("/workspaces/bootstrap", tags=["workspace"])
async def bootstrap_workspace() -> RedirectResponse:
workspace_id = get_workspace_repository().create_workspace_id()
response = RedirectResponse(url=f"/{workspace_id}", status_code=303)
response.set_cookie(
key=WORKSPACE_COOKIE,
value=workspace_id,
httponly=True,
samesite="lax",
max_age=60 * 60 * 24 * 365,
path="/",
)
return response
@app.websocket("/ws/updates") @app.websocket("/ws/updates")
async def websocket_updates(websocket: WebSocket) -> None: async def websocket_updates(websocket: WebSocket) -> None:
manager: ConnectionManager = websocket.app.state.ws_manager manager: ConnectionManager = websocket.app.state.ws_manager

View File

@@ -222,12 +222,13 @@ class PortfolioRepository:
CONFIG_PATH = Path("data/portfolio_config.json") CONFIG_PATH = Path("data/portfolio_config.json")
def __init__(self) -> None: def __init__(self, config_path: Path | None = None) -> None:
self.CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True) self.config_path = config_path or self.CONFIG_PATH
self.config_path.parent.mkdir(parents=True, exist_ok=True)
def save(self, config: PortfolioConfig) -> None: def save(self, config: PortfolioConfig) -> None:
"""Save configuration to disk.""" """Save configuration to disk."""
with open(self.CONFIG_PATH, "w") as f: with open(self.config_path, "w") as f:
json.dump(config.to_dict(), f, indent=2) json.dump(config.to_dict(), f, indent=2)
def load(self) -> PortfolioConfig: def load(self) -> PortfolioConfig:
@@ -235,13 +236,13 @@ class PortfolioRepository:
Returns default configuration if file doesn't exist. Returns default configuration if file doesn't exist.
""" """
if not self.CONFIG_PATH.exists(): if not self.config_path.exists():
default = PortfolioConfig() default = PortfolioConfig()
self.save(default) self.save(default)
return default return default
try: try:
with open(self.CONFIG_PATH) as f: with open(self.config_path) as f:
data = json.load(f) data = json.load(f)
return PortfolioConfig.from_dict(data) return PortfolioConfig.from_dict(data)
except (json.JSONDecodeError, ValueError) as e: except (json.JSONDecodeError, ValueError) as e:

65
app/models/workspace.py Normal file
View File

@@ -0,0 +1,65 @@
from __future__ import annotations
import re
from pathlib import Path
from uuid import uuid4
from app.models.portfolio import PortfolioConfig, PortfolioRepository
WORKSPACE_COOKIE = "workspace_id"
_WORKSPACE_ID_RE = re.compile(
r"^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$",
re.IGNORECASE,
)
class WorkspaceRepository:
"""Persist workspace-scoped portfolio configuration on disk."""
def __init__(self, base_path: Path | str = Path("data/workspaces")) -> None:
self.base_path = Path(base_path)
self.base_path.mkdir(parents=True, exist_ok=True)
def is_valid_workspace_id(self, workspace_id: str) -> bool:
return bool(_WORKSPACE_ID_RE.match(workspace_id))
def workspace_exists(self, workspace_id: str) -> bool:
if not self.is_valid_workspace_id(workspace_id):
return False
return self._portfolio_path(workspace_id).exists()
def create_workspace(self, workspace_id: str | None = None) -> PortfolioConfig:
resolved_workspace_id = workspace_id or str(uuid4())
if not self.is_valid_workspace_id(resolved_workspace_id):
raise ValueError("workspace_id must be a UUID4 string")
config = PortfolioConfig()
self.save_portfolio_config(resolved_workspace_id, config)
return config
def create_workspace_id(self) -> str:
workspace_id = str(uuid4())
self.create_workspace(workspace_id)
return workspace_id
def load_portfolio_config(self, workspace_id: str) -> PortfolioConfig:
if not self.workspace_exists(workspace_id):
raise FileNotFoundError(f"Unknown workspace: {workspace_id}")
return PortfolioRepository(self._portfolio_path(workspace_id)).load()
def save_portfolio_config(self, workspace_id: str, config: PortfolioConfig) -> None:
if not self.is_valid_workspace_id(workspace_id):
raise ValueError("workspace_id must be a UUID4 string")
PortfolioRepository(self._portfolio_path(workspace_id)).save(config)
def _portfolio_path(self, workspace_id: str) -> Path:
return self.base_path / workspace_id / "portfolio_config.json"
_workspace_repo: WorkspaceRepository | None = None
def get_workspace_repository() -> WorkspaceRepository:
global _workspace_repo
if _workspace_repo is None:
_workspace_repo = WorkspaceRepository()
return _workspace_repo

View File

@@ -18,6 +18,15 @@ NAV_ITEMS: list[tuple[str, str, str]] = [
] ]
def nav_items(workspace_id: str | None = None) -> list[tuple[str, str, str]]:
if not workspace_id:
return NAV_ITEMS
return [
("overview", f"/{workspace_id}", "Overview"),
("settings", f"/{workspace_id}/settings", "Settings"),
]
def demo_spot_price() -> float: def demo_spot_price() -> float:
return 215.0 return 215.0
@@ -166,7 +175,7 @@ def strategy_metrics(strategy_name: str, scenario_pct: int) -> dict[str, Any]:
@contextmanager @contextmanager
def dashboard_page(title: str, subtitle: str, current: str) -> Iterator[ui.column]: def dashboard_page(title: str, subtitle: str, current: str, workspace_id: str | None = None) -> Iterator[ui.column]:
ui.colors(primary="#0f172a", secondary="#1e293b", accent="#0ea5e9") ui.colors(primary="#0f172a", secondary="#1e293b", accent="#0ea5e9")
# Header must be at page level, not inside container # Header must be at page level, not inside container
@@ -179,7 +188,7 @@ def dashboard_page(title: str, subtitle: str, current: str) -> Iterator[ui.colum
ui.label("Vault Dashboard").classes("text-lg font-bold text-slate-900 dark:text-slate-50") ui.label("Vault Dashboard").classes("text-lg font-bold text-slate-900 dark:text-slate-50")
ui.label("NiceGUI hedging cockpit").classes("text-xs text-slate-500 dark:text-slate-400") ui.label("NiceGUI hedging cockpit").classes("text-xs text-slate-500 dark:text-slate-400")
with ui.row().classes("items-center gap-2 max-sm:flex-wrap"): with ui.row().classes("items-center gap-2 max-sm:flex-wrap"):
for key, href, label in NAV_ITEMS: for key, href, label in nav_items(workspace_id):
active = key == current active = key == current
link_classes = "rounded-lg px-4 py-2 text-sm font-medium no-underline transition " + ( link_classes = "rounded-lg px-4 py-2 text-sm font-medium no-underline transition " + (
"bg-slate-900 text-white dark:bg-slate-100 dark:text-slate-900" "bg-slate-900 text-white dark:bg-slate-100 dark:text-slate-900"

View File

@@ -2,10 +2,12 @@ from __future__ import annotations
from datetime import datetime, timezone from datetime import datetime, timezone
from fastapi import Request
from fastapi.responses import RedirectResponse
from nicegui import ui from nicegui import ui
from app.components import PortfolioOverview from app.components import PortfolioOverview
from app.models.portfolio import get_portfolio_repository from app.models.workspace import WORKSPACE_COOKIE, get_workspace_repository
from app.pages.common import dashboard_page, quick_recommendations, recommendation_style, strategy_catalog from app.pages.common import dashboard_page, quick_recommendations, recommendation_style, strategy_catalog
from app.services.alerts import AlertService, build_portfolio_alert_context from app.services.alerts import AlertService, build_portfolio_alert_context
from app.services.runtime import get_data_service from app.services.runtime import get_data_service
@@ -31,10 +33,66 @@ def _alert_badge_classes(severity: str) -> str:
}.get(severity, "rounded-full bg-slate-100 px-3 py-1 text-xs font-semibold text-slate-700") }.get(severity, "rounded-full bg-slate-100 px-3 py-1 text-xs font-semibold text-slate-700")
def _render_workspace_recovery(title: str, message: str) -> None:
with ui.column().classes("mx-auto mt-24 w-full max-w-2xl gap-6 px-6 text-center"):
ui.icon("folder_off").classes("mx-auto text-6xl text-slate-400")
ui.label(title).classes("text-3xl font-bold text-slate-900 dark:text-slate-50")
ui.label(message).classes("text-base text-slate-500 dark:text-slate-400")
with ui.row().classes("mx-auto gap-3"):
ui.link("Get started", "/workspaces/bootstrap").classes(
"rounded-lg bg-slate-900 px-5 py-3 text-sm font-semibold text-white no-underline dark:bg-slate-100 dark:text-slate-900"
)
ui.link("Go to welcome page", "/").classes(
"rounded-lg border border-slate-300 px-5 py-3 text-sm font-semibold text-slate-700 no-underline dark:border-slate-700 dark:text-slate-200"
)
@ui.page("/") @ui.page("/")
def welcome_page(request: Request):
repo = get_workspace_repository()
workspace_id = request.cookies.get(WORKSPACE_COOKIE, "")
if workspace_id and repo.workspace_exists(workspace_id):
return RedirectResponse(url=f"/{workspace_id}", status_code=307)
with ui.column().classes("mx-auto mt-24 w-full max-w-3xl gap-8 px-6"):
with ui.card().classes(
"w-full rounded-3xl border border-slate-200 bg-white p-8 shadow-sm dark:border-slate-800 dark:bg-slate-900"
):
ui.label("Vault Dashboard").classes("text-sm font-semibold uppercase tracking-[0.2em] text-sky-600")
ui.label("Create a private workspace URL").classes("text-4xl font-bold text-slate-900 dark:text-slate-50")
ui.label(
"Start with a workspace-scoped overview and settings area. Your portfolio defaults are stored server-side and your browser keeps a workspace cookie for quick return visits."
).classes("text-base text-slate-500 dark:text-slate-400")
with ui.row().classes("items-center gap-4 pt-4"):
ui.link("Get started", "/workspaces/bootstrap").classes(
"rounded-lg bg-slate-900 px-5 py-3 text-sm font-semibold text-white no-underline dark:bg-slate-100 dark:text-slate-900"
)
ui.label("You can always create a fresh workspace later if a link is lost.").classes(
"text-sm text-slate-500 dark:text-slate-400"
)
@ui.page("/overview") @ui.page("/overview")
async def overview_page() -> None: def legacy_overview_page(request: Request):
config = get_portfolio_repository().load() repo = get_workspace_repository()
workspace_id = request.cookies.get(WORKSPACE_COOKIE, "")
if workspace_id and repo.workspace_exists(workspace_id):
return RedirectResponse(url=f"/{workspace_id}", status_code=307)
return RedirectResponse(url="/", status_code=307)
@ui.page("/{workspace_id}")
@ui.page("/{workspace_id}/overview")
async def overview_page(workspace_id: str) -> None:
repo = get_workspace_repository()
if not repo.workspace_exists(workspace_id):
_render_workspace_recovery(
"Workspace not found",
"The workspace link looks missing or expired. Create a new workspace or return to the welcome page.",
)
return
config = repo.load_portfolio_config(workspace_id)
data_service = get_data_service() data_service = get_data_service()
symbol = data_service.default_symbol symbol = data_service.default_symbol
quote = await data_service.get_quote(symbol) quote = await data_service.get_quote(symbol)
@@ -57,6 +115,7 @@ async def overview_page() -> None:
"Overview", "Overview",
"Portfolio health, LTV risk, and quick strategy guidance for the current GLD-backed loan.", "Portfolio health, LTV risk, and quick strategy guidance for the current GLD-backed loan.",
"overview", "overview",
workspace_id=workspace_id,
): ):
with ui.row().classes("w-full items-center justify-between gap-4 max-md:flex-col max-md:items-start"): with ui.row().classes("w-full items-center justify-between gap-4 max-md:flex-col max-md:items-start"):
ui.label(quote_status).classes("text-sm text-slate-500 dark:text-slate-400") ui.label(quote_status).classes("text-sm text-slate-500 dark:text-slate-400")

View File

@@ -1,8 +1,11 @@
from __future__ import annotations from __future__ import annotations
from fastapi import Request
from fastapi.responses import RedirectResponse
from nicegui import ui from nicegui import ui
from app.models.portfolio import PortfolioConfig, get_portfolio_repository from app.models.portfolio import PortfolioConfig
from app.models.workspace import WORKSPACE_COOKIE, get_workspace_repository
from app.pages.common import dashboard_page from app.pages.common import dashboard_page
from app.services.alerts import AlertService, build_portfolio_alert_context from app.services.alerts import AlertService, build_portfolio_alert_context
from app.services.settings_status import save_status_text from app.services.settings_status import save_status_text
@@ -16,11 +19,40 @@ def _alert_badge_classes(severity: str) -> str:
}.get(severity, "rounded-full bg-slate-100 px-3 py-1 text-xs font-semibold text-slate-700") }.get(severity, "rounded-full bg-slate-100 px-3 py-1 text-xs font-semibold text-slate-700")
def _render_workspace_recovery() -> None:
with ui.column().classes("mx-auto mt-24 w-full max-w-2xl gap-6 px-6 text-center"):
ui.icon("folder_off").classes("mx-auto text-6xl text-slate-400")
ui.label("Workspace not found").classes("text-3xl font-bold text-slate-900 dark:text-slate-50")
ui.label(
"The requested workspace is unavailable. Start a new workspace or return to the welcome page."
).classes("text-base text-slate-500 dark:text-slate-400")
with ui.row().classes("mx-auto gap-3"):
ui.link("Get started", "/workspaces/bootstrap").classes(
"rounded-lg bg-slate-900 px-5 py-3 text-sm font-semibold text-white no-underline dark:bg-slate-100 dark:text-slate-900"
)
ui.link("Go to welcome page", "/").classes(
"rounded-lg border border-slate-300 px-5 py-3 text-sm font-semibold text-slate-700 no-underline dark:border-slate-700 dark:text-slate-200"
)
@ui.page("/settings") @ui.page("/settings")
def settings_page() -> None: def legacy_settings_page(request: Request):
"""Settings page with persistent portfolio configuration.""" repo = get_workspace_repository()
repo = get_portfolio_repository() workspace_id = request.cookies.get(WORKSPACE_COOKIE, "")
config = repo.load() if workspace_id and repo.workspace_exists(workspace_id):
return RedirectResponse(url=f"/{workspace_id}/settings", status_code=307)
return RedirectResponse(url="/", status_code=307)
@ui.page("/{workspace_id}/settings")
def settings_page(workspace_id: str) -> None:
"""Settings page with workspace-scoped persistent portfolio configuration."""
workspace_repo = get_workspace_repository()
if not workspace_repo.workspace_exists(workspace_id):
_render_workspace_recovery()
return
config = workspace_repo.load_portfolio_config(workspace_id)
alert_service = AlertService() alert_service = AlertService()
syncing_entry_basis = False syncing_entry_basis = False
@@ -61,6 +93,7 @@ def settings_page() -> None:
"Settings", "Settings",
"Configure portfolio assumptions, collateral entry basis, preferred market data inputs, and alert thresholds.", "Configure portfolio assumptions, collateral entry basis, preferred market data inputs, and alert thresholds.",
"settings", "settings",
workspace_id=workspace_id,
): ):
with ui.row().classes("w-full gap-6 max-lg:flex-col"): with ui.row().classes("w-full gap-6 max-lg:flex-col"):
with ui.card().classes( with ui.card().classes(
@@ -326,7 +359,7 @@ def settings_page() -> None:
def save_settings() -> None: def save_settings() -> None:
try: try:
new_config = build_preview_config() new_config = build_preview_config()
repo.save(new_config) workspace_repo.save_portfolio_config(workspace_id, new_config)
render_alert_state() render_alert_state()
status.set_text(save_status_text(new_config)) status.set_text(save_status_text(new_config))

View File

@@ -16,12 +16,25 @@ def test_homepage_and_options_page_render() -> None:
page.goto(BASE_URL, wait_until="domcontentloaded", timeout=30000) page.goto(BASE_URL, wait_until="domcontentloaded", timeout=30000)
expect(page).to_have_title("NiceGUI") expect(page).to_have_title("NiceGUI")
expect(page.locator("text=Create a private workspace URL").first).to_be_visible(timeout=10000)
page.get_by_role("link", name="Get started").click()
page.wait_for_url(f"{BASE_URL}/*", timeout=15000)
workspace_url = page.url
workspace_id = workspace_url.removeprefix(f"{BASE_URL}/")
assert workspace_id
cookies = page.context.cookies()
workspace_cookie = next(cookie for cookie in cookies if cookie["name"] == "workspace_id")
assert workspace_cookie["value"] == workspace_id
expect(page.locator("text=Vault Dashboard").first).to_be_visible(timeout=10000) expect(page.locator("text=Vault Dashboard").first).to_be_visible(timeout=10000)
expect(page.locator("text=Overview").first).to_be_visible(timeout=10000) expect(page.locator("text=Overview").first).to_be_visible(timeout=10000)
expect(page.locator("text=Live quote source:").first).to_be_visible(timeout=15000) expect(page.locator("text=Live quote source:").first).to_be_visible(timeout=15000)
expect(page.locator("text=Alert Status").first).to_be_visible(timeout=15000) expect(page.locator("text=Alert Status").first).to_be_visible(timeout=15000)
page.screenshot(path=str(ARTIFACTS / "overview.png"), full_page=True) page.screenshot(path=str(ARTIFACTS / "overview.png"), full_page=True)
page.goto(BASE_URL, wait_until="domcontentloaded", timeout=30000)
page.wait_for_url(workspace_url, timeout=15000)
expect(page.locator("text=Alert Status").first).to_be_visible(timeout=15000)
page.goto(f"{BASE_URL}/backtests", wait_until="networkidle", timeout=30000) page.goto(f"{BASE_URL}/backtests", wait_until="networkidle", timeout=30000)
expect(page.locator("text=Backtests").first).to_be_visible(timeout=15000) expect(page.locator("text=Backtests").first).to_be_visible(timeout=15000)
expect(page.locator("text=Scenario Form").first).to_be_visible(timeout=15000) expect(page.locator("text=Scenario Form").first).to_be_visible(timeout=15000)
@@ -79,15 +92,35 @@ def test_homepage_and_options_page_render() -> None:
assert "RuntimeError" not in body_text assert "RuntimeError" not in body_text
page.screenshot(path=str(ARTIFACTS / "options.png"), full_page=True) page.screenshot(path=str(ARTIFACTS / "options.png"), full_page=True)
page.goto(f"{BASE_URL}/settings", wait_until="domcontentloaded", timeout=30000) page.goto(f"{workspace_url}/settings", wait_until="domcontentloaded", timeout=30000)
assert page.url.endswith("/settings")
expect(page.locator("text=Settings").first).to_be_visible(timeout=15000) expect(page.locator("text=Settings").first).to_be_visible(timeout=15000)
expect(page.locator("text=Collateral entry basis").first).to_be_visible(timeout=15000) expect(page.locator("text=Collateral entry basis").first).to_be_visible(timeout=15000)
expect(page.locator("text=Entry price ($/oz)").first).to_be_visible(timeout=15000) expect(page.locator("text=Entry price ($/oz)").first).to_be_visible(timeout=15000)
budget_input = page.get_by_label("Monthly hedge budget ($)")
budget_input.fill("12345")
page.get_by_role("button", name="Save settings").click()
expect(page.locator("text=Settings saved successfully").first).to_be_visible(timeout=15000)
page.reload(wait_until="domcontentloaded", timeout=30000)
expect(page.get_by_label("Monthly hedge budget ($)")).to_have_value("12345")
settings_text = page.locator("body").inner_text(timeout=15000) settings_text = page.locator("body").inner_text(timeout=15000)
assert "RuntimeError" not in settings_text assert "RuntimeError" not in settings_text
assert "Server error" not in settings_text assert "Server error" not in settings_text
page.screenshot(path=str(ARTIFACTS / "settings.png"), full_page=True) page.screenshot(path=str(ARTIFACTS / "settings.png"), full_page=True)
second_context = browser.new_context(viewport={"width": 1440, "height": 1000})
second_page = second_context.new_page()
second_page.goto(BASE_URL, wait_until="domcontentloaded", timeout=30000)
expect(second_page.locator("text=Create a private workspace URL").first).to_be_visible(timeout=10000)
second_page.get_by_role("link", name="Get started").click()
second_page.wait_for_url(f"{BASE_URL}/*", timeout=15000)
second_workspace_url = second_page.url
assert second_workspace_url != workspace_url
second_page.goto(f"{second_workspace_url}/settings", wait_until="domcontentloaded", timeout=30000)
expect(second_page.get_by_label("Monthly hedge budget ($)")).to_have_value("8000")
second_page.close()
second_context.close()
page.goto(f"{BASE_URL}/hedge", wait_until="domcontentloaded", timeout=30000) page.goto(f"{BASE_URL}/hedge", wait_until="domcontentloaded", timeout=30000)
expect(page.locator("text=Hedge Analysis").first).to_be_visible(timeout=15000) expect(page.locator("text=Hedge Analysis").first).to_be_visible(timeout=15000)
expect(page.locator("text=Strategy selector").first).to_be_visible(timeout=15000) expect(page.locator("text=Strategy selector").first).to_be_visible(timeout=15000)

100
tests/test_workspace.py Normal file
View File

@@ -0,0 +1,100 @@
from __future__ import annotations
import re
from uuid import uuid4
from fastapi.testclient import TestClient
from app.main import app
from app.models.workspace import WorkspaceRepository
UUID4_RE = re.compile(
r"^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$",
re.IGNORECASE,
)
def _install_workspace_repo(tmp_path, monkeypatch) -> WorkspaceRepository:
from app.models import workspace as workspace_module
repo = WorkspaceRepository(base_path=tmp_path / "workspaces")
monkeypatch.setattr(workspace_module, "_workspace_repo", repo)
return repo
def test_workspace_repository_persists_workspace_specific_portfolio_config(tmp_path) -> None:
repo = WorkspaceRepository(base_path=tmp_path / "workspaces")
workspace_id = str(uuid4())
created = repo.create_workspace(workspace_id)
created.loan_amount = 123_456.0
repo.save_portfolio_config(workspace_id, created)
reloaded = repo.load_portfolio_config(workspace_id)
assert repo.workspace_exists(workspace_id)
assert reloaded.loan_amount == 123_456.0
assert reloaded.gold_value == created.gold_value
def test_root_without_workspace_cookie_shows_welcome_page(tmp_path, monkeypatch) -> None:
_install_workspace_repo(tmp_path, monkeypatch)
with TestClient(app) as client:
response = client.get("/")
assert response.status_code == 200
assert "Create a private workspace URL" in response.text
assert "Get started" in response.text
def test_bootstrap_endpoint_creates_workspace_cookie_and_redirects(tmp_path, monkeypatch) -> None:
repo = _install_workspace_repo(tmp_path, monkeypatch)
with TestClient(app) as client:
response = client.get("/workspaces/bootstrap", follow_redirects=False)
assert response.status_code in {302, 303, 307}
location = response.headers["location"]
workspace_id = location.strip("/")
assert UUID4_RE.match(workspace_id)
assert repo.workspace_exists(workspace_id)
assert response.cookies.get("workspace_id") == workspace_id
def test_root_with_valid_workspace_cookie_redirects_to_workspace(tmp_path, monkeypatch) -> None:
repo = _install_workspace_repo(tmp_path, monkeypatch)
workspace_id = str(uuid4())
repo.create_workspace(workspace_id)
with TestClient(app) as client:
response = client.get("/", cookies={"workspace_id": workspace_id}, follow_redirects=False)
assert response.status_code in {302, 303, 307}
assert response.headers["location"] == f"/{workspace_id}"
def test_unknown_workspace_route_shows_friendly_recovery_path(tmp_path, monkeypatch) -> None:
_install_workspace_repo(tmp_path, monkeypatch)
with TestClient(app) as client:
response = client.get("/00000000-0000-4000-8000-000000000000")
assert response.status_code == 200
assert "Workspace not found" in response.text
assert "Get started" in response.text
def test_workspace_settings_round_trip_uses_workspace_storage(tmp_path, monkeypatch) -> None:
repo = _install_workspace_repo(tmp_path, monkeypatch)
workspace_id = str(uuid4())
config = repo.create_workspace(workspace_id)
config.monthly_budget = 9_999.0
repo.save_portfolio_config(workspace_id, config)
with TestClient(app) as client:
response = client.get(f"/{workspace_id}/settings")
assert response.status_code == 200
assert "Settings" in response.text
assert "9,999" in response.text or "9999" in response.text