Bring the original Overview gating contract forward to the new footnotes block: any non-empty `instance.overview` / AGNES_INSTANCE_OVERVIEW value enables the footnotes, an empty value hides them. The raw yaml HTML body is no longer rendered (the static product framing replaces it) — operators relying on custom Overview HTML should migrate that content to instance.custom_scripts or admin-edited news. Preserves the explanatory comments from the original Overview section (operator-owned, opt-in, no dismiss button) so future readers understand why the block is gated. Splits the test into two halves matching the original gating pattern: footnotes appear when the flag is set, hidden when unset. Uses "Get the most out of it" as the marker (unique to the footnotes copy) since "What leaves your machine" still appears in the untouched session-privacy annotation lower on the page.
412 lines
18 KiB
Python
412 lines
18 KiB
Python
"""GET /home — state-aware landing page.
|
||
|
||
The boolean ``users.onboarded`` drives template selection. No
|
||
auto-transition: the not-onboarded view stays put until the user reloads
|
||
(the brainstorm called this out explicitly — quiet UI is preferable to a
|
||
surprise redirect mid-setup).
|
||
|
||
See origin: docs/brainstorms/home-page-requirements.md.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import tempfile
|
||
import uuid
|
||
|
||
import pytest
|
||
|
||
|
||
@pytest.fixture
|
||
def fresh_db(monkeypatch):
|
||
with tempfile.TemporaryDirectory() as tmp:
|
||
monkeypatch.setenv("DATA_DIR", tmp)
|
||
monkeypatch.setenv("TESTING", "1")
|
||
monkeypatch.setenv("JWT_SECRET_KEY", "test-jwt-secret-key-minimum-32-chars!!")
|
||
yield tmp
|
||
|
||
|
||
def _make_user_and_session(conn, email="u@example.com", onboarded=False):
|
||
from src.repositories.users import UserRepository
|
||
from app.auth.jwt import create_access_token
|
||
|
||
uid = str(uuid.uuid4())
|
||
UserRepository(conn).create(id=uid, email=email, name=email.split("@")[0])
|
||
if onboarded:
|
||
conn.execute("UPDATE users SET onboarded = TRUE WHERE id = ?", [uid])
|
||
return uid, create_access_token(user_id=uid, email=email)
|
||
|
||
|
||
def _client(follow_redirects: bool = True):
|
||
from fastapi.testclient import TestClient
|
||
from app.main import app
|
||
|
||
return TestClient(app, follow_redirects=follow_redirects)
|
||
|
||
|
||
def test_home_unauth_redirects_to_login(fresh_db):
|
||
"""Non-API HTML routes redirect 401→/login per app.main's
|
||
StarletteHTTPException handler. /home follows that contract."""
|
||
c = _client(follow_redirects=False)
|
||
resp = c.get("/home")
|
||
assert resp.status_code == 302
|
||
assert resp.headers["location"].startswith("/login")
|
||
|
||
|
||
def test_home_not_onboarded_user_sees_setup_view(fresh_db):
|
||
"""A FALSE-onboarded user gets the install/setup template, identifiable
|
||
by its 'Install Claude Code' heading and the self-mark button."""
|
||
from src.db import get_system_db, close_system_db
|
||
|
||
conn = get_system_db()
|
||
try:
|
||
_, sess = _make_user_and_session(conn, onboarded=False)
|
||
finally:
|
||
conn.close()
|
||
close_system_db()
|
||
|
||
c = _client()
|
||
resp = c.get("/home", cookies={"access_token": sess})
|
||
assert resp.status_code == 200
|
||
body = resp.text
|
||
assert "install Claude Code" in body # step 1 label
|
||
assert "install Agnes" in body # step 2 label
|
||
assert "self-mark-btn" in body # self-acknowledged escape hatch
|
||
assert "setupClaudeBtn" in body # primary one-click CTA from shared partial
|
||
|
||
|
||
def test_home_onboarded_user_sees_nav_hub(fresh_db):
|
||
"""A TRUE-onboarded user gets the post-onboarding view: the blue
|
||
install-hero is gone entirely (no welcome banner, no completion
|
||
badge, no inline step commands), the offboard escape strip is the
|
||
only setup-flow remnant rendered, and the rest of /home (connector
|
||
tiles, news, etc.) stays. PR #289 collapsed the dual-state hero
|
||
into a single not-onboarded-only render — pre-PR the onboarded
|
||
branch reused the same `.install-hero` shell with welcome copy
|
||
and a "Steps 1–4 done" badge."""
|
||
from src.db import get_system_db, close_system_db
|
||
|
||
conn = get_system_db()
|
||
try:
|
||
_, sess = _make_user_and_session(conn, onboarded=True)
|
||
finally:
|
||
conn.close()
|
||
close_system_db()
|
||
|
||
c = _client()
|
||
resp = c.get("/home", cookies={"access_token": sess})
|
||
assert resp.status_code == 200
|
||
body = resp.text
|
||
# Install hero entirely absent for onboarded users.
|
||
assert '<div class="install-hero">' not in body
|
||
# Offboard escape strip + its button replace the in-hero self-mark control.
|
||
assert '<div class="offboard-strip">' in body
|
||
assert "Mark me as offboarded" in body
|
||
# All six inline install-blocks are hidden post-onboarding — the
|
||
# labels rendered inside the install-block divs go away. Labels
|
||
# tracked with the CEO-mock-parity 6-step rename (Step 3 inserted
|
||
# as "Open a terminal", Step 6 added as the optional shortcut
|
||
# alias; the old Step 3 became Step 4).
|
||
assert "Step 1 — Install Claude Code on your computer" not in body
|
||
assert "Step 2 — Pick a folder for" not in body
|
||
assert "Step 3 — Open a terminal inside that folder" not in body
|
||
assert "Step 4 — Launch Claude with auto-approve on" not in body
|
||
assert "Step 5 — Get the install script" not in body
|
||
assert "Step 6 — Optional: create a one-word shortcut" not in body
|
||
|
||
|
||
def test_connectors_section_removed_from_home(fresh_db):
|
||
"""The dedicated `<details data-section="connectors">` block was
|
||
dropped from `/home` — the install-hero's Step 4 clipboard payload
|
||
(rendered via `_claude_setup_instructions.jinja` inside the manual
|
||
fallback) already inlines the same Asana / GWS / Atlassian prompts
|
||
from `app/web/connector_prompts.py` via
|
||
`app/web/setup_instructions.py::_connectors_block`. Showing them
|
||
twice on the same page was duplicate UX. The lead paragraph in the
|
||
install-hero now mentions the connectors briefly so users still see
|
||
the benefit before they hit the install.
|
||
|
||
Co-asserts the auto-mode block removal that this test originally
|
||
pinned — onboarded users still see neither the connectors block
|
||
nor the legacy auto-mode peer section."""
|
||
from src.db import get_system_db, close_system_db
|
||
|
||
conn = get_system_db()
|
||
try:
|
||
_, sess = _make_user_and_session(conn, onboarded=True)
|
||
finally:
|
||
conn.close()
|
||
close_system_db()
|
||
|
||
c = _client()
|
||
resp = c.get("/home", cookies={"access_token": sess})
|
||
assert resp.status_code == 200
|
||
body = resp.text
|
||
# Auto-mode peer section still gone (legacy guard, not regressed).
|
||
assert 'class="automode-card"' not in body
|
||
assert 'data-section="step3"' not in body
|
||
assert "Step 4 — Launch Claude with auto-approve on" not in body
|
||
# Dedicated connectors block is gone from /home in BOTH states.
|
||
assert 'class="connector-tiles"' not in body
|
||
assert 'data-section="connectors"' not in body
|
||
# Server-rendered HTML never carries the data-setup-minimized
|
||
# attribute on the .home-mock root — that's a client-side
|
||
# localStorage decision applied via JS on load.
|
||
assert '<div class="home-mock" data-setup-minimized' not in body
|
||
assert 'class="home-mock"\n' in body or '<div class="home-mock">' in body
|
||
|
||
# Not-onboarded path: same — the section disappears regardless of
|
||
# state. Lead-paragraph still surfaces the connector names so users
|
||
# know the benefit exists before they kick off the install.
|
||
conn = get_system_db()
|
||
try:
|
||
_, sess2 = _make_user_and_session(
|
||
conn, email="not-onboarded@example.com", onboarded=False
|
||
)
|
||
finally:
|
||
conn.close()
|
||
close_system_db()
|
||
body2 = _client().get("/home", cookies={"access_token": sess2}).text
|
||
assert 'class="connector-tiles"' not in body2
|
||
assert 'data-section="connectors"' not in body2
|
||
# Lead-paragraph mentions the three connector families so the
|
||
# benefit isn't lost when the dedicated section disappears.
|
||
assert "Asana, Google Workspace, Atlassian" in body2
|
||
|
||
|
||
def test_minimize_toggle_no_longer_rendered(fresh_db):
|
||
"""The "Minimize setup view" toggle used to live inside the
|
||
onboarded-branch of the install-hero. PR #289 hides the hero
|
||
entirely once `users.onboarded=true`, so the minimize toggle
|
||
has no rendering site anymore — verify the markup is absent
|
||
from both states. (The localStorage `agnes_home_setup_minimized`
|
||
flag and its applyMinimize() JS handler stay in the page so a
|
||
stale flag from a pre-PR session no-ops cleanly.)"""
|
||
from src.db import get_system_db, close_system_db
|
||
|
||
for onboarded in (False, True):
|
||
conn = get_system_db()
|
||
try:
|
||
_, sess = _make_user_and_session(
|
||
conn, email=f"user-{onboarded}@example.com", onboarded=onboarded
|
||
)
|
||
finally:
|
||
conn.close()
|
||
close_system_db()
|
||
c = _client()
|
||
resp = c.get("/home", cookies={"access_token": sess})
|
||
assert resp.status_code == 200
|
||
assert '<button id="setupMinimizeToggle"' not in resp.text
|
||
assert 'class="setup-minimize"' not in resp.text
|
||
|
||
|
||
def test_home_no_auto_transition_after_post_until_reload(fresh_db):
|
||
"""POST /api/me/onboarded flips the flag in the DB but the in-flight
|
||
/home response from before the POST keeps showing the setup view —
|
||
the next GET /home picks up the new state. Verifies the manual-reload
|
||
contract called out in the brainstorm."""
|
||
from src.db import get_system_db, close_system_db
|
||
|
||
conn = get_system_db()
|
||
try:
|
||
_, sess = _make_user_and_session(conn, onboarded=False)
|
||
finally:
|
||
conn.close()
|
||
close_system_db()
|
||
|
||
c = _client()
|
||
|
||
pre = c.get("/home", cookies={"access_token": sess})
|
||
# `class="install-block"` is the not-onboarded-only structural element
|
||
# holding the inline Step-1 install pane. Use it as the discriminator
|
||
# instead of a free-form string like "install Claude Code", which now
|
||
# also appears in the always-on SETUP_INSTRUCTIONS_TEMPLATE clipboard
|
||
# payload's preflight comment after the 2026-05-10 init-report fix.
|
||
assert 'class="install-block"' in pre.text # setup view
|
||
|
||
flip = c.post("/api/me/onboarded", cookies={"access_token": sess})
|
||
assert flip.status_code == 200
|
||
|
||
post = c.get("/home", cookies={"access_token": sess})
|
||
# PR #289: hero disappears entirely; offboard strip is the
|
||
# only setup-flow remnant. Use either as the nav-hub view marker.
|
||
assert '<div class="offboard-strip">' in post.text
|
||
assert 'class="install-block"' not in post.text
|
||
|
||
|
||
# ── GWS Email-admin button render tests (admin_email knob coverage) ────────
|
||
|
||
|
||
def test_home_hides_email_admin_button_when_admin_email_unset(fresh_db, monkeypatch):
|
||
"""When ``instance.admin_email`` is unset, the GWS connector tile
|
||
must NOT render the mailto link (template guards on truthiness;
|
||
empty resolver value cleanly hides). Defends against a `mailto:?`
|
||
link sneaking out as a render-time artifact."""
|
||
monkeypatch.delenv("AGNES_INSTANCE_ADMIN_EMAIL", raising=False)
|
||
monkeypatch.delenv("AGNES_GWS_CLIENT_ID", raising=False)
|
||
monkeypatch.delenv("AGNES_GWS_CLIENT_SECRET", raising=False)
|
||
from src.db import get_system_db, close_system_db
|
||
conn = get_system_db()
|
||
try:
|
||
_, sess = _make_user_and_session(conn)
|
||
finally:
|
||
conn.close()
|
||
close_system_db()
|
||
body = _client().get("/home", cookies={"access_token": sess}).text
|
||
# No "Email admin" CTA, no mailto: link in the body.
|
||
assert "Email admin" not in body
|
||
assert "mailto:?" not in body # specifically, no broken empty mailto
|
||
|
||
|
||
def test_home_no_longer_shows_email_admin_button(fresh_db, monkeypatch):
|
||
"""The Email-admin mailto CTA used to live inside the /home GWS
|
||
connector tile. With the dedicated `<details data-section="connectors">`
|
||
block removed (see test_connectors_section_removed_from_home above),
|
||
the button has no rendering site even when admin_email is set + GWS
|
||
is unconfigured. The escalation path lives inside the install
|
||
script's GWS step now — Claude prompts the user with the admin
|
||
email when the connector setup hits an OAuth gating wall, so the
|
||
affordance moves to the surface where it's actually useful."""
|
||
monkeypatch.setenv("AGNES_INSTANCE_ADMIN_EMAIL", "ops@example.com")
|
||
monkeypatch.delenv("AGNES_GWS_CLIENT_ID", raising=False)
|
||
monkeypatch.delenv("AGNES_GWS_CLIENT_SECRET", raising=False)
|
||
from src.db import get_system_db, close_system_db
|
||
conn = get_system_db()
|
||
try:
|
||
_, sess = _make_user_and_session(conn)
|
||
finally:
|
||
conn.close()
|
||
close_system_db()
|
||
body = _client().get("/home", cookies={"access_token": sess}).text
|
||
assert "Email admin" not in body
|
||
assert 'mailto:ops@example.com' not in body
|
||
|
||
|
||
def test_home_hides_email_admin_button_when_gws_configured(fresh_db, monkeypatch):
|
||
"""Even with admin_email set, when GWS OAuth is operator-provisioned
|
||
(gws_oauth.configured = True), the Email-admin CTA is redundant —
|
||
user can just connect. Template gates on `not gws_oauth.configured`."""
|
||
monkeypatch.setenv("AGNES_INSTANCE_ADMIN_EMAIL", "ops@example.com")
|
||
monkeypatch.setenv("AGNES_GWS_CLIENT_ID", "abc.apps.googleusercontent.com")
|
||
monkeypatch.setenv("AGNES_GWS_CLIENT_SECRET", "GOCSPX-secret")
|
||
from src.db import get_system_db, close_system_db
|
||
conn = get_system_db()
|
||
try:
|
||
_, sess = _make_user_and_session(conn)
|
||
finally:
|
||
conn.close()
|
||
close_system_db()
|
||
body = _client().get("/home", cookies={"access_token": sess}).text
|
||
assert "Email admin" not in body
|
||
|
||
|
||
# `test_home_renders_connector_prompts_from_shared_module` was dropped here
|
||
# alongside the removal of the /home `<details data-section="connectors">`
|
||
# block. The test pinned source-of-truth parity between the home tile
|
||
# `<code id="*-prompt">` blocks and `app/web/connector_prompts.py`. With the
|
||
# tiles gone, the only surface left for those strings is the install-hero's
|
||
# Step 4 clipboard payload (rendered via `_claude_setup_instructions.jinja`
|
||
# from `setup_instructions_lines`, which is built in
|
||
# `app/web/setup_instructions.py::_connectors_block` calling the same
|
||
# `connector_prompts.py` functions). One surface, no drift risk → the
|
||
# parity test is redundant. If a second surface ever re-renders these
|
||
# prompts, restore a parity test scoped to that new consumer.
|
||
|
||
|
||
# ── Setup section header + Overview + Usage modes ────────────────────────
|
||
|
||
|
||
def test_setup_section_renders_for_not_onboarded(fresh_db):
|
||
"""Not-onboarded users land on /home and see the setup section
|
||
header (eyebrow + heading + lede) floating above the install hero
|
||
card. The dismissible Getting Started shortcut block has been
|
||
removed — its two links lived only as in-page jumps and duplicated
|
||
the install-hero + /setup-advanced affordances already present on
|
||
the page. Onboarded users see neither header nor install hero so
|
||
the page reads as a hub, not a setup screen."""
|
||
from src.db import get_system_db, close_system_db
|
||
|
||
# Not-onboarded: setup header + install hero both render.
|
||
conn = get_system_db()
|
||
try:
|
||
_, sess = _make_user_and_session(
|
||
conn, email="setup-not-onboarded@example.com", onboarded=False
|
||
)
|
||
finally:
|
||
conn.close()
|
||
close_system_db()
|
||
body = _client().get("/home", cookies={"access_token": sess}).text
|
||
# Header floats above the card with the design spec eyebrow + h2.
|
||
assert '<div class="setup-section-header"' in body
|
||
assert ">First time here<" in body
|
||
assert "Set up" in body and "on your machine" in body
|
||
# Install hero card sits below the header.
|
||
assert '<div class="install-hero">' in body
|
||
# Getting Started shortcut block is gone.
|
||
assert "home-getting-started" not in body
|
||
assert "agnes_home_gs_dismissed" not in body
|
||
|
||
# Onboarded: install hero (and the setup header above it) are gone.
|
||
conn = get_system_db()
|
||
try:
|
||
_, sess2 = _make_user_and_session(
|
||
conn, email="setup-onboarded@example.com", onboarded=True
|
||
)
|
||
finally:
|
||
conn.close()
|
||
close_system_db()
|
||
body2 = _client().get("/home", cookies={"access_token": sess2}).text
|
||
assert '<div class="install-hero"' not in body2
|
||
assert '<div class="setup-section-header"' not in body2
|
||
|
||
|
||
def test_overview_section_replaced_by_welcome_footnotes(fresh_db, monkeypatch):
|
||
"""The standalone operator-owned Overview `<section>` was removed
|
||
from `/home`. Its privacy / workspace-layout copy now ships as
|
||
static product framing inside the welcome hero footnotes
|
||
(`.home-hero-footnotes`). The `instance.overview` yaml field
|
||
(`AGNES_INSTANCE_OVERVIEW`) keeps its gating role — any
|
||
non-empty value acts as the feature flag — but its raw HTML
|
||
body is no longer rendered (the static copy replaces it). When
|
||
set: the standalone section MUST stay absent and the
|
||
footnotes block MUST appear, but the raw yaml HTML body MUST
|
||
NOT leak into the page."""
|
||
monkeypatch.setenv("AGNES_INSTANCE_OVERVIEW", "<p>OVERVIEW_TEST_MARKER</p>")
|
||
from src.db import get_system_db, close_system_db
|
||
|
||
conn = get_system_db()
|
||
try:
|
||
_, sess = _make_user_and_session(conn)
|
||
finally:
|
||
conn.close()
|
||
close_system_db()
|
||
body = _client().get("/home", cookies={"access_token": sess}).text
|
||
assert '<section class="home-overview">' not in body
|
||
assert "OVERVIEW_TEST_MARKER" not in body
|
||
assert '<div class="home-hero-footnotes">' in body
|
||
# "Get the most out of it" is unique to the footnotes block — the
|
||
# original "What leaves your machine" copy still exists in the
|
||
# session-privacy annotation lower on the page (untouched), so we
|
||
# can't use it as a footnotes marker.
|
||
assert "Get the most out of it" in body
|
||
|
||
|
||
def test_welcome_footnotes_hidden_when_overview_unset(fresh_db, monkeypatch):
|
||
"""Default empty `instance.overview` (no env override) hides the
|
||
welcome-hero footnotes entirely so the OSS ships without the
|
||
central-catalog privacy framing baked into the welcome card."""
|
||
monkeypatch.delenv("AGNES_INSTANCE_OVERVIEW", raising=False)
|
||
from src.db import get_system_db, close_system_db
|
||
|
||
conn = get_system_db()
|
||
try:
|
||
_, sess = _make_user_and_session(conn)
|
||
finally:
|
||
conn.close()
|
||
close_system_db()
|
||
body = _client().get("/home", cookies={"access_token": sess}).text
|
||
assert '<div class="home-hero-footnotes">' not in body
|
||
# "Get the most out of it" is the unique footnotes marker — the
|
||
# original "What leaves your machine" copy in the session-privacy
|
||
# annotation lower on the page always renders, so checking that
|
||
# would be a false-positive.
|
||
assert "Get the most out of it" not in body
|