bada72f87e
Track each authenticated user's last-viewed route + light view state server-side, and on next sign-in redirect them to that state, falling back to the empty-state home only when there's no recorded state. Backend: - migration 022_user_session_state.sql: one row per user (user_id PK/FK, last_route, last_route_state JSON-as-TEXT, resume_enabled default 1, last_updated_at). - PUT /api/me/last-state (require_user): upserts route + light state; no-ops when resume_enabled=0. Localized in the /me region. - /api/auth/me payload now carries resume_enabled + last_route + decoded last_route_state (no extra round-trip). - test_session_resume_vertical.py: auth-required, upsert/read-back, per-user isolation, resume_enabled=0 disable. Frontend: - lib/useLastState.js: debounced (~1s) route-change PUT for authenticated users; one-time resume redirect on sign-in, gated on identify having fired (preserves #21 Part C identify-then-track). - api.js: putLastState() client call. - App.jsx: import + call the hook; set identifyReady after identify. Header region untouched. Per-user (not per-device); profile-settings opt-out toggle UI deferred (column + default-on behavior ship now). Stored state is route + light view state ONLY, never draft buffers — documented in SPEC §6.8. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
141 lines
6.0 KiB
Python
141 lines
6.0 KiB
Python
"""End-to-end integration tests for the v0.23.0 sign-in state-resume
|
|
vertical (§6.2, roadmap item #29).
|
|
|
|
New behavior: each authenticated user's last-viewed route + a small bag
|
|
of light view state is tracked server-side, so the next sign-in can land
|
|
them back where they left off rather than on the empty-state home view.
|
|
|
|
The tests below prove:
|
|
|
|
* `PUT /api/me/last-state` requires auth — an anonymous client gets
|
|
401, and nothing is stored.
|
|
* An authenticated PUT upserts the route, and the stored route is
|
|
read back for that user off `GET /api/auth/me` (`last_route`).
|
|
* A second PUT overwrites (upsert, one row per user) — the latest
|
|
route wins.
|
|
* `last_route_state` round-trips as decoded JSON on `/api/auth/me`.
|
|
* Per-user isolation: user A's stored route is not visible to user B.
|
|
* `resume_enabled = 0` disables resume: the PUT no-ops (does not
|
|
rewrite the stored route) and `/api/auth/me` hands back a null
|
|
`last_route` even though a stored row exists.
|
|
|
|
The fakes from `test_propose_vertical` give us a working app harness +
|
|
the `sign_in_as` / `provision_user_row` seams.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from test_propose_vertical import ( # noqa: F401
|
|
FakeGitea,
|
|
app_with_fake_gitea,
|
|
provision_user_row,
|
|
sign_in_as,
|
|
tmp_env,
|
|
)
|
|
|
|
|
|
def test_put_last_state_requires_auth(app_with_fake_gitea):
|
|
from fastapi.testclient import TestClient
|
|
from app import db
|
|
|
|
app, _fake = app_with_fake_gitea
|
|
with TestClient(app) as client:
|
|
# Anonymous — no session cookie set.
|
|
r = client.put("/api/me/last-state", json={"route": "/rfc/open-human-model"})
|
|
assert r.status_code == 401
|
|
# Nothing landed in the table.
|
|
row = db.conn().execute("SELECT COUNT(*) AS n FROM user_session_state").fetchone()
|
|
assert row["n"] == 0
|
|
|
|
|
|
def test_put_last_state_upserts_and_reads_back(app_with_fake_gitea):
|
|
from fastapi.testclient import TestClient
|
|
|
|
app, _fake = app_with_fake_gitea
|
|
with TestClient(app) as client:
|
|
provision_user_row(user_id=2, login="alice", role="contributor")
|
|
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor", email="alice@test")
|
|
|
|
# First POST stores a route + light state.
|
|
r = client.put(
|
|
"/api/me/last-state",
|
|
json={"route": "/rfc/open-human-model", "state": {"tab": "discussion", "scroll": 420}},
|
|
)
|
|
assert r.status_code == 200
|
|
assert r.json()["stored"] is True
|
|
|
|
# /api/auth/me hands the route + decoded state back.
|
|
me = client.get("/api/auth/me").json()
|
|
assert me["authenticated"] is True
|
|
assert me["user"]["resume_enabled"] is True
|
|
assert me["user"]["last_route"] == "/rfc/open-human-model"
|
|
assert me["user"]["last_route_state"] == {"tab": "discussion", "scroll": 420}
|
|
|
|
# A later POST overwrites — one row per user, latest wins.
|
|
r = client.put("/api/me/last-state", json={"route": "/proposals/7"})
|
|
assert r.status_code == 200
|
|
me = client.get("/api/auth/me").json()
|
|
assert me["user"]["last_route"] == "/proposals/7"
|
|
# state was omitted on the second POST → cleared to null.
|
|
assert me["user"]["last_route_state"] is None
|
|
|
|
|
|
def test_last_state_is_per_user(app_with_fake_gitea):
|
|
from fastapi.testclient import TestClient
|
|
|
|
app, _fake = app_with_fake_gitea
|
|
with TestClient(app) as client:
|
|
provision_user_row(user_id=2, login="alice", role="contributor")
|
|
provision_user_row(user_id=3, login="bob", role="contributor")
|
|
|
|
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor", email="alice@test")
|
|
client.put("/api/me/last-state", json={"route": "/rfc/alice-route"})
|
|
|
|
# Switch to bob — he has no stored route yet.
|
|
sign_in_as(client, user_id=3, gitea_login="bob", display_name="Bob", role="contributor", email="bob@test")
|
|
me = client.get("/api/auth/me").json()
|
|
assert me["user"]["last_route"] is None
|
|
|
|
client.put("/api/me/last-state", json={"route": "/rfc/bob-route"})
|
|
me = client.get("/api/auth/me").json()
|
|
assert me["user"]["last_route"] == "/rfc/bob-route"
|
|
|
|
# Back to alice — her route is untouched by bob's write.
|
|
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor", email="alice@test")
|
|
me = client.get("/api/auth/me").json()
|
|
assert me["user"]["last_route"] == "/rfc/alice-route"
|
|
|
|
|
|
def test_resume_disabled_no_ops_put_and_hides_route(app_with_fake_gitea):
|
|
from fastapi.testclient import TestClient
|
|
from app import db
|
|
|
|
app, _fake = app_with_fake_gitea
|
|
with TestClient(app) as client:
|
|
provision_user_row(user_id=2, login="alice", role="contributor")
|
|
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor", email="alice@test")
|
|
|
|
# Seed a stored row, then flip resume_enabled off directly (the
|
|
# profile-settings toggle UI to do this from the client is a
|
|
# follow-up; the column + behavior ship now).
|
|
client.put("/api/me/last-state", json={"route": "/rfc/before-disable"})
|
|
db.conn().execute(
|
|
"UPDATE user_session_state SET resume_enabled = 0 WHERE user_id = ?",
|
|
(2,),
|
|
)
|
|
|
|
# /api/auth/me reports resume off and hands back a null route
|
|
# even though a stored row exists.
|
|
me = client.get("/api/auth/me").json()
|
|
assert me["user"]["resume_enabled"] is False
|
|
assert me["user"]["last_route"] is None
|
|
|
|
# A PUT while disabled no-ops: stored=False and the stored route
|
|
# is NOT rewritten.
|
|
r = client.put("/api/me/last-state", json={"route": "/rfc/after-disable"})
|
|
assert r.status_code == 200
|
|
assert r.json()["stored"] is False
|
|
row = db.conn().execute(
|
|
"SELECT last_route FROM user_session_state WHERE user_id = ?", (2,)
|
|
).fetchone()
|
|
assert row["last_route"] == "/rfc/before-disable"
|