feat(onboarding): BB welcome modal + 25-step guided tour

Closes the recurring "I added the printer but it isn't connecting" +
  "where is X feature" cluster that drove ~1/3 of invalid-tagged issues.
  New users now see a friendly welcome modal on first load and can take
  a guided walkthrough that highlights the load-bearing UI surface
  step-by-step.

  Backend: two new nullable columns on users (onboarding_status
  VARCHAR(64), onboarding_snoozed_until TIMESTAMP) added via inline
  migration with a one-shot backfill that marks every existing user as
  dismissed_at_migration -- pre-existing installs never see the welcome
  modal. GET + PATCH /api/v1/users/me/onboarding round-trip the state;
  OnboardingUpdate schema rejects dismissed_at_migration from the API
  (migration-internal only), validates the tour_in_progress:<step_id>
  form, and enforces snooze coherence (snoozed_until required only when
  status is snoozed). _users_column_exists helper mirrors
  _api_keys_column_exists and gates the backfill so restarts after
  new-user signups do not clobber the welcome-eligible NULL state.
  SQLite and Postgres both verified end-to-end.

  Frontend architecture: OnboardingProvider wraps the app inside
  AuthProvider; reads from the backend when auth is on, falls back to
  localStorage (bambuddy.onboarding_status + onboarding_snoozed_until)
  when auth is off so no-auth installs still get the welcome
  experience. A loadFailed gate prevents the welcome modal from popping
  over a backend outage -- we cannot distinguish "new user" from "GET
  errored" so we stay silent. OnboardingFlow driver picks between the
  Phase 0 welcome modal, the Phase 0.2 about modal, and the
  step-by-step TourEngine based on persisted status. Mounted inside
  BrowserRouter so the route guard's useLocation has its context (a
  sibling-of-Router placement crashed at runtime).

  Route guard: the overlay never renders on /setup, /login,
  /spoolbuddy/*, /camera/*, /overlay/*, or while requiresSetup is
  true -- fresh installs walk through the existing /setup flow
  uninterrupted, and the SpoolBuddy kiosk / OBS overlay / camera-popout
  windows never get a modal slapped over them.

  Tour engine: 25 steps targeting existing data-tour anchors --
  add-printer -> verify-connection -> printer-card sub-tour x5 (status
  row, AMS row, camera, controls, customize menu) -> add-spool ->
  bambu-cloud-sync -> sidebar overview x6 -> vp -> slicer-api ->
  makerworld -> obico -> integrations -> notifications -> users ->
  groups -> sso -> outro. Per-step route navigation via useNavigate,
  anchor polling at 100ms intervals with a 3-second cap (pages need a
  beat after navigation; never spin forever), box-shadow dimmed-
  spotlight cutout that pointer-events-through so the user can still
  interact, smart modal positioning (sidebar anchors to the right;
  page anchors below or flipped above based on viewport room),
  Back/Next/Skip with Escape as Skip, persistence on every Back/Next
  so mid-tour reloads resume at the same step.

  Conditional skip: each step exposes a skipIf(ctx) evaluated against
  at least one printer exists; verify-connection + the entire card
  sub-tour skip when no printer exists; makerworld skips when the user
  lacks makerworld:view (the sidebar entry is permission-gated and the
  anchor would not resolve); users / groups / sso skip when auth is
  off. Pre-render gate means the user never sees a flash of a step
  that is about to skip.

  Phase 1.1 "Lock the front door" auth step explicitly removed from
  the live tour -- /setup already prompts for the auth choice on fresh
  installs, and users who deliberately chose no-auth must not be
  nudged to enable it. The auth-card anchor stays for any future
  privacy-checkup surface.

  BB mascot: hero pose + 5 named poses (started / walk / almost /
  allset / help) sliced from the character sheet via Pillow into
  public/img/bb_*.webp. MascotIcon component takes a pose prop;
  per-step pose mapping in tourSteps.ts. TourLauncher sits in the
  sidebar footer as a BB icon that relaunches the tour from step 0
  and consumes the [data-tour="help-icon"] selector.

  WikiHelpIcon component lands the per-page question-mark icon on
  Queue / Archives / Stats / Maintenance / Files / Projects /
  Inventory -- opens the matching wiki.bambuddy.cool/{path}/ page in
  a new tab (chose new-tab over iframe because the wiki sets
  X-Frame-Options: DENY).

  27 new data-tour anchors across PrintersPage / SettingsPage /
  ProfilesPage / InventoryPage / Layout. i18n: 138 new keys under a
  new onboarding.* namespace, real translations in every locale
  (de/en/es/fr/it/ja/ko/pt-BR/tr/zh-CN/zh-TW) -- no
  IDENTICAL_TO_EN_ALLOWED entries added.

  Backend tests: 12 in TestOnboardingAPI cover the round-trips and the
  validator branches. Frontend tests: 61 across 7 files -- anchor
  presence backstop, Phase 0 modal interaction, tour engine + step
  helpers, provider state machine (auth-on/off split, PATCH error
  fall-through, localStorage round-trip), route guard for every
  suppress path, WikiHelpIcon href/target/aria. Backend pytest
  5723/5723; frontend vitest 2154/2154; ESLint clean; frontend build
  clean; i18n parity clean at 5233 leaves x 11 locales.

  Companion docs: docs/onboarding-tour-plan.md carries the design
  (Phase 0-5 step inventory, anchor selector list, state model, asset
  inventory, resolved decisions, Implementation Status section that
  is authoritative for current shipped state).
This commit is contained in:
maziggy
2026-06-10 10:06:42 +02:00
parent e737c84c6e
commit df66924066
62 changed files with 4506 additions and 8870 deletions
+179
View File
@@ -991,3 +991,182 @@ class TestInputLengthValidation:
)
# Schema accepts it; auth may reject with 401 (auth disabled) or 400
assert response.status_code != 422
class TestOnboardingAPI:
"""Integration tests for /api/v1/users/me/onboarding endpoints.
See docs/onboarding-tour-plan.md Appendix B for the state model.
"""
@pytest.fixture
async def user_token(self, async_client: AsyncClient):
"""Enable auth, create a regular user, return their bearer token."""
await async_client.post(
"/api/v1/auth/setup",
json={
"auth_enabled": True,
"admin_username": "onboardingadmin",
"admin_password": "AdminPass1!",
},
)
admin_login = await async_client.post(
"/api/v1/auth/login",
json={"username": "onboardingadmin", "password": "AdminPass1!"},
)
admin_token = admin_login.json()["access_token"]
await async_client.post(
"/api/v1/users/",
headers={"Authorization": f"Bearer {admin_token}"},
json={"username": "onboardinguser", "password": "Userpass123!"},
)
user_login = await async_client.post(
"/api/v1/auth/login",
json={"username": "onboardinguser", "password": "Userpass123!"},
)
return user_login.json()["access_token"]
@pytest.mark.asyncio
@pytest.mark.integration
async def test_get_returns_null_for_new_user(self, async_client: AsyncClient, user_token: str):
"""A newly-created user has no onboarding status set yet (welcome modal eligible)."""
response = await async_client.get(
"/api/v1/users/me/onboarding",
headers={"Authorization": f"Bearer {user_token}"},
)
assert response.status_code == 200
body = response.json()
assert body["status"] is None
assert body["snoozed_until"] is None
@pytest.mark.asyncio
@pytest.mark.integration
async def test_patch_sets_dismissed(self, async_client: AsyncClient, user_token: str):
"""PATCH with status=dismissed persists and is returned by subsequent GET."""
response = await async_client.patch(
"/api/v1/users/me/onboarding",
headers={"Authorization": f"Bearer {user_token}"},
json={"status": "dismissed"},
)
assert response.status_code == 200
assert response.json()["status"] == "dismissed"
followup = await async_client.get(
"/api/v1/users/me/onboarding",
headers={"Authorization": f"Bearer {user_token}"},
)
assert followup.json()["status"] == "dismissed"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_patch_sets_snoozed_with_timestamp(self, async_client: AsyncClient, user_token: str):
"""PATCH with status=snoozed + snoozed_until persists both fields."""
response = await async_client.patch(
"/api/v1/users/me/onboarding",
headers={"Authorization": f"Bearer {user_token}"},
json={"status": "snoozed", "snoozed_until": "2026-06-15T12:00:00+00:00"},
)
assert response.status_code == 200
body = response.json()
assert body["status"] == "snoozed"
assert body["snoozed_until"] is not None
assert body["snoozed_until"].startswith("2026-06-15T12:00:00")
@pytest.mark.asyncio
@pytest.mark.integration
async def test_patch_snoozed_without_timestamp_rejected(self, async_client: AsyncClient, user_token: str):
"""status=snoozed without snoozed_until is a 422 — UI must supply both."""
response = await async_client.patch(
"/api/v1/users/me/onboarding",
headers={"Authorization": f"Bearer {user_token}"},
json={"status": "snoozed"},
)
assert response.status_code == 422
@pytest.mark.asyncio
@pytest.mark.integration
async def test_patch_non_snoozed_with_timestamp_rejected(self, async_client: AsyncClient, user_token: str):
"""snoozed_until is meaningful only for snoozed status — reject otherwise."""
response = await async_client.patch(
"/api/v1/users/me/onboarding",
headers={"Authorization": f"Bearer {user_token}"},
json={"status": "dismissed", "snoozed_until": "2026-06-15T12:00:00+00:00"},
)
assert response.status_code == 422
@pytest.mark.asyncio
@pytest.mark.integration
async def test_patch_tour_in_progress_with_step_id(self, async_client: AsyncClient, user_token: str):
"""tour_in_progress:<step_id> is accepted so the tour can resume on next session."""
response = await async_client.patch(
"/api/v1/users/me/onboarding",
headers={"Authorization": f"Bearer {user_token}"},
json={"status": "tour_in_progress:1.2"},
)
assert response.status_code == 200
assert response.json()["status"] == "tour_in_progress:1.2"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_patch_completed_tour(self, async_client: AsyncClient, user_token: str):
"""status=completed_tour is the happy-path terminal state."""
response = await async_client.patch(
"/api/v1/users/me/onboarding",
headers={"Authorization": f"Bearer {user_token}"},
json={"status": "completed_tour"},
)
assert response.status_code == 200
assert response.json()["status"] == "completed_tour"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_patch_invalid_status_rejected(self, async_client: AsyncClient, user_token: str):
"""Arbitrary status strings outside the allowed set are 422."""
response = await async_client.patch(
"/api/v1/users/me/onboarding",
headers={"Authorization": f"Bearer {user_token}"},
json={"status": "nonsense"},
)
assert response.status_code == 422
@pytest.mark.asyncio
@pytest.mark.integration
async def test_patch_dismissed_at_migration_rejected(self, async_client: AsyncClient, user_token: str):
"""dismissed_at_migration is set only by the column-add migration, never by clients."""
response = await async_client.patch(
"/api/v1/users/me/onboarding",
headers={"Authorization": f"Bearer {user_token}"},
json={"status": "dismissed_at_migration"},
)
assert response.status_code == 422
@pytest.mark.asyncio
@pytest.mark.integration
async def test_patch_tour_in_progress_malformed_step_id_rejected(self, async_client: AsyncClient, user_token: str):
"""Step IDs with characters outside the allowlist are 422."""
response = await async_client.patch(
"/api/v1/users/me/onboarding",
headers={"Authorization": f"Bearer {user_token}"},
json={"status": "tour_in_progress:step with spaces"},
)
assert response.status_code == 422
@pytest.mark.asyncio
@pytest.mark.integration
async def test_get_requires_auth(self, async_client: AsyncClient):
"""No bearer token → 401."""
response = await async_client.get("/api/v1/users/me/onboarding")
assert response.status_code == 401
@pytest.mark.asyncio
@pytest.mark.integration
async def test_patch_requires_auth(self, async_client: AsyncClient):
"""No bearer token → 401 (route is authenticated even though it has no permission gate)."""
response = await async_client.patch(
"/api/v1/users/me/onboarding",
json={"status": "dismissed"},
)
assert response.status_code == 401