Compare commits

...

33 Commits

Author SHA1 Message Date
Bastian Wagner
8a23147dc8 Add cache-busting version query to static assets
Cloudflare was caching /static/style.css and /static/app.js at the
edge for up to 4 hours (its default Browser Cache TTL, since the app
sets no explicit Cache-Control), so deploys could look like they
hadn't landed even though the origin was already up to date. A
content hash of style.css/app.js/htmx.min.js, computed once at
startup, is now appended as ?v=<hash> to their URLs in base.html, so
every deploy that changes those files produces new, never-cached
URLs and needs no manual cache purge.

Also commits the live-sync-updates design spec, which was written
but never staged earlier in the session.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-16 13:51:28 +02:00
Bastian Wagner
450e4e935f Mark live sync updates plan tasks complete
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-16 13:32:13 +02:00
Bastian Wagner
13864a77f7 Auto-dismiss sync toasts after a few seconds
Fixes two issues found during manual verification:
- fragments/toast.html was missing class="toast-container" on the
  swapped-in element, so the container lost its fixed-position
  styling after the first swap.
- htmx:oobAfterSwap's event.detail.target is the *old* element that
  just got replaced (outerHTML oob-swaps detach it), so the dismiss
  timer must look up the live #toast-container by id instead of
  trusting that stale reference.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-16 13:32:03 +02:00
Bastian Wagner
eb97374578 Live-update the account page status block after sync
Mirrors the dashboard's row update: "Sync now" on the account page
refreshes the status block in place and shows a toast, instead of
navigating to a separate result page.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-16 13:18:23 +02:00
Bastian Wagner
8d73dea7dd Live-update dashboard rows and show toasts after sync actions
"Sync now" and "Sync all now" now return the freshly reloaded rider
row(s) plus an out-of-band toast instead of navigating to a separate
result page. The "sync already running" case is a 200 + info toast
now instead of a 409 special case.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-16 13:16:10 +02:00
Bastian Wagner
cc69b3ebb6 Extract dashboard row and sync-all form into reusable partials
These render the initial dashboard page today and will also be
rendered standalone by the sync routes in the next commits, so the
same markup drives both the full page and the post-sync response.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-16 13:14:08 +02:00
Bastian Wagner
59b39f10cb Vendor htmx and add toast/loading-state CSS
Self-hosted htmx v2.0.10 (no CDN) plus the toast container and CSS
this and the following tasks need for in-place sync updates.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-16 13:12:49 +02:00
Bastian Wagner
f70f511907 Add UserRepository.dashboard_row for single-row refresh
Extracts the dashboard_rows() loop body into a shared
_build_dashboard_row helper so a single rider's row can be re-queried
after a sync action, without changing dashboard_rows()'s behavior.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-16 13:11:29 +02:00
Bastian Wagner
a0890126bc Add dashboard summary tiles
Shows rider count, activities imported in the last 7 days, 7-day
sync success rate, and how many riders currently need attention,
right above the rider list where an admin looks first.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-16 12:48:44 +02:00
Bastian Wagner
9df6a619d2 Mark visual redesign plan tasks complete
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-16 11:03:22 +02:00
Bastian Wagner
a9a46e949f Turn the next-sync indicator into a live ticking countdown
Replaces the one-time UTC-to-local formatting with a per-second
countdown (HH:MM:SS, or MM:SS under an hour) that fits the dark
cockpit theme's instrument-panel feel, falling back to "due now" once
the target passes instead of showing a negative duration.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-16 11:03:07 +02:00
Bastian Wagner
aa9e289185 Fix unstyled number inputs on the system scheduling form
input[type="number"] was missing from the form field selector added
in the redesign, so the scheduler settings fields stayed white-on-
white. Caught via manual browser verification.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-16 11:03:07 +02:00
Bastian Wagner
b6d4be97c1 Redesign UI with dark cockpit color and typography tokens
Replaces the light generic theme with a dark palette grounded in the
app's own subject matter (bike computer telemetry): electric-lime
accent, tabular monospace numerals for stats/timestamps, hairline
card borders instead of shadows, and visible focus rings. Every
template already shares the same classes, so no markup changes were
needed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-16 10:59:58 +02:00
Bastian Wagner
a74ab95f3f Show next scheduled sync time on every page
Exposes the scheduler's next_tick in the topbar via a safe Jinja
helper (falls back to nothing if the scheduler isn't running yet),
and converts the server-rendered UTC timestamp to the visitor's local
time client-side so it reads correctly regardless of timezone.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-16 10:58:53 +02:00
Bastian Wagner
5d75aa328d Add design spec for dark cockpit-style visual redesign
Documents the color/type/layout token system and the next-sync
countdown evolution agreed on with the user, ahead of implementation
planning.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-16 10:48:43 +02:00
Bastian Wagner
722570c9d3 icon 2026-08-15 21:51:53 +02:00
Bastian Wagner
ff5dab6f0d intervall 2026-08-15 21:44:13 +02:00
Bastian Wagner
f7b04337ce auth für user 2026-08-15 21:34:40 +02:00
Bastian Wagner
adfe14dfa8 logging 2026-08-15 21:05:55 +02:00
Bastian Wagner
420d089760 log 2026-08-15 20:54:35 +02:00
Bastian Wagner
2aba1265af mail notification 2026-08-15 20:47:02 +02:00
Bastian Wagner
7c9e19ba0b errors 2026-08-15 20:31:57 +02:00
Bastian Wagner
85b0d861b4 feat: restyle admin UI and add per-user sync run history
Adds a self-hosted stylesheet (no CDN dependencies) with a card-based
dashboard and color-coded status badges, and shows the last 10 sync
runs per user on the detail page.
2026-08-15 20:19:31 +02:00
Bastian Wagner
990a55af14 fix: address final review findings for sync-scheduler-web plan
Close the leaked httpx.AsyncClient in MyWhoosh sync runs, ensure hard
failures finish sync_runs as FAILED instead of leaving them stuck at
RUNNING, log (non-benign) exceptions surfaced by sync_all_enabled during
scheduled ticks, classify GarminImportRejected as a non-retryable
per-activity failure, fix a bug where a live Garmin-class action_required
state could be silently cleared by a run that did no Garmin work, use the
activity's DB primary key instead of the unsanitized remote id for
filesystem paths, add regression/coverage tests for the health-state fix
and MFA code threading through the real SyncManager, add idempotency
coverage to the two-user acceptance test, and note the Dockerfile's
single-process assumption.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-15 17:09:55 +02:00
Bastian Wagner
aed9d6bb48 test: cover multi-user sync acceptance 2026-08-15 16:43:45 +02:00
Bastian Wagner
6657124983 feat: handle Garmin MFA and activity retries 2026-08-15 16:33:16 +02:00
Bastian Wagner
c2f13611b9 feat: add operational sync controls 2026-08-15 16:24:11 +02:00
Bastian Wagner
fd50bbbab7 feat: schedule periodic user synchronization 2026-08-15 16:14:25 +02:00
Bastian Wagner
1d414d6298 feat: isolate concurrent user syncs
Wrap SyncManager.sync_user in a per-user asyncio.Lock (raising
SyncAlreadyRunning on overlap) and add sync_all_enabled() to fan out
across all enabled users with per-user failure isolation via
asyncio.gather(return_exceptions=True). The prior sync_user body is
renamed to _sync_user_locked with no logic changes.
2026-08-15 16:08:00 +02:00
Bastian Wagner
4aaf490bc5 style: use SQLAlchemy 2.0 select() in sync test helpers
Match the select()/scalars() convention already used throughout
app/db/repositories.py instead of the legacy Query API.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-15 15:54:52 +02:00
Bastian Wagner
c4e986e3f8 feat: add resumable per-user sync pipeline 2026-08-15 15:53:27 +02:00
Bastian Wagner
1d5bbdb2a2 feat: add durable sync state transitions
Add state-transition methods to ActivityRepository for advancing activity
stages (mark_downloaded, mark_converted, mark_imported, mark_duplicate,
mark_failed) with proper retention of last_completed_stage on failure.
Add list_pending_for_user to filter activities for processing. Implement
SyncRunRepository for creating and finalizing sync runs with counts and
summary errors.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-15 15:33:46 +02:00
Bastian Wagner
2f65c0178c fix: address final review findings for service clients plan
Fixes 9 numbered findings + 7 minor fixes from the whole-plan review of
the MyWhoosh/Garmin service clients (Plan 3):

Garmin uploader (app/garmin/uploader.py):
- Detect Garmin-rejected imports (failures without successes) and raise
  new GarminImportRejected instead of reporting them as successful.
- Reclassify 429/rate-limit/500 login failures as transient instead of
  falling through to permanent auth errors; unrecognized login failures
  are now treated as transient (retryable) rather than GarminAuthError.
- Mirror the auth-token check from the login branch into the import
  branch so 401-at-import-time raises GarminAuthError instead of
  propagating raw.
- Add common GarminError base class, hoist transient-token tuple to a
  shared module constant, check response.status_code==409 before the
  duplicate substring fallback, and create the tokenstore dir 0o700.

MyWhoosh client (app/mywhoosh/client.py):
- Add optional max_pages bound to list_activities pagination.
- Add aclose()/__aenter__/__aexit__ so the client's own httpx.AsyncClient
  gets closed, while never closing an injected client.
- Guard the two remaining unguarded JSON-decode paths (login body,
  download-fit metadata) so malformed bodies raise
  MyWhooshIntegrationError instead of raw ValueError/AttributeError.
- Row-level malformation (missing id/activityFileId, unparseable
  startDatetime) is now skipped rather than aborting the whole page;
  envelope-shape failures still raise. id/activityFileId checks use
  explicit None/"" comparisons instead of Python falsiness.
- Replace asserts in _authenticated_post with explicit exceptions;
  restrict the reauth retry to 401 only, treat 403 as immediately
  terminal; naive startDatetime values are now treated as already-UTC
  instead of host-local.

MyWhoosh tokenstore (app/mywhoosh/tokenstore.py):
- load() now treats any corrupt/malformed token file (bad JSON, missing
  keys, OS errors) as "absent" instead of raising, so a bad cache no
  longer permanently wedges a user.

pyproject.toml:
- Tighten garminconnect pin to >=0.3.10,<1 (import_activity requires
  0.3.10+).

Adds/updates tests across tests/mywhoosh/ and tests/garmin/ covering
all of the above, including a fake client that wraps GarminUploadBlocked
in a plain RuntimeError to mirror the real garminconnect library's MFA
error wrapping.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-15 15:23:14 +02:00
77 changed files with 7557 additions and 113 deletions

View File

@@ -7,3 +7,12 @@ DATA_DIR=/data
DATABASE_URL=sqlite:////data/app.db
# Set to true only when TLS terminates in front of this service.
SESSION_HTTPS_ONLY=false
# Optional: SMTP settings for "email me when action is required" user
# notifications. Leave SMTP_HOST unset to disable sending (notifications are
# silently skipped rather than failing a sync run).
SMTP_HOST=
SMTP_PORT=587
SMTP_USERNAME=
SMTP_PASSWORD=
SMTP_FROM_ADDRESS=
SMTP_USE_TLS=true

View File

@@ -7,4 +7,8 @@ COPY app /app/app
RUN mkdir -p /data && chmod 700 /data
ENV DATA_DIR=/data
EXPOSE 8080
# Single-process assumption: per-user sync locking and the in-process
# scheduler both live in this one worker's memory. Do not scale this to
# multiple uvicorn workers or container replicas without adding a
# cross-process lock -- otherwise duplicate imports become possible.
CMD ["uvicorn", "app.main:create_app", "--factory", "--host", "0.0.0.0", "--port", "8080"]

36
app/auth/account.py Normal file
View File

@@ -0,0 +1,36 @@
from sqlalchemy.orm import Session
from app.auth.admin import password_matches
from app.db.models import SyncUser
from app.db.repositories import UserRepository
from app.security.credentials import CredentialCipher
def authenticate_self_service(
session: Session, cipher: CredentialCipher, email: str, password: str
) -> SyncUser | None:
"""Matches submitted email/password against any user's stored MyWhoosh OR
Garmin credentials -- decrypted and compared locally rather than
verified against the live MyWhoosh/Garmin APIs, so logging into this app
never opens a redundant upstream session (which, for MyWhoosh, would
itself trigger the "already logged in from another device" conflict)."""
submitted_email = email.strip()
if not submitted_email or not password:
return None
for user in UserRepository(session).list_all():
if _credential_matches(cipher, user.mywhoosh_email_enc, user.mywhoosh_password_enc, submitted_email, password):
return user
if _credential_matches(cipher, user.garmin_email_enc, user.garmin_password_enc, submitted_email, password):
return user
return None
def _credential_matches(
cipher: CredentialCipher, email_enc: str, password_enc: str, submitted_email: str, submitted_password: str
) -> bool:
try:
stored_email = cipher.decrypt(email_enc)
stored_password = cipher.decrypt(password_enc)
except ValueError:
return False
return stored_email == submitted_email and password_matches(submitted_password, stored_password)

View File

@@ -10,3 +10,10 @@ def password_matches(submitted: str, configured: str) -> bool:
def require_admin(request: Request) -> None:
if request.session.get("admin_authenticated") is not True:
raise HTTPException(status_code=status.HTTP_303_SEE_OTHER, headers={"Location": "/login"})
def require_self_service(request: Request) -> int:
user_id = request.session.get("self_service_user_id")
if not isinstance(user_id, int):
raise HTTPException(status_code=status.HTTP_303_SEE_OTHER, headers={"Location": "/account-login"})
return user_id

View File

@@ -15,6 +15,12 @@ class Settings(BaseSettings):
database_url: str | None = Field(default=None, alias="DATABASE_URL")
sync_interval_minutes: PositiveInt = Field(default=5, alias="SYNC_INTERVAL_MINUTES")
session_https_only: bool = Field(default=False, alias="SESSION_HTTPS_ONLY")
smtp_host: str | None = Field(default=None, alias="SMTP_HOST")
smtp_port: int = Field(default=587, alias="SMTP_PORT")
smtp_username: str | None = Field(default=None, alias="SMTP_USERNAME")
smtp_password: str | None = Field(default=None, alias="SMTP_PASSWORD")
smtp_from_address: str | None = Field(default=None, alias="SMTP_FROM_ADDRESS")
smtp_use_tls: bool = Field(default=True, alias="SMTP_USE_TLS")
@model_validator(mode="after")
def derive_paths(self) -> "Settings":

View File

@@ -53,6 +53,8 @@ class SyncUser(Base):
mywhoosh_password_enc: Mapped[str] = mapped_column(Text, nullable=False)
garmin_email_enc: Mapped[str] = mapped_column(Text, nullable=False)
garmin_password_enc: Mapped[str] = mapped_column(Text, nullable=False)
notify_email_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
notification_email: Mapped[str | None] = mapped_column(String(255))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow)
@@ -77,6 +79,30 @@ class Activity(Base):
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow)
class SystemLogEntry(Base):
__tablename__ = "system_log_entries"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, index=True)
source: Mapped[str] = mapped_column(String(64), nullable=False)
message: Mapped[str] = mapped_column(Text, nullable=False)
user_id: Mapped[int | None] = mapped_column(ForeignKey("sync_users.id", ondelete="SET NULL"))
class SchedulerSettings(Base):
"""Singleton row (id is always 1) holding the admin-configurable day/night
sync interval -- editable at runtime via the UI, unlike SYNC_INTERVAL_MINUTES
which only seeds this row's initial values on first startup."""
__tablename__ = "scheduler_settings"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
day_start_hour: Mapped[int] = mapped_column(Integer, nullable=False, default=6)
night_start_hour: Mapped[int] = mapped_column(Integer, nullable=False, default=22)
day_interval_minutes: Mapped[int] = mapped_column(Integer, nullable=False, default=5)
night_interval_minutes: Mapped[int] = mapped_column(Integer, nullable=False, default=5)
class SyncRun(Base):
__tablename__ = "sync_runs"

View File

@@ -1,10 +1,44 @@
from dataclasses import dataclass
from datetime import datetime
from sqlalchemy import select
from sqlalchemy import and_, func, or_, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.db.models import Activity, ActivityStatus, SyncUser
from app.db.models import (
Activity,
ActivityStatus,
HealthState,
SchedulerSettings,
SyncRun,
SyncRunStatus,
SystemLogEntry,
SyncUser,
utcnow,
)
_SCHEDULER_SETTINGS_ID = 1
@dataclass(frozen=True)
class DashboardSummary:
rider_total: int
rider_enabled: int
imported_recent: int
success_rate_recent: float | None
action_required_count: int
@dataclass(frozen=True)
class UserDashboardRow:
id: int
name: str
enabled: bool
health_state: str
action_reason: str | None
last_sync_at: datetime | None
last_activity_name: str | None
last_activity_status: str | None
class UserRepository:
@@ -32,11 +66,80 @@ class UserRepository:
self.session.commit()
return user
def dashboard_rows(self) -> list[UserDashboardRow]:
return [self._build_dashboard_row(user) for user in self.list_all()]
def dashboard_row(self, user_id: int) -> UserDashboardRow | None:
user = self.get(user_id)
if user is None:
return None
return self._build_dashboard_row(user)
def _build_dashboard_row(self, user: SyncUser) -> UserDashboardRow:
last_run = self.session.scalar(
select(SyncRun).where(SyncRun.user_id == user.id).order_by(SyncRun.started_at.desc()).limit(1)
)
last_activity = self.session.scalar(
select(Activity).where(Activity.user_id == user.id).order_by(Activity.created_at.desc()).limit(1)
)
return UserDashboardRow(
id=user.id,
name=user.name,
enabled=user.enabled,
health_state=user.health_state.value,
action_reason=user.action_reason,
last_sync_at=last_run.finished_at if last_run else None,
last_activity_name=last_activity.activity_name if last_activity else None,
last_activity_status=last_activity.status.value if last_activity else None,
)
def dashboard_summary(self, *, since: datetime) -> DashboardSummary:
rider_total = self.session.scalar(select(func.count()).select_from(SyncUser)) or 0
rider_enabled = self.session.scalar(
select(func.count()).select_from(SyncUser).where(SyncUser.enabled.is_(True))
) or 0
action_required_count = self.session.scalar(
select(func.count()).select_from(SyncUser).where(SyncUser.health_state == HealthState.ACTION_REQUIRED)
) or 0
imported_recent = self.session.scalar(
select(func.coalesce(func.sum(SyncRun.imported_count), 0)).where(SyncRun.started_at >= since)
) or 0
finished_runs = list(
self.session.scalars(
select(SyncRun).where(SyncRun.started_at >= since, SyncRun.finished_at.is_not(None))
)
)
if finished_runs:
successful = sum(
1 for run in finished_runs if run.status in (SyncRunStatus.SUCCESS, SyncRunStatus.PARTIAL)
)
success_rate_recent = (successful / len(finished_runs)) * 100
else:
success_rate_recent = None
return DashboardSummary(
rider_total=rider_total,
rider_enabled=rider_enabled,
imported_recent=imported_recent,
success_rate_recent=success_rate_recent,
action_required_count=action_required_count,
)
class ActivityRepository:
def __init__(self, session: Session) -> None:
self.session = session
def _require(self, activity_id: int) -> Activity:
activity = self.session.get(Activity, activity_id)
if activity is None:
raise ValueError(f"activity {activity_id} not found")
return activity
def get(self, activity_id: int) -> Activity | None:
return self.session.get(Activity, activity_id)
def get_or_create_discovered(
self,
*,
@@ -76,3 +179,163 @@ class ActivityRepository:
raise
return existing, False
return activity, True
def mark_downloaded(self, activity_id: int, path: str) -> Activity:
activity = self._require(activity_id)
activity.source_fit_path = path
activity.status = ActivityStatus.DOWNLOADED
activity.last_completed_stage = ActivityStatus.DOWNLOADED
activity.last_error = None
activity.retryable = True
self.session.commit()
return activity
def mark_converted(self, activity_id: int, path: str) -> Activity:
activity = self._require(activity_id)
activity.converted_fit_path = path
activity.status = ActivityStatus.CONVERTED
activity.last_completed_stage = ActivityStatus.CONVERTED
activity.last_error = None
activity.retryable = True
self.session.commit()
return activity
def mark_imported(self, activity_id: int, garmin_activity_id: str | None) -> Activity:
activity = self._require(activity_id)
activity.status = ActivityStatus.IMPORTED
activity.last_completed_stage = ActivityStatus.IMPORTED
activity.garmin_activity_id = garmin_activity_id
activity.last_error = None
activity.retryable = False
self.session.commit()
return activity
def mark_duplicate(self, activity_id: int) -> Activity:
activity = self._require(activity_id)
activity.status = ActivityStatus.DUPLICATE
activity.last_completed_stage = ActivityStatus.DUPLICATE
activity.last_error = None
activity.retryable = False
self.session.commit()
return activity
def mark_failed(self, activity_id: int, error: str, *, retryable: bool) -> Activity:
activity = self._require(activity_id)
activity.status = ActivityStatus.FAILED
activity.last_error = error[:2000]
activity.retryable = retryable
self.session.commit()
return activity
def reset_retryable_failure(self, activity_id: int) -> Activity:
activity = self._require(activity_id)
if activity.status != ActivityStatus.FAILED or not activity.retryable:
raise ValueError("activity is not retryable")
activity.status = activity.last_completed_stage
activity.last_error = None
self.session.commit()
return activity
def list_pending_for_user(self, user_id: int) -> list[Activity]:
return list(
self.session.scalars(
select(Activity).where(
Activity.user_id == user_id,
or_(
Activity.status.in_([ActivityStatus.DISCOVERED, ActivityStatus.DOWNLOADED, ActivityStatus.CONVERTED]),
and_(Activity.status == ActivityStatus.FAILED, Activity.retryable.is_(True)),
),
).order_by(Activity.id)
)
)
class SystemLogRepository:
def __init__(self, session: Session) -> None:
self.session = session
def add(self, *, source: str, message: str, user_id: int | None = None) -> SystemLogEntry:
entry = SystemLogEntry(source=source, message=message[:2000], user_id=user_id)
self.session.add(entry)
self.session.commit()
return entry
def list_recent(self, limit: int = 50) -> list[SystemLogEntry]:
return list(
self.session.scalars(
select(SystemLogEntry)
.order_by(SystemLogEntry.created_at.desc(), SystemLogEntry.id.desc())
.limit(limit)
)
)
class SchedulerSettingsRepository:
def __init__(self, session: Session) -> None:
self.session = session
def get_or_create(self, *, default_minutes: int = 5) -> SchedulerSettings:
row = self.session.get(SchedulerSettings, _SCHEDULER_SETTINGS_ID)
if row is None:
row = SchedulerSettings(
id=_SCHEDULER_SETTINGS_ID,
day_interval_minutes=default_minutes,
night_interval_minutes=default_minutes,
)
self.session.add(row)
self.session.commit()
return row
def update(self, row: SchedulerSettings, **values) -> SchedulerSettings:
for key, value in values.items():
setattr(row, key, value)
self.session.commit()
return row
class SyncRunRepository:
def __init__(self, session: Session) -> None:
self.session = session
def start(self, user_id: int) -> SyncRun:
sync_run = SyncRun(user_id=user_id, status=SyncRunStatus.RUNNING)
self.session.add(sync_run)
self.session.commit()
return sync_run
def get(self, sync_run_id: int) -> SyncRun | None:
return self.session.get(SyncRun, sync_run_id)
def list_recent_for_user(self, user_id: int, limit: int = 10) -> list[SyncRun]:
return list(
self.session.scalars(
select(SyncRun)
.where(SyncRun.user_id == user_id)
.order_by(SyncRun.started_at.desc())
.limit(limit)
)
)
def finish(
self,
sync_run_id: int,
*,
status: SyncRunStatus,
discovered: int,
imported: int,
skipped: int,
failed: int,
summary_error: str | None = None,
) -> SyncRun:
sync_run = self.session.get(SyncRun, sync_run_id)
if sync_run is None:
raise ValueError(f"sync_run {sync_run_id} not found")
sync_run.finished_at = utcnow()
sync_run.status = status
sync_run.discovered_count = discovered
sync_run.imported_count = imported
sync_run.skipped_count = skipped
sync_run.failed_count = failed
sync_run.summary_error = summary_error[:2000] if summary_error else None
self.session.commit()
return sync_run

View File

@@ -1,9 +1,21 @@
from sqlalchemy import create_engine
from sqlalchemy import create_engine, inspect, text
from sqlalchemy.engine import Engine
from sqlalchemy.orm import Session, sessionmaker
from app.db.models import Base
# Columns added to existing tables after their initial release. create_all()
# only creates missing tables, never adds columns to tables that already
# exist, so a column added to a model here must also be listed below or an
# already-deployed database will never receive it and the app will crash
# reading/writing that column.
_ADDITIVE_COLUMNS: dict[str, list[tuple[str, str]]] = {
"sync_users": [
("notify_email_enabled", "BOOLEAN NOT NULL DEFAULT 0"),
("notification_email", "VARCHAR(255)"),
],
}
def create_db_engine(database_url: str) -> Engine:
connect_args = {"check_same_thread": False} if database_url.startswith("sqlite") else {}
@@ -16,3 +28,24 @@ def create_session_factory(engine: Engine) -> sessionmaker[Session]:
def initialize_schema(engine: Engine) -> None:
Base.metadata.create_all(engine)
_apply_additive_migrations(engine)
def _apply_additive_migrations(engine: Engine) -> None:
if engine.dialect.name != "sqlite":
# ALTER TABLE ... ADD COLUMN syntax/type names below are only
# verified against sqlite, the only backend this app is deployed
# against; a fresh create_all() on another backend already has every
# current column, so skipping here only matters for a pre-existing
# non-sqlite database, which does not exist in practice.
return
inspector = inspect(engine)
existing_tables = set(inspector.get_table_names())
with engine.begin() as conn:
for table, columns in _ADDITIVE_COLUMNS.items():
if table not in existing_tables:
continue
existing_columns = {col["name"] for col in inspector.get_columns(table)}
for name, ddl_type in columns:
if name not in existing_columns:
conn.execute(text(f"ALTER TABLE {table} ADD COLUMN {name} {ddl_type}"))

View File

@@ -18,18 +18,40 @@ class UploadResult:
raw_response: Any
class GarminUploadBlocked(RuntimeError):
class GarminError(RuntimeError):
pass
class GarminAuthError(RuntimeError):
class GarminUploadBlocked(GarminError):
pass
class GarminTransientError(RuntimeError):
class GarminAuthError(GarminError):
pass
class GarminTransientError(GarminError):
pass
class GarminImportRejected(GarminError):
pass
_TRANSIENT_ERROR_TOKENS = (
"timeout",
"temporar",
"connection",
"429",
"too many",
"rate limit",
"500",
"502",
"503",
"504",
)
class GarminUploader:
def __init__(
self,
@@ -56,9 +78,12 @@ class GarminUploader:
if _looks_duplicate_error(exc):
return UploadResult("duplicate", True, None, str(exc))
text = str(exc).lower()
if any(token in text for token in ("timeout", "temporar", "connection", "502", "503", "504")):
if any(token in text for token in _TRANSIENT_ERROR_TOKENS):
raise GarminTransientError("Garmin import failed transiently") from exc
if any(token in text for token in ("password", "credential", "unauthorized", "401")):
raise GarminAuthError("Garmin import failed: authentication rejected") from exc
raise
_raise_if_import_rejected(response)
return UploadResult("imported", False, _extract_activity_id(response), response)
finally:
self._mfa_code = None
@@ -66,7 +91,7 @@ class GarminUploader:
def _ensure_client(self) -> GarminClientProtocol:
if self._client is not None:
return self._client
self.tokenstore.mkdir(parents=True, exist_ok=True)
self.tokenstore.mkdir(parents=True, exist_ok=True, mode=0o700)
factory = self.client_factory or _default_garmin_factory
client = factory(self.email, self.password, prompt_mfa=self._prompt_mfa)
try:
@@ -79,9 +104,9 @@ class GarminUploader:
raise GarminUploadBlocked("Garmin MFA is required") from exc
if any(token in text for token in ("password", "credential", "unauthorized", "401")):
raise GarminAuthError("Garmin authentication failed") from exc
if any(token in text for token in ("timeout", "temporar", "connection", "502", "503", "504")):
if any(token in text for token in _TRANSIENT_ERROR_TOKENS):
raise GarminTransientError("Garmin login failed transiently") from exc
raise GarminAuthError("Garmin login failed") from exc
raise GarminTransientError("Garmin login failed") from exc
self._client = client
return client
@@ -98,10 +123,25 @@ def _default_garmin_factory(*args: Any, **kwargs: Any) -> GarminClientProtocol:
def _looks_duplicate_error(exc: Exception) -> bool:
status_code = getattr(getattr(exc, "response", None), "status_code", None)
if status_code == 409:
return True
text = str(exc).lower()
return any(token in text for token in ("duplicate", "already exists", "409"))
def _raise_if_import_rejected(response: Any) -> None:
if not isinstance(response, dict):
return
detailed = response.get("detailedImportResult")
if not isinstance(detailed, dict):
return
failures = detailed.get("failures")
successes = detailed.get("successes")
if isinstance(failures, list) and failures and not successes:
raise GarminImportRejected(f"Garmin rejected the import ({len(failures)} failure(s))")
def _extract_activity_id(response: Any) -> str | None:
if not isinstance(response, dict):
return None

View File

@@ -1,8 +1,21 @@
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from starlette.middleware.sessions import SessionMiddleware
from app.config import Settings, get_settings
from app.db.session import create_db_engine, create_session_factory, initialize_schema
from app.fit.rewriter import convert_fit_device
from app.garmin.uploader import GarminUploader
from app.mywhoosh.client import MyWhooshClient
from app.notifications.emailer import EmailNotifier
from app.security.credentials import CredentialCipher
from app.sync.manager import SyncManager
from app.sync.scheduler import DayNightIntervalProvider, SyncScheduler
from app.web.account import router as account_router
from app.web.operations import router as operations_router
from app.web.routes import router as web_router
@@ -12,7 +25,52 @@ def create_app(settings: Settings | None = None) -> FastAPI:
resolved.tokens_dir.mkdir(parents=True, exist_ok=True)
resolved.activities_dir.mkdir(parents=True, exist_ok=True)
app = FastAPI(title="MyWhoosh Garmin Sync")
@asynccontextmanager
async def lifespan(app: FastAPI):
cipher = CredentialCipher(resolved.credential_encryption_key)
def mywhoosh_factory(token_store):
return MyWhooshClient(token_store)
def garmin_factory(email, password, tokenstore):
return GarminUploader(email=email, password=password, tokenstore=tokenstore)
notifier = EmailNotifier(
host=resolved.smtp_host,
port=resolved.smtp_port,
username=resolved.smtp_username,
password=resolved.smtp_password,
from_address=resolved.smtp_from_address,
use_tls=resolved.smtp_use_tls,
)
sync_manager = SyncManager(
session_factory=app.state.session_factory,
credential_cipher=cipher,
settings=resolved,
mywhoosh_factory=mywhoosh_factory,
garmin_factory=garmin_factory,
fit_converter=convert_fit_device,
notifier=notifier,
)
app.state.sync_manager = sync_manager
interval_provider = DayNightIntervalProvider(
app.state.session_factory, default_minutes=resolved.sync_interval_minutes
)
scheduler = SyncScheduler(
sync_manager,
interval_seconds=resolved.sync_interval_minutes * 60,
interval_provider=interval_provider,
)
app.state.scheduler = scheduler
await scheduler.start()
yield
await scheduler.stop()
app = FastAPI(title="MyWhoosh Garmin Sync", lifespan=lifespan)
app.state.settings = resolved
engine = create_db_engine(resolved.database_url)
@@ -27,6 +85,13 @@ def create_app(settings: Settings | None = None) -> FastAPI:
https_only=resolved.session_https_only,
)
app.include_router(web_router)
app.include_router(operations_router)
app.include_router(account_router)
app.mount(
"/static",
StaticFiles(directory=str(Path(__file__).resolve().parent / "web" / "static")),
name="static",
)
@app.get("/healthz")
def healthz() -> dict[str, str]:

View File

@@ -20,6 +20,10 @@ class MyWhooshAuthError(MyWhooshError):
pass
class MyWhooshDeviceConflictError(MyWhooshAuthError):
pass
class MyWhooshTransientError(MyWhooshError):
pass
@@ -31,9 +35,20 @@ class MyWhooshIntegrationError(MyWhooshError):
class MyWhooshClient:
def __init__(self, token_store: MyWhooshTokenStore, http_client: httpx.AsyncClient | None = None) -> None:
self.token_store = token_store
self._owns_http = http_client is None
self.http = http_client or httpx.AsyncClient(timeout=30.0)
self.token = token_store.load()
async def aclose(self) -> None:
if self._owns_http:
await self.http.aclose()
async def __aenter__(self) -> "MyWhooshClient":
return self
async def __aexit__(self, *exc_info: object) -> None:
await self.aclose()
async def login(self, email: str, password: str) -> None:
payload = {
"Username": email,
@@ -41,7 +56,7 @@ class MyWhooshClient:
"Platform": "Android",
"Action": 1001,
"CorrelationId": str(uuid.uuid4()),
"DeviceId": str(uuid.uuid4()),
"DeviceId": self.token_store.get_or_create_device_id(),
"Authorization": "",
}
try:
@@ -56,8 +71,13 @@ class MyWhooshClient:
body = response.json()
except ValueError as exc:
raise MyWhooshIntegrationError("MyWhoosh login returned invalid JSON") from exc
if not isinstance(body, dict):
raise MyWhooshIntegrationError("MyWhoosh login response is not a JSON object")
if body.get("Success") is not True or not body.get("AccessToken"):
raise MyWhooshAuthError(str(body.get("Message") or "MyWhoosh login failed"))
message = str(body.get("Message") or "MyWhoosh login failed")
if "another device" in message.lower():
raise MyWhooshDeviceConflictError(message)
raise MyWhooshAuthError(message)
self.token = MyWhooshToken(
access_token=str(body["AccessToken"]),
refresh_token=str(body["RefreshToken"]) if body.get("RefreshToken") else None,
@@ -72,7 +92,8 @@ class MyWhooshClient:
async def _authenticated_post(self, url: str, payload: dict, email: str, password: str) -> httpx.Response:
await self.ensure_authenticated(email, password)
for attempt in range(2):
assert self.token is not None
if self.token is None:
raise MyWhooshIntegrationError("no token after ensure_authenticated")
try:
response = await self.http.post(
url,
@@ -81,7 +102,9 @@ class MyWhooshClient:
)
except httpx.TransportError as exc:
raise MyWhooshTransientError("MyWhoosh request failed") from exc
if response.status_code not in {401, 403}:
if response.status_code == 403:
raise MyWhooshAuthError(f"MyWhoosh returned HTTP {response.status_code}")
if response.status_code != 401:
if response.status_code >= 500:
raise MyWhooshTransientError(f"MyWhoosh returned HTTP {response.status_code}")
return response
@@ -91,13 +114,15 @@ class MyWhooshClient:
await self.login(email, password)
continue
raise MyWhooshAuthError("MyWhoosh session rejected after reauthentication")
raise AssertionError("unreachable")
raise MyWhooshIntegrationError("unreachable state in _authenticated_post")
async def list_activities(self, email: str, password: str) -> list[MyWhooshActivity]:
async def list_activities(
self, email: str, password: str, max_pages: int | None = None
) -> list[MyWhooshActivity]:
activities: list[MyWhooshActivity] = []
page = 1
total_pages = 1
while page <= total_pages:
while page <= total_pages and (max_pages is None or page <= max_pages):
response = await self._authenticated_post(
ACTIVITIES_BASE + "rider/profile/activities",
{"sortDate": "DESC", "page": page},
@@ -119,26 +144,30 @@ class MyWhooshClient:
if not isinstance(results, list):
raise MyWhooshIntegrationError("MyWhoosh activities response has unexpected shape")
for row in results:
activities.append(self._normalize_activity(row))
activity = self._normalize_activity(row)
if activity is not None:
activities.append(activity)
page += 1
return activities
def _normalize_activity(self, row: object) -> MyWhooshActivity:
def _normalize_activity(self, row: object) -> MyWhooshActivity | None:
if not isinstance(row, dict):
raise MyWhooshIntegrationError("MyWhoosh activity row is not an object")
activity_id = row.get("id")
activity_file_id = row.get("activityFileId")
if not activity_id or not activity_file_id:
raise MyWhooshIntegrationError("MyWhoosh activity row missing stable id or activityFileId")
if activity_id is None or activity_id == "" or activity_file_id is None or activity_file_id == "":
return None
raw_started = row.get("startDatetime")
started_at: datetime | None = None
if raw_started:
try:
started_at = datetime.fromisoformat(str(raw_started).replace("Z", "+00:00")).astimezone(
timezone.utc
)
except ValueError as exc:
raise MyWhooshIntegrationError("MyWhoosh activity row has invalid startDatetime") from exc
parsed = datetime.fromisoformat(str(raw_started).replace("Z", "+00:00"))
except ValueError:
return None
if parsed.tzinfo is None:
started_at = parsed.replace(tzinfo=timezone.utc)
else:
started_at = parsed.astimezone(timezone.utc)
return MyWhooshActivity(
id=str(activity_id),
title=str(row.get("title") or ""),
@@ -155,7 +184,13 @@ class MyWhooshClient:
)
if response.status_code >= 400:
raise MyWhooshIntegrationError(f"download metadata returned HTTP {response.status_code}")
url = response.json().get("data")
try:
body = response.json()
except ValueError as exc:
raise MyWhooshIntegrationError("MyWhoosh download response returned invalid JSON") from exc
if not isinstance(body, dict):
raise MyWhooshIntegrationError("MyWhoosh download response is not a JSON object")
url = body.get("data")
if not isinstance(url, str) or not url:
raise MyWhooshIntegrationError("MyWhoosh download response has no URL")
try:

View File

@@ -1,5 +1,6 @@
import json
import os
import uuid
from pathlib import Path
from app.mywhoosh.models import MyWhooshToken
@@ -8,17 +9,18 @@ from app.mywhoosh.models import MyWhooshToken
class MyWhooshTokenStore:
def __init__(self, path: Path) -> None:
self.path = path
self.device_id_path = path.with_name("device_id")
def load(self) -> MyWhooshToken | None:
try:
raw = json.loads(self.path.read_text("utf-8"))
except FileNotFoundError:
return MyWhooshToken(
access_token=raw["access_token"],
refresh_token=raw.get("refresh_token"),
whoosh_id=raw.get("whoosh_id"),
)
except (FileNotFoundError, OSError, ValueError, KeyError, TypeError):
return None
return MyWhooshToken(
access_token=raw["access_token"],
refresh_token=raw.get("refresh_token"),
whoosh_id=raw.get("whoosh_id"),
)
def save(self, token: MyWhooshToken) -> None:
self.path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
@@ -40,3 +42,16 @@ class MyWhooshTokenStore:
def clear(self) -> None:
self.path.unlink(missing_ok=True)
def get_or_create_device_id(self) -> str:
try:
existing = self.device_id_path.read_text("utf-8").strip()
except (FileNotFoundError, OSError):
existing = ""
if existing:
return existing
device_id = str(uuid.uuid4())
self.device_id_path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
self.device_id_path.write_text(device_id, "utf-8")
os.chmod(self.device_id_path, 0o600)
return device_id

View File

View File

@@ -0,0 +1,42 @@
from __future__ import annotations
import smtplib
from email.message import EmailMessage
class EmailNotifier:
def __init__(
self,
*,
host: str | None,
port: int,
username: str | None,
password: str | None,
from_address: str | None,
use_tls: bool,
) -> None:
self.host = host
self.port = port
self.username = username
self.password = password
self.from_address = from_address
self.use_tls = use_tls
@property
def configured(self) -> bool:
return bool(self.host and self.from_address)
def send(self, *, to_address: str, subject: str, body: str) -> None:
if not self.configured:
return
message = EmailMessage()
message["Subject"] = subject
message["From"] = self.from_address
message["To"] = to_address
message.set_content(body)
with smtplib.SMTP(self.host, self.port, timeout=10) as smtp:
if self.use_tls:
smtp.starttls()
if self.username and self.password:
smtp.login(self.username, self.password)
smtp.send_message(message)

0
app/sync/__init__.py Normal file
View File

455
app/sync/manager.py Normal file
View File

@@ -0,0 +1,455 @@
from __future__ import annotations
import asyncio
import logging
from pathlib import Path
from typing import Any, Callable
from app.db.models import ActivityStatus, HealthState, SyncRunStatus
from app.db.repositories import ActivityRepository, SyncRunRepository, SystemLogRepository, UserRepository
from app.fit.rewriter import FitFormatError
from app.garmin.uploader import (
GarminAuthError,
GarminImportRejected,
GarminTransientError,
GarminUploadBlocked,
)
from app.mywhoosh.client import (
MyWhooshAuthError,
MyWhooshDeviceConflictError,
MyWhooshIntegrationError,
MyWhooshTransientError,
)
from app.mywhoosh.tokenstore import MyWhooshTokenStore
from app.security.credentials import CredentialCipher
from app.sync.states import SyncOutcome
logger = logging.getLogger(__name__)
class SyncAlreadyRunning(RuntimeError):
pass
# action_reason values that are resolved purely by this run's MyWhoosh listing
# succeeding (reaching the end of the activity loop with stop_user_run still
# False already proves MyWhoosh is working again -- every MyWhoosh-related
# exception branch that could fire also sets stop_user_run=True).
_MYWHOOSH_ACTION_REASONS = frozenset(
{"mywhoosh_auth_required", "mywhoosh_integration_changed", "mywhoosh_device_conflict"}
)
# action_reason values that can only be resolved by actual, this-run evidence
# of a successful Garmin import -- the mere absence of a Garmin exception does
# NOT prove anything, since no Garmin work may have been attempted this run.
_GARMIN_ACTION_REASONS = frozenset({"garmin_mfa_required", "garmin_auth_required"})
class SyncManager:
"""Resumable single-user MyWhoosh -> Garmin sync pipeline.
`_sync_user_locked` implements the state machine for one user's sync run.
`sync_user` wraps it with a per-user `asyncio.Lock` so only one sync can
run for a given user at a time (raising `SyncAlreadyRunning` on overlap),
and `sync_all_enabled` fans out across all enabled users, isolating each
user's failure to its own result rather than cancelling siblings.
"""
def __init__(
self,
*,
session_factory: Callable[[], Any],
credential_cipher: CredentialCipher,
settings: Any,
mywhoosh_factory: Callable[[MyWhooshTokenStore], Any],
garmin_factory: Callable[[str, str, Path], Any],
fit_converter: Callable[[Path, Path], Any],
notifier: Any = None,
) -> None:
self.session_factory = session_factory
self.credential_cipher = credential_cipher
self.settings = settings
self.mywhoosh_factory = mywhoosh_factory
self.garmin_factory = garmin_factory
self.fit_converter = fit_converter
self.notifier = notifier
self._locks: dict[int, asyncio.Lock] = {}
self._locks_guard = asyncio.Lock()
def _notify_action_required(self, session: Any, user: Any, message: str | None) -> None:
if self.notifier is None or not user.notify_email_enabled or not user.notification_email:
return
# A notifier with no SMTP host configured silently no-ops in send()
# rather than raising -- without this check, that silence would look
# identical to "everything's fine" in the system log, leaving an
# opted-in user with no way to find out why no mail ever arrives.
if not getattr(self.notifier, "configured", True):
self._record_log(
session,
(
f"Skipped action-required email to {user.notification_email} for user {user.id} "
f"({user.name}): SMTP is not configured (SMTP_HOST unset)."
),
user_id=user.id,
)
return
try:
self.notifier.send(
to_address=user.notification_email,
subject=f"MyWhoosh-Garmin Sync: action required for {user.name}",
body=message or "Your sync requires attention. Check the dashboard for details.",
)
except Exception as exc:
logger.warning(
"sync_user: failed to send action-required notification for user %s", user.id, exc_info=True
)
self._record_log(
session,
(
f"Failed to email {user.notification_email} for user {user.id} ({user.name}): "
f"{type(exc).__name__}: {exc}"
),
user_id=user.id,
)
def _record_log(self, session: Any, message: str, *, user_id: int | None = None) -> None:
try:
SystemLogRepository(session).add(source="email_notification", message=message, user_id=user_id)
except Exception:
logger.warning("sync_user: failed to record email-notification entry in system log", exc_info=True)
async def _lock_for(self, user_id: int) -> asyncio.Lock:
async with self._locks_guard:
return self._locks.setdefault(user_id, asyncio.Lock())
async def sync_user(self, user_id: int, mfa_code: str | None = None) -> SyncOutcome:
lock = await self._lock_for(user_id)
if lock.locked():
raise SyncAlreadyRunning(f"sync already running for user {user_id}")
async with lock:
return await self._sync_user_locked(user_id, mfa_code)
def _load_enabled_user_ids(self) -> list[int]:
with self.session_factory() as session:
return [user.id for user in UserRepository(session).list_enabled()]
async def sync_all_enabled(self) -> list[SyncOutcome | Exception]:
user_ids = self._load_enabled_user_ids()
return await asyncio.gather(
*(self.sync_user(user_id) for user_id in user_ids),
return_exceptions=True,
)
async def _sync_user_locked(self, user_id: int, mfa_code: str | None = None) -> SyncOutcome:
with self.session_factory() as session:
user = UserRepository(session).get(user_id)
if user is None:
raise ValueError(f"user {user_id} not found")
# Captured before this run mutates action_reason, so the
# end-of-run notification below only fires on a transition into
# (or between) action-required states -- never repeatedly for a
# cause that's already been reported and still unresolved.
previous_action_reason = user.action_reason
sync_run_repo = SyncRunRepository(session)
run = sync_run_repo.start(user_id)
# Best-available counters, tracked outside the inner try so that if
# something raises before/while they'd normally be populated, the
# outer except below can still report whatever we do know.
discovered = 0
imported_count = 0
skipped_count = 0
failed_count = 0
mywhoosh = None
try:
try:
mw_email = self.credential_cipher.decrypt(user.mywhoosh_email_enc)
mw_password = self.credential_cipher.decrypt(user.mywhoosh_password_enc)
garmin_email = self.credential_cipher.decrypt(user.garmin_email_enc)
garmin_password = self.credential_cipher.decrypt(user.garmin_password_enc)
token_dir = self.settings.tokens_dir / str(user.id)
mywhoosh = self.mywhoosh_factory(MyWhooshTokenStore(token_dir / "mywhoosh.json"))
garmin = self.garmin_factory(garmin_email, garmin_password, token_dir / "garmin")
activity_repo = ActivityRepository(session)
stop_user_run = False
summary_error: str | None = None
# True only once an actual, this-run Garmin import/duplicate
# check has succeeded for a real activity -- the sole
# evidence that can justify clearing a Garmin-class
# action_reason (see _GARMIN_ACTION_REASONS below).
garmin_succeeded_this_run = False
try:
remote_activities = await mywhoosh.list_activities(mw_email, mw_password)
except MyWhooshTransientError as exc:
user.health_state = HealthState.DEGRADED
user.mywhoosh_state = "error"
session.commit()
remote_activities = []
stop_user_run = True
summary_error = str(exc)
except MyWhooshDeviceConflictError as exc:
user.health_state = HealthState.ACTION_REQUIRED
user.mywhoosh_state = "device_conflict"
user.action_reason = "mywhoosh_device_conflict"
session.commit()
remote_activities = []
stop_user_run = True
summary_error = str(exc)
except MyWhooshAuthError as exc:
user.health_state = HealthState.ACTION_REQUIRED
user.mywhoosh_state = "auth_required"
user.action_reason = "mywhoosh_auth_required"
session.commit()
remote_activities = []
stop_user_run = True
summary_error = str(exc)
except MyWhooshIntegrationError as exc:
user.health_state = HealthState.ACTION_REQUIRED
user.mywhoosh_state = "integration_error"
user.action_reason = "mywhoosh_integration_changed"
session.commit()
remote_activities = []
stop_user_run = True
summary_error = str(exc)
except Exception as exc:
user.health_state = HealthState.DEGRADED
user.mywhoosh_state = "error"
session.commit()
remote_activities = []
stop_user_run = True
summary_error = f"{type(exc).__name__}: {str(exc)[:200]}"
logger.warning(
"sync_user: unexpected error listing activities for user %s: %s",
user.id,
exc.__class__.__name__,
)
else:
user.mywhoosh_state = "connected"
session.commit()
discovered = len(remote_activities)
for remote in remote_activities:
if stop_user_run:
break
activity, _created = activity_repo.get_or_create_discovered(
user_id=user.id,
mywhoosh_activity_id=remote.id,
activity_name=remote.title,
activity_timestamp=remote.started_at,
)
# Non-retryable failures (e.g. corrupt/unsupported FIT
# files) are terminal: never re-attempt them, and don't
# count them in any counter for this run.
if activity.status == ActivityStatus.FAILED and not activity.retryable:
continue
stage = (
activity.last_completed_stage
if activity.status == ActivityStatus.FAILED
else activity.status
)
initial_stage = stage
# Use the DB's own numeric primary key rather than the
# upstream-supplied mywhoosh_activity_id as a directory
# component: activity.id is always a safe integer, so
# this avoids any path-traversal risk from an
# unsanitized remote id (e.g. "../../etc") while
# remaining just as stable across resumed syncs.
activity_dir = self.settings.activities_dir / str(user.id) / str(activity.id)
source_path = activity_dir / "source.fit"
converted_path = activity_dir / "edge-1030-plus.fit"
try:
if stage == ActivityStatus.DISCOVERED:
fit_bytes = await mywhoosh.download_fit(
remote.activity_file_id, mw_email, mw_password
)
activity_dir.mkdir(parents=True, exist_ok=True)
source_path.write_bytes(fit_bytes)
activity = activity_repo.mark_downloaded(activity.id, str(source_path))
stage = activity.status
if stage in {ActivityStatus.DOWNLOADED}:
self.fit_converter(source_path, converted_path)
activity = activity_repo.mark_converted(activity.id, str(converted_path))
stage = activity.status
if stage in {ActivityStatus.CONVERTED}:
upload = await asyncio.to_thread(garmin.import_fit, converted_path, mfa_code)
if upload.duplicate:
activity = activity_repo.mark_duplicate(activity.id)
else:
activity = activity_repo.mark_imported(activity.id, upload.garmin_activity_id)
stage = activity.status
user.garmin_state = "connected"
garmin_succeeded_this_run = True
# Only count this activity's outcome toward this
# run's totals if the state machine actually did
# work this call. An activity that was already
# terminal (IMPORTED/DUPLICATE) before this call is
# resume history, not this run's work.
if initial_stage not in (ActivityStatus.IMPORTED, ActivityStatus.DUPLICATE):
if activity.status == ActivityStatus.IMPORTED:
imported_count += 1
elif activity.status == ActivityStatus.DUPLICATE:
skipped_count += 1
except MyWhooshTransientError as exc:
user.health_state = HealthState.DEGRADED
user.mywhoosh_state = "error"
activity_repo.mark_failed(activity.id, str(exc), retryable=True)
failed_count += 1
except MyWhooshDeviceConflictError as exc:
user.health_state = HealthState.ACTION_REQUIRED
user.mywhoosh_state = "device_conflict"
user.action_reason = "mywhoosh_device_conflict"
stop_user_run = True
summary_error = str(exc)
except MyWhooshAuthError as exc:
user.health_state = HealthState.ACTION_REQUIRED
user.mywhoosh_state = "auth_required"
user.action_reason = "mywhoosh_auth_required"
stop_user_run = True
summary_error = str(exc)
except MyWhooshIntegrationError as exc:
user.health_state = HealthState.ACTION_REQUIRED
user.mywhoosh_state = "integration_error"
user.action_reason = "mywhoosh_integration_changed"
stop_user_run = True
summary_error = str(exc)
except GarminUploadBlocked as exc:
user.health_state = HealthState.ACTION_REQUIRED
user.garmin_state = "mfa_required"
user.action_reason = "garmin_mfa_required"
stop_user_run = True
summary_error = str(exc)
except GarminAuthError as exc:
user.health_state = HealthState.ACTION_REQUIRED
user.garmin_state = "auth_required"
user.action_reason = "garmin_auth_required"
stop_user_run = True
summary_error = str(exc)
except GarminTransientError as exc:
user.health_state = HealthState.DEGRADED
user.garmin_state = "error"
activity_repo.mark_failed(activity.id, str(exc), retryable=True)
failed_count += 1
except GarminImportRejected as exc:
# Garmin-side permanent content rejection (real
# failures, no successes in detailedImportResult):
# same category as a corrupt/unsupported FIT file --
# a non-retryable per-activity failure, no user
# health/state change.
activity_repo.mark_failed(activity.id, str(exc), retryable=False)
failed_count += 1
except FitFormatError as exc:
activity_repo.mark_failed(activity.id, str(exc), retryable=False)
failed_count += 1
except Exception as exc:
user.health_state = HealthState.DEGRADED
activity_repo.mark_failed(
activity.id,
f"{type(exc).__name__}: {str(exc)[:200]}",
retryable=True,
)
failed_count += 1
logger.warning(
"sync_user: unexpected error for user %s activity %s: %s",
user.id,
activity.id,
exc.__class__.__name__,
)
session.commit()
if not stop_user_run:
# Reaching here with stop_user_run still False proves
# this run's MyWhoosh listing succeeded (every
# MyWhoosh-exception branch above also sets
# stop_user_run=True) -- so a MyWhoosh-class
# action_reason is always safe to clear here. It does
# NOT prove any Garmin problem was fixed: a Garmin-class
# action_reason may only be cleared when this run
# actually succeeded at a real Garmin import
# (garmin_succeeded_this_run). Otherwise the
# action-required condition is still live and
# unverified, so leave both action_reason and
# health_state untouched.
reason = user.action_reason
can_clear = (
reason is None
or reason in _MYWHOOSH_ACTION_REASONS
or (reason in _GARMIN_ACTION_REASONS and garmin_succeeded_this_run)
)
if can_clear:
user.action_reason = None
user.health_state = (
HealthState.DEGRADED if failed_count > 0 else HealthState.HEALTHY
)
session.commit()
status = (
SyncRunStatus.SUCCESS
if failed_count == 0 and not stop_user_run
else SyncRunStatus.PARTIAL
if (imported_count + skipped_count) > 0
else SyncRunStatus.FAILED
)
sync_run_repo.finish(
run.id,
status=status,
discovered=discovered,
imported=imported_count,
skipped=skipped_count,
failed=failed_count,
summary_error=summary_error,
)
session.commit()
if user.action_reason is not None and user.action_reason != previous_action_reason:
self._notify_action_required(session, user, summary_error)
return SyncOutcome(
user_id=user.id,
status=status.value,
discovered=discovered,
imported=imported_count,
skipped=skipped_count,
failed=failed_count,
message=summary_error,
)
except Exception as exc:
# Any unhandled exception escaping the block above (e.g. a
# credential-decrypt failure after key rotation, a factory
# constructor raising, an unexpected DB error) would
# otherwise leave this SyncRun stuck at
# status=RUNNING/finished_at=NULL forever. Finish it as
# FAILED with whatever counters we do have, then
# re-propagate so callers (e.g. sync_all_enabled's
# asyncio.gather(return_exceptions=True)) still see it.
session.rollback()
sync_run_repo.finish(
run.id,
status=SyncRunStatus.FAILED,
discovered=discovered,
imported=imported_count,
skipped=skipped_count,
failed=failed_count,
summary_error=f"{type(exc).__name__}: {str(exc)[:200]}",
)
session.commit()
raise
finally:
if mywhoosh is not None:
aclose = getattr(mywhoosh, "aclose", None)
if aclose is not None:
await aclose()

116
app/sync/scheduler.py Normal file
View File

@@ -0,0 +1,116 @@
import asyncio
import logging
from datetime import datetime, timedelta, timezone
from typing import Callable
from app.db.repositories import SchedulerSettingsRepository
from app.sync.manager import SyncAlreadyRunning
logger = logging.getLogger(__name__)
def is_daytime(hour: int, day_start_hour: int, night_start_hour: int) -> bool:
"""True if `hour` falls within [day_start_hour, night_start_hour),
handling a day period that wraps past midnight (e.g. day_start=20,
night_start=6). Equal start hours are treated as "always day"."""
if day_start_hour == night_start_hour:
return True
if day_start_hour < night_start_hour:
return day_start_hour <= hour < night_start_hour
return hour >= day_start_hour or hour < night_start_hour
class DayNightIntervalProvider:
"""Reads the admin-configurable day/night interval from the DB on every
call, so a change made via the UI takes effect on the scheduler's very
next tick without an app restart."""
def __init__(self, session_factory, *, default_minutes: int, now: Callable[[], datetime] | None = None) -> None:
self.session_factory = session_factory
self.default_minutes = default_minutes
self._now = now or datetime.now
def __call__(self) -> float:
with self.session_factory() as session:
row = SchedulerSettingsRepository(session).get_or_create(default_minutes=self.default_minutes)
day_start_hour = row.day_start_hour
night_start_hour = row.night_start_hour
day_interval_minutes = row.day_interval_minutes
night_interval_minutes = row.night_interval_minutes
hour = self._now().hour
minutes = (
day_interval_minutes
if is_daytime(hour, day_start_hour, night_start_hour)
else night_interval_minutes
)
return minutes * 60
class SyncScheduler:
def __init__(
self,
manager,
*,
interval_seconds: float,
interval_provider: Callable[[], float] | None = None,
) -> None:
self.manager = manager
self.interval_seconds = interval_seconds
self.interval_provider = interval_provider
self._task: asyncio.Task | None = None
self._stop = asyncio.Event()
self.last_tick = None
self.next_tick = None
def _current_interval(self) -> float:
if self.interval_provider is None:
return self.interval_seconds
try:
return float(self.interval_provider())
except Exception:
logger.warning("interval_provider failed, falling back to default interval", exc_info=True)
return self.interval_seconds
async def run_once(self, interval: float | None = None) -> None:
if interval is None:
interval = self._current_interval()
self.last_tick = datetime.now(timezone.utc)
try:
results = await self.manager.sync_all_enabled()
for result in results:
if not isinstance(result, Exception):
continue
# SyncAlreadyRunning is an expected, benign outcome when a
# manual sync and a scheduler tick overlap for the same user
# -- not an error worth logging.
if isinstance(result, SyncAlreadyRunning):
continue
logger.warning(
"sync_all_enabled: user sync failed during scheduled tick: %s: %s",
type(result).__name__,
str(result)[:200],
)
except Exception:
logger.exception("sync_all_enabled failed during scheduled tick")
finally:
self.next_tick = datetime.now(timezone.utc) + timedelta(seconds=interval)
async def _run(self) -> None:
while not self._stop.is_set():
interval = self._current_interval()
await self.run_once(interval)
try:
await asyncio.wait_for(self._stop.wait(), timeout=interval)
except TimeoutError:
pass
async def start(self) -> None:
self._stop.clear()
self._task = asyncio.create_task(self._run())
async def stop(self) -> None:
self._stop.set()
if self._task is not None:
await self._task
self._task = None

12
app/sync/states.py Normal file
View File

@@ -0,0 +1,12 @@
from dataclasses import dataclass
@dataclass(frozen=True)
class SyncOutcome:
user_id: int
status: str
discovered: int
imported: int
skipped: int
failed: int
message: str | None = None

161
app/web/account.py Normal file
View File

@@ -0,0 +1,161 @@
from fastapi import APIRouter, Form, HTTPException, Request, status
from fastapi.responses import HTMLResponse, RedirectResponse
from app.auth.account import authenticate_self_service
from app.auth.admin import require_self_service
from app.auth.csrf import ensure_csrf_token, validate_csrf
from app.db.repositories import ActivityRepository, SyncRunRepository, UserRepository
from app.security.credentials import CredentialCipher
from app.sync.manager import SyncAlreadyRunning
from app.web.operations import _outcome_toast, _toast_html
from app.web.routes import templates
router = APIRouter()
def _cipher(request: Request) -> CredentialCipher:
return CredentialCipher(request.app.state.settings.credential_encryption_key)
def _require_non_empty(value: str, field_name: str) -> None:
if not value.strip():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"{field_name} must not be empty",
)
@router.get("/account-login", response_class=HTMLResponse)
def account_login_page(request: Request):
return templates.TemplateResponse(request, "account_login.html", {"csrf_token": ensure_csrf_token(request)})
@router.post("/account-login")
def account_login(
request: Request,
csrf_token: str = Form(...),
email: str = Form(...),
password: str = Form(...),
):
validate_csrf(request, csrf_token)
cipher = _cipher(request)
with request.app.state.session_factory() as session:
user = authenticate_self_service(session, cipher, email, password)
if user is None:
return templates.TemplateResponse(
request,
"account_login.html",
{"csrf_token": ensure_csrf_token(request), "error": "Invalid email or password"},
status_code=401,
)
request.session["self_service_user_id"] = user.id
return RedirectResponse("/account", status_code=303)
@router.post("/account-logout")
def account_logout(request: Request, csrf_token: str = Form(...)):
validate_csrf(request, csrf_token)
request.session.pop("self_service_user_id", None)
return RedirectResponse("/account-login", status_code=303)
def _get_own_user_or_reauth(request: Request, repository: UserRepository, user_id: int):
user = repository.get(user_id)
if user is None:
request.session.pop("self_service_user_id", None)
raise HTTPException(status_code=status.HTTP_303_SEE_OTHER, headers={"Location": "/account-login"})
return user
@router.get("/account", response_class=HTMLResponse)
def account_detail(request: Request):
user_id = require_self_service(request)
with request.app.state.session_factory() as session:
user = _get_own_user_or_reauth(request, UserRepository(session), user_id)
activities = ActivityRepository(session).list_pending_for_user(user_id)
recent_runs = SyncRunRepository(session).list_recent_for_user(user_id, limit=10)
return templates.TemplateResponse(
request,
"account/detail.html",
{
"csrf_token": ensure_csrf_token(request),
"user": user,
"activities": activities,
"recent_runs": recent_runs,
},
)
@router.post("/account/sync", response_class=HTMLResponse)
async def account_sync(request: Request, csrf_token: str = Form(...)):
user_id = require_self_service(request)
validate_csrf(request, csrf_token)
try:
outcome = await request.app.state.sync_manager.sync_user(user_id)
with request.app.state.session_factory() as session:
user = UserRepository(session).get(user_id)
label = user.name if user is not None else "Account"
message, level = _outcome_toast(outcome, label)
except SyncAlreadyRunning:
with request.app.state.session_factory() as session:
user = UserRepository(session).get(user_id)
label = user.name if user is not None else "Account"
message, level = f"{label}: sync already running", "info"
status_html = (
templates.get_template("fragments/account_status.html").render(user=user) if user is not None else ""
)
return HTMLResponse(status_html + _toast_html(message, level))
@router.get("/account/edit", response_class=HTMLResponse)
def account_edit_page(request: Request):
user_id = require_self_service(request)
cipher = _cipher(request)
with request.app.state.session_factory() as session:
user = _get_own_user_or_reauth(request, UserRepository(session), user_id)
return templates.TemplateResponse(
request,
"account/form.html",
{
"csrf_token": ensure_csrf_token(request),
"user": user,
"mywhoosh_email": cipher.decrypt(user.mywhoosh_email_enc),
"garmin_email": cipher.decrypt(user.garmin_email_enc),
},
)
@router.post("/account/edit")
def account_update(
request: Request,
csrf_token: str = Form(...),
mywhoosh_email: str = Form(""),
mywhoosh_password: str = Form(""),
garmin_email: str = Form(""),
garmin_password: str = Form(""),
notify_email_enabled: str | None = Form(None),
notification_email: str = Form(""),
):
user_id = require_self_service(request)
validate_csrf(request, csrf_token)
_require_non_empty(mywhoosh_email, "mywhoosh_email")
_require_non_empty(garmin_email, "garmin_email")
notify_enabled = notify_email_enabled is not None
if notify_enabled:
_require_non_empty(notification_email, "notification_email")
cipher = _cipher(request)
with request.app.state.session_factory() as session:
repository = UserRepository(session)
user = _get_own_user_or_reauth(request, repository, user_id)
values = {
"mywhoosh_email_enc": cipher.encrypt(mywhoosh_email.strip()),
"garmin_email_enc": cipher.encrypt(garmin_email.strip()),
"notify_email_enabled": notify_enabled,
"notification_email": notification_email.strip() or None,
}
if mywhoosh_password:
values["mywhoosh_password_enc"] = cipher.encrypt(mywhoosh_password)
if garmin_password:
values["garmin_password_enc"] = cipher.encrypt(garmin_password)
repository.update(user, **values)
return RedirectResponse("/account", status_code=303)

View File

@@ -9,3 +9,5 @@ class UserFormData:
garmin_email: str
garmin_password: str
enabled: bool
notify_email_enabled: bool = False
notification_email: str = ""

195
app/web/operations.py Normal file
View File

@@ -0,0 +1,195 @@
from fastapi import APIRouter, Form, HTTPException, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from sqlalchemy import func, select
from app.auth.admin import require_admin
from app.auth.csrf import ensure_csrf_token, validate_csrf
from app.db.models import Activity
from app.db.repositories import ActivityRepository, SchedulerSettingsRepository, SystemLogRepository, UserRepository
from app.sync.manager import SyncAlreadyRunning
from app.web.routes import templates
router = APIRouter()
APP_VERSION = "1.0.0"
def _toast_html(message: str, level: str) -> str:
return templates.get_template("fragments/toast.html").render(message=message, level=level)
def _outcome_toast(outcome, label: str) -> tuple[str, str]:
if outcome.status in ("success", "partial"):
return f"{label}: {outcome.imported} imported, {outcome.failed} failed", "success"
return f"{label}: sync failed — {outcome.message or 'unknown error'}", "danger"
def _normalize_outcome(item):
if isinstance(item, Exception):
return {
"status": "error",
"user_id": None,
"discovered": 0,
"imported": 0,
"skipped": 0,
"failed": 0,
"message": str(item),
}
return {
"status": item.status,
"user_id": item.user_id,
"discovered": item.discovered,
"imported": item.imported,
"skipped": item.skipped,
"failed": item.failed,
"message": item.message,
}
@router.post("/users/{user_id}/sync", response_class=HTMLResponse)
async def manual_sync(request: Request, user_id: int, csrf_token: str = Form(...)):
require_admin(request)
validate_csrf(request, csrf_token)
token = ensure_csrf_token(request)
try:
outcome = await request.app.state.sync_manager.sync_user(user_id)
with request.app.state.session_factory() as session:
row = UserRepository(session).dashboard_row(user_id)
label = row.name if row is not None else f"Rider #{user_id}"
message, level = _outcome_toast(outcome, label)
except SyncAlreadyRunning:
with request.app.state.session_factory() as session:
row = UserRepository(session).dashboard_row(user_id)
label = row.name if row is not None else f"Rider #{user_id}"
message, level = f"{label}: sync already running", "info"
row_html = (
templates.get_template("fragments/user_row.html").render(row=row, csrf_token=token, oob=False)
if row is not None
else ""
)
return HTMLResponse(row_html + _toast_html(message, level))
@router.post("/sync-all", response_class=HTMLResponse)
async def manual_sync_all(request: Request, csrf_token: str = Form(...)):
require_admin(request)
validate_csrf(request, csrf_token)
outcomes = await request.app.state.sync_manager.sync_all_enabled()
token = ensure_csrf_token(request)
parts = [templates.get_template("fragments/sync_all_form.html").render(csrf_token=token)]
ok = 0
failed = 0
with request.app.state.session_factory() as session:
repository = UserRepository(session)
for item in outcomes:
normalized = _normalize_outcome(item)
if normalized["status"] in ("success", "partial"):
ok += 1
else:
failed += 1
outcome_user_id = normalized["user_id"]
if outcome_user_id is not None:
row = repository.dashboard_row(outcome_user_id)
if row is not None:
parts.append(
templates.get_template("fragments/user_row.html").render(row=row, csrf_token=token, oob=True)
)
if not outcomes:
parts.append(_toast_html("No riders to sync", "info"))
else:
level = "success" if failed == 0 else "danger"
parts.append(_toast_html(f"Synced {len(outcomes)} riders — {ok} ok, {failed} failed", level))
return HTMLResponse("".join(parts))
@router.post("/users/{user_id}/garmin-mfa", response_class=HTMLResponse)
async def garmin_mfa(request: Request, user_id: int, csrf_token: str = Form(...), code: str = Form(...)):
require_admin(request)
validate_csrf(request, csrf_token)
stripped = code.strip()
if not stripped or len(stripped) > 20:
raise HTTPException(status_code=400, detail="Invalid MFA code")
try:
outcome = await request.app.state.sync_manager.sync_user(user_id, mfa_code=stripped)
except SyncAlreadyRunning:
return HTMLResponse("Sync already running for this user", status_code=409)
return templates.TemplateResponse(
request, "fragments/sync_result.html", {"outcomes": [_normalize_outcome(outcome)]}
)
@router.post("/activities/{activity_id}/retry", response_class=HTMLResponse)
async def retry_activity(request: Request, activity_id: int, csrf_token: str = Form(...)):
require_admin(request)
validate_csrf(request, csrf_token)
with request.app.state.session_factory() as session:
activity_repo = ActivityRepository(session)
try:
activity = activity_repo.reset_retryable_failure(activity_id)
except ValueError:
return HTMLResponse("Activity is not retryable", status_code=409)
user_id = activity.user_id
try:
outcome = await request.app.state.sync_manager.sync_user(user_id)
except SyncAlreadyRunning:
return HTMLResponse("Sync already running for this user", status_code=409)
return templates.TemplateResponse(
request, "fragments/sync_result.html", {"outcomes": [_normalize_outcome(outcome)]}
)
@router.get("/system", response_class=HTMLResponse)
def system_page(request: Request):
require_admin(request)
settings = request.app.state.settings
scheduler = request.app.state.scheduler
with request.app.state.session_factory() as session:
user_count = len(UserRepository(session).list_all())
activity_count = session.scalar(select(func.count()).select_from(Activity)) or 0
log_entries = SystemLogRepository(session).list_recent(limit=50)
scheduler_settings = SchedulerSettingsRepository(session).get_or_create(
default_minutes=settings.sync_interval_minutes
)
return templates.TemplateResponse(request, "system.html", {
"csrf_token": ensure_csrf_token(request),
"app_version": APP_VERSION,
"last_tick": scheduler.last_tick,
"next_tick": scheduler.next_tick,
"user_count": user_count,
"activity_count": activity_count,
"log_entries": log_entries,
"scheduler_settings": scheduler_settings,
})
@router.post("/system/scheduler-settings")
def update_scheduler_settings(
request: Request,
csrf_token: str = Form(...),
day_start_hour: int = Form(...),
night_start_hour: int = Form(...),
day_interval_minutes: int = Form(...),
night_interval_minutes: int = Form(...),
):
require_admin(request)
validate_csrf(request, csrf_token)
if not (0 <= day_start_hour <= 23) or not (0 <= night_start_hour <= 23):
raise HTTPException(status_code=400, detail="Hours must be between 0 and 23")
if day_interval_minutes < 1 or night_interval_minutes < 1:
raise HTTPException(status_code=400, detail="Intervals must be at least 1 minute")
settings = request.app.state.settings
with request.app.state.session_factory() as session:
repository = SchedulerSettingsRepository(session)
row = repository.get_or_create(default_minutes=settings.sync_interval_minutes)
repository.update(
row,
day_start_hour=day_start_hour,
night_start_hour=night_start_hour,
day_interval_minutes=day_interval_minutes,
night_interval_minutes=night_interval_minutes,
)
return RedirectResponse("/system", status_code=303)

View File

@@ -1,3 +1,5 @@
import hashlib
from datetime import timedelta
from pathlib import Path
from fastapi import APIRouter, Form, HTTPException, Request, status
@@ -6,15 +8,41 @@ from fastapi.templating import Jinja2Templates
from app.auth.admin import password_matches, require_admin
from app.auth.csrf import ensure_csrf_token, validate_csrf
from app.db.models import SyncUser
from app.db.repositories import UserRepository
from app.db.models import SyncUser, utcnow
from app.db.repositories import ActivityRepository, SyncRunRepository, UserRepository
from app.security.credentials import CredentialCipher
from app.web.forms import UserFormData
DASHBOARD_SUMMARY_WINDOW = timedelta(days=7)
CACHE_BUSTED_STATIC_FILES = ("style.css", "app.js", "htmx.min.js")
router = APIRouter()
templates = Jinja2Templates(directory=str(Path(__file__).resolve().parent / "templates"))
def _compute_static_version(static_dir: Path) -> str:
hasher = hashlib.sha256()
for name in CACHE_BUSTED_STATIC_FILES:
try:
hasher.update((static_dir / name).read_bytes())
except FileNotFoundError:
continue
return hasher.hexdigest()[:10]
templates.env.globals["static_version"] = _compute_static_version(
Path(__file__).resolve().parent / "static"
)
def _next_sync_tick(request: Request):
scheduler = getattr(request.app.state, "scheduler", None)
return getattr(scheduler, "next_tick", None)
templates.env.globals["next_sync_tick"] = _next_sync_tick
def _get_user_or_404(repository: UserRepository, user_id: int) -> SyncUser:
user = repository.get(user_id)
if user is None:
@@ -62,11 +90,13 @@ def login(
def dashboard(request: Request):
require_admin(request)
with request.app.state.session_factory() as session:
users = UserRepository(session).list_all()
repository = UserRepository(session)
rows = repository.dashboard_rows()
summary = repository.dashboard_summary(since=utcnow() - DASHBOARD_SUMMARY_WINDOW)
return templates.TemplateResponse(
request,
"dashboard.html",
{"users": users, "csrf_token": ensure_csrf_token(request)},
{"rows": rows, "summary": summary, "csrf_token": ensure_csrf_token(request)},
)
@@ -96,6 +126,8 @@ def create_user(
garmin_email: str = Form(""),
garmin_password: str = Form(""),
enabled: str | None = Form(None),
notify_email_enabled: str | None = Form(None),
notification_email: str = Form(""),
):
require_admin(request)
validate_csrf(request, csrf_token)
@@ -110,7 +142,11 @@ def create_user(
garmin_email=garmin_email,
garmin_password=garmin_password,
enabled=enabled is not None,
notify_email_enabled=notify_email_enabled is not None,
notification_email=notification_email,
)
if form.notify_email_enabled:
_require_non_empty(form.notification_email, "notification_email")
cipher = _cipher(request)
with request.app.state.session_factory() as session:
repository = UserRepository(session)
@@ -121,6 +157,8 @@ def create_user(
mywhoosh_password_enc=cipher.encrypt(form.mywhoosh_password),
garmin_email_enc=cipher.encrypt(form.garmin_email.strip()),
garmin_password_enc=cipher.encrypt(form.garmin_password),
notify_email_enabled=form.notify_email_enabled,
notification_email=form.notification_email.strip() or None,
)
user_id = user.id
return RedirectResponse(f"/users/{user_id}", status_code=303)
@@ -131,12 +169,16 @@ def user_detail(request: Request, user_id: int):
require_admin(request)
with request.app.state.session_factory() as session:
user = _get_user_or_404(UserRepository(session), user_id)
activities = ActivityRepository(session).list_pending_for_user(user_id)
recent_runs = SyncRunRepository(session).list_recent_for_user(user_id, limit=10)
return templates.TemplateResponse(
request,
"users/detail.html",
{
"csrf_token": ensure_csrf_token(request),
"user": user,
"activities": activities,
"recent_runs": recent_runs,
},
)
@@ -171,6 +213,8 @@ def update_user(
garmin_email: str = Form(""),
garmin_password: str = Form(""),
enabled: str | None = Form(None),
notify_email_enabled: str | None = Form(None),
notification_email: str = Form(""),
):
require_admin(request)
validate_csrf(request, csrf_token)
@@ -183,7 +227,11 @@ def update_user(
garmin_email=garmin_email,
garmin_password=garmin_password,
enabled=enabled is not None,
notify_email_enabled=notify_email_enabled is not None,
notification_email=notification_email,
)
if form.notify_email_enabled:
_require_non_empty(form.notification_email, "notification_email")
cipher = _cipher(request)
with request.app.state.session_factory() as session:
repository = UserRepository(session)
@@ -193,6 +241,8 @@ def update_user(
"enabled": form.enabled,
"mywhoosh_email_enc": cipher.encrypt(form.mywhoosh_email.strip()),
"garmin_email_enc": cipher.encrypt(form.garmin_email.strip()),
"notify_email_enabled": form.notify_email_enabled,
"notification_email": form.notification_email.strip() or None,
}
if form.mywhoosh_password:
values["mywhoosh_password_enc"] = cipher.encrypt(form.mywhoosh_password)

57
app/web/static/app.js Normal file
View File

@@ -0,0 +1,57 @@
function formatCountdown(remainingMs) {
if (remainingMs <= 0) {
return "due now";
}
const totalSeconds = Math.floor(remainingMs / 1000);
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
const pad = (n) => String(n).padStart(2, "0");
if (hours > 0) {
return `${pad(hours)}:${pad(minutes)}:${pad(seconds)}`;
}
return `${pad(minutes)}:${pad(seconds)}`;
}
function startSyncCountdown(el) {
const target = new Date(el.dataset.utc);
if (Number.isNaN(target.getTime())) {
return;
}
el.title = `${target.toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" })} · ${el.dataset.utc} UTC`;
let intervalId = null;
const tick = () => {
const remaining = target.getTime() - Date.now();
el.textContent = formatCountdown(remaining);
if (remaining <= 0 && intervalId !== null) {
clearInterval(intervalId);
}
};
tick();
if (target.getTime() - Date.now() > 0) {
intervalId = setInterval(tick, 1000);
}
}
document.addEventListener("DOMContentLoaded", () => {
document.querySelectorAll("time.next-sync[data-utc]").forEach(startSyncCountdown);
});
document.body.addEventListener("htmx:oobAfterSwap", (event) => {
if (event.detail.target.id !== "toast-container") {
return;
}
// event.detail.target is the *old* element htmx just swapped out (an
// outerHTML oob-swap detaches it), so look the live one up by id
// rather than trusting that reference.
const container = document.getElementById("toast-container");
const toast = container ? container.querySelector(".toast") : null;
if (!toast) {
return;
}
setTimeout(() => {
toast.classList.add("toast-leaving");
setTimeout(() => toast.remove(), 300);
}, 4000);
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 647 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

BIN
app/web/static/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 669 B

1
app/web/static/htmx.min.js vendored Normal file

File diff suppressed because one or more lines are too long

BIN
app/web/static/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

509
app/web/static/style.css Normal file
View File

@@ -0,0 +1,509 @@
:root {
--bg: #12151a;
--surface: #1a1f27;
--surface-raised: #232935;
--border: #2a3038;
--text: #e7eaf0;
--text-muted: #8b93a3;
--accent: #c8ff4d;
--danger: #ff5f6d;
--danger-bg: rgba(255, 95, 109, 0.16);
--success: #c8ff4d;
--success-bg: rgba(200, 255, 77, 0.16);
--warning: #ffb454;
--warning-bg: rgba(255, 180, 84, 0.16);
--neutral: #8b93a3;
--neutral-bg: rgba(139, 147, 163, 0.16);
--info: #5fd4ff;
--info-bg: rgba(95, 212, 255, 0.16);
--radius: 10px;
--shadow: none;
--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
background: var(--bg);
color: var(--text);
line-height: 1.5;
}
a {
color: var(--info);
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
a:focus-visible,
button:focus-visible,
.btn:focus-visible,
input:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.topbar {
background: var(--surface);
border-bottom: 1px solid var(--border);
padding: 0.9rem 1.5rem;
display: flex;
align-items: center;
justify-content: space-between;
}
.topbar .brand {
display: flex;
align-items: center;
gap: 0.6rem;
font-weight: 700;
font-size: 1.05rem;
color: var(--text);
}
.brand-logo {
display: block;
border-radius: 7px;
}
.topbar-right {
display: flex;
align-items: center;
gap: 1.25rem;
flex-wrap: wrap;
}
.topbar nav {
display: flex;
gap: 1.25rem;
font-size: 0.9rem;
}
.container {
max-width: 960px;
margin: 0 auto;
padding: 2rem 1.5rem 4rem;
}
h1, h2 {
text-transform: uppercase;
letter-spacing: 0.06em;
}
h1 {
font-size: 1.4rem;
margin: 0 0 1.25rem;
}
h2 {
font-size: 1rem;
margin: 2rem 0 0.75rem;
color: var(--text-muted);
}
.page-actions {
display: flex;
gap: 0.75rem;
align-items: center;
margin-bottom: 1.5rem;
flex-wrap: wrap;
}
.stat-tiles {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 0.75rem;
margin-bottom: 1.5rem;
}
.stat-tile {
display: flex;
flex-direction: column;
gap: 0.3rem;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 0.9rem 1.1rem;
}
.stat-tile-label {
color: var(--text-muted);
font-size: 0.7rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.stat-tile-value {
font-family: var(--mono);
font-variant-numeric: tabular-nums;
font-size: 1.6rem;
font-weight: 700;
color: var(--accent);
}
.stat-tile-sub {
color: var(--text-muted);
font-size: 0.78rem;
}
.toast-container {
position: fixed;
top: 1rem;
right: 1rem;
z-index: 100;
display: flex;
flex-direction: column;
gap: 0.5rem;
pointer-events: none;
}
.toast {
pointer-events: auto;
background: var(--surface-raised);
border: 1px solid var(--border);
border-left: 3px solid var(--text-muted);
border-radius: 8px;
padding: 0.6rem 0.9rem;
font-size: 0.85rem;
color: var(--text);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
max-width: 320px;
transition: opacity 200ms ease, transform 200ms ease;
}
.toast-success {
border-left-color: var(--success);
}
.toast-danger {
border-left-color: var(--danger);
}
.toast-info {
border-left-color: var(--info);
}
.toast-leaving {
opacity: 0;
transform: translateX(8px);
}
@media (prefers-reduced-motion: reduce) {
.toast {
transition: none;
}
}
button.htmx-request, .btn.htmx-request {
opacity: 0.6;
cursor: progress;
}
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 1.25rem 1.4rem;
margin-bottom: 1rem;
animation: card-in 150ms ease-out;
}
@keyframes card-in {
from {
opacity: 0;
transform: translateY(4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (prefers-reduced-motion: reduce) {
.card {
animation: none;
}
}
.user-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.user-card {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 0.75rem 1.25rem;
}
.user-card .user-main {
display: flex;
flex-direction: column;
gap: 0.3rem;
min-width: 220px;
}
.user-card .user-name {
font-weight: 600;
font-size: 1rem;
}
.user-card .user-meta {
color: var(--text-muted);
font-size: 0.85rem;
display: flex;
flex-wrap: wrap;
gap: 0.4rem 0.9rem;
}
.user-card .user-actions {
display: flex;
align-items: center;
gap: 0.6rem;
flex-wrap: wrap;
}
.badge {
display: inline-flex;
align-items: center;
padding: 0.2rem 0.6rem;
border-radius: 999px;
font-size: 0.75rem;
font-weight: 600;
letter-spacing: 0.01em;
white-space: nowrap;
}
.badge-healthy {
background: var(--success-bg);
color: var(--success);
}
.badge-syncing {
background: var(--info-bg);
color: var(--info);
}
.badge-degraded {
background: var(--warning-bg);
color: var(--warning);
}
.badge-action_required {
background: var(--danger-bg);
color: var(--danger);
}
.badge-disabled {
background: var(--neutral-bg);
color: var(--neutral);
}
.badge-success {
background: var(--success-bg);
color: var(--success);
}
.badge-partial {
background: var(--warning-bg);
color: var(--warning);
}
.badge-failed,
.badge-error {
background: var(--danger-bg);
color: var(--danger);
}
.badge-running {
background: var(--info-bg);
color: var(--info);
}
.action-required {
color: var(--danger);
font-size: 0.85rem;
}
button, .btn {
font: inherit;
background: var(--accent);
color: var(--bg);
border: none;
border-radius: 8px;
padding: 0.45rem 0.9rem;
cursor: pointer;
font-size: 0.85rem;
font-weight: 600;
}
button:hover, .btn:hover {
filter: brightness(0.88);
text-decoration: none;
}
button.secondary, .btn.secondary {
background: var(--surface);
color: var(--text);
border: 1px solid var(--border);
}
button.secondary:hover, .btn.secondary:hover {
background: var(--surface-raised);
filter: none;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 0.88rem;
}
table th, table td {
text-align: left;
padding: 0.55rem 0.7rem;
border-bottom: 1px solid var(--border);
}
table th {
color: var(--text-muted);
font-weight: 600;
font-size: 0.78rem;
text-transform: uppercase;
letter-spacing: 0.03em;
}
table td {
font-family: var(--mono);
font-variant-numeric: tabular-nums;
}
table tr:last-child td {
border-bottom: none;
}
dl.info-grid {
display: grid;
grid-template-columns: max-content 1fr;
gap: 0.5rem 1.5rem;
margin: 0;
}
dl.info-grid dt {
color: var(--text-muted);
font-size: 0.85rem;
}
dl.info-grid dd {
margin: 0;
font-family: var(--mono);
font-variant-numeric: tabular-nums;
}
.error {
background: var(--danger-bg);
color: var(--danger);
padding: 0.6rem 0.9rem;
border-radius: 8px;
font-size: 0.9rem;
}
.hint {
color: var(--text-muted);
font-size: 0.8rem;
margin: -0.4rem 0 0.6rem;
}
.summary-error {
color: var(--danger);
font-size: 0.82rem;
}
.empty-state {
color: var(--text-muted);
font-size: 0.9rem;
padding: 1rem 0;
}
form.stacked-form {
display: flex;
flex-direction: column;
gap: 0.35rem;
max-width: 420px;
}
form.stacked-form label {
font-size: 0.85rem;
font-weight: 500;
margin-top: 0.6rem;
}
form.stacked-form input[type="text"],
form.stacked-form input[type="password"],
form.stacked-form input[type="email"],
form.stacked-form input[type="number"] {
font: inherit;
color: var(--text);
padding: 0.5rem 0.65rem;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--surface);
}
form.stacked-form input[type="checkbox"] {
accent-color: var(--accent);
}
form.stacked-form button {
margin-top: 1rem;
align-self: flex-start;
}
.inline-form {
display: inline;
}
.sync-status {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.35rem 0.8rem;
border-radius: 999px;
background: var(--surface-raised);
border: 1px solid var(--border);
color: var(--text-muted);
font-size: 0.7rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
white-space: nowrap;
}
.sync-status .next-sync {
font-family: var(--mono);
font-variant-numeric: tabular-nums;
font-weight: 700;
font-size: 0.85rem;
letter-spacing: 0.02em;
color: var(--accent);
text-transform: none;
}
.sync-status .next-sync::before {
content: "\25b8 ";
}

View File

@@ -0,0 +1,69 @@
{% extends "base.html" %}
{% block title %}My Account - MyWhoosh Garmin Sync{% endblock %}
{% block content %}
<h1>{{ user.name }}</h1>
<div class="page-actions">
<a class="btn secondary" href="/account/edit">Edit</a>
<form method="post" action="/account/sync" class="inline-form"
hx-post="/account/sync" hx-target="#account-status" hx-swap="outerHTML" hx-disabled-elt="find button">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit">Sync now</button>
</form>
<form method="post" action="/account-logout" class="inline-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" class="secondary">Log out</button>
</form>
</div>
<div class="card">
{% include "fragments/account_status.html" %}
</div>
{% if user.action_reason == "mywhoosh_device_conflict" %}
<h2>MyWhoosh device conflict</h2>
<div class="card">
<p>MyWhoosh reports this account is already logged in on another device. Log out of MyWhoosh there (app or website), then retry the sync.</p>
</div>
{% endif %}
<h2>Recent sync runs</h2>
<div class="card">
{% if recent_runs %}
<table>
<thead>
<tr>
<th>Started</th>
<th>Finished</th>
<th>Status</th>
<th>Discovered</th>
<th>Imported</th>
<th>Skipped</th>
<th>Failed</th>
</tr>
</thead>
<tbody>
{% for run in recent_runs %}
<tr>
<td>{{ run.started_at }}</td>
<td>{{ run.finished_at or "-" }}</td>
<td><span class="badge badge-{{ run.status.value }}">{{ run.status.value }}</span></td>
<td>{{ run.discovered_count }}</td>
<td>{{ run.imported_count }}</td>
<td>{{ run.skipped_count }}</td>
<td>{{ run.failed_count }}</td>
</tr>
{% if run.summary_error %}
<tr>
<td colspan="7" class="summary-error">{{ run.summary_error }}</td>
</tr>
{% endif %}
{% endfor %}
</tbody>
</table>
{% else %}
<p class="empty-state">No sync runs yet.</p>
{% endif %}
</div>
{% endblock %}

View File

@@ -0,0 +1,39 @@
{% extends "base.html" %}
{% block title %}Edit My Account - MyWhoosh Garmin Sync{% endblock %}
{% block content %}
<h1>Edit My Account</h1>
<div class="card">
<form method="post" action="/account/edit" class="stacked-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label for="mywhoosh_email">MyWhoosh Email</label>
<input type="email" id="mywhoosh_email" name="mywhoosh_email" value="{{ mywhoosh_email }}" required>
<label for="mywhoosh_password">MyWhoosh Password</label>
<input type="password" id="mywhoosh_password" name="mywhoosh_password" autocomplete="new-password">
<p class="hint">Leave blank to keep the existing password.</p>
<label for="garmin_email">Garmin Email</label>
<input type="email" id="garmin_email" name="garmin_email" value="{{ garmin_email }}" required>
<label for="garmin_password">Garmin Password</label>
<input type="password" id="garmin_password" name="garmin_password" autocomplete="new-password">
<p class="hint">Leave blank to keep the existing password.</p>
<label for="notify_email_enabled">
<input type="checkbox" id="notify_email_enabled" name="notify_email_enabled"
{% if user.notify_email_enabled %}checked{% endif %}>
Email me when this account needs attention
</label>
<label for="notification_email">Notification email</label>
<input type="email" id="notification_email" name="notification_email"
value="{{ user.notification_email or '' }}">
<button type="submit">Save</button>
</form>
</div>
<p><a href="/account">Back to my account</a></p>
{% endblock %}

View File

@@ -0,0 +1,22 @@
{% extends "base.html" %}
{% block title %}Account Login - MyWhoosh Garmin Sync{% endblock %}
{% block content %}
<h1>Account Login</h1>
<div class="card">
{% if error %}
<p class="error">{{ error }}</p>
{% endif %}
<form method="post" action="/account-login" class="stacked-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label for="email">MyWhoosh or Garmin email</label>
<input type="email" id="email" name="email" required autofocus>
<label for="password">MyWhoosh or Garmin password</label>
<input type="password" id="password" name="password" required>
<button type="submit">Log in</button>
</form>
<p class="hint">Use the email and password for either your MyWhoosh or your Garmin account.</p>
</div>
<p><a href="/login">Admin login</a></p>
{% endblock %}

View File

@@ -2,9 +2,38 @@
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}MyWhoosh Garmin Sync{% endblock %}</title>
<link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/static/favicon-16.png">
<link rel="shortcut icon" href="/static/favicon.ico">
<link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png">
<link rel="stylesheet" href="/static/style.css?v={{ static_version }}">
<script src="/static/htmx.min.js?v={{ static_version }}" defer></script>
<script src="/static/app.js?v={{ static_version }}" defer></script>
</head>
<body>
{% block content %}{% endblock %}
<header class="topbar">
<span class="brand"><img src="/static/logo.png" alt="" class="brand-logo" width="28" height="28">MyWhoosh &rarr; Garmin Sync</span>
<div class="topbar-right">
<nav>
{% if request.session.get('admin_authenticated') %}
<a href="/">Dashboard</a>
<a href="/system">System</a>
{% endif %}
{% if request.session.get('self_service_user_id') %}
<a href="/account">My Account</a>
{% endif %}
</nav>
{% set next_tick = next_sync_tick(request) %}
{% if next_tick %}
<span class="sync-status">Next sync: <time class="next-sync" datetime="{{ next_tick.isoformat() }}" data-utc="{{ next_tick.isoformat() }}">{{ next_tick.strftime('%Y-%m-%d %H:%M UTC') }}</time></span>
{% endif %}
</div>
</header>
<main class="container">
{% block content %}{% endblock %}
</main>
<div id="toast-container" class="toast-container"></div>
</body>
</html>

View File

@@ -4,16 +4,37 @@
{% block content %}
<h1>Dashboard</h1>
<p><a href="/users/new">Add user</a></p>
<ul>
{% for user in users %}
<li>
<a href="/users/{{ user.id }}">{{ user.name }}</a>
&mdash; {{ "enabled" if user.enabled else "disabled" }}
&mdash; {{ user.health_state.value }}
</li>
<div class="stat-tiles">
<div class="stat-tile">
<span class="stat-tile-label">Riders</span>
<span class="stat-tile-value">{{ summary.rider_total }}</span>
<span class="stat-tile-sub">{{ summary.rider_enabled }} enabled</span>
</div>
<div class="stat-tile">
<span class="stat-tile-label">Imported &middot; 7d</span>
<span class="stat-tile-value">{{ summary.imported_recent }}</span>
</div>
<div class="stat-tile">
<span class="stat-tile-label">Success rate &middot; 7d</span>
<span class="stat-tile-value">{% if summary.success_rate_recent is not none %}{{ "%.0f"|format(summary.success_rate_recent) }}%{% else %}&ndash;{% endif %}</span>
</div>
<div class="stat-tile">
<span class="stat-tile-label">Action required</span>
<span class="stat-tile-value">{{ summary.action_required_count }}</span>
</div>
</div>
<div class="page-actions">
<a class="btn secondary" href="/users/new">Add user</a>
{% include "fragments/sync_all_form.html" %}
</div>
<ul class="user-list">
{% for row in rows %}
{% include "fragments/user_row.html" %}
{% else %}
<li>No users yet.</li>
<li class="card empty-state">No users yet.</li>
{% endfor %}
</ul>
{% endblock %}

View File

@@ -0,0 +1,13 @@
<dl class="info-grid" id="account-status">
<dt>Status</dt>
<dd><span class="badge badge-{{ user.health_state.value }}">{{ user.health_state.value.replace("_", " ") }}</span></dd>
<dt>MyWhoosh state</dt>
<dd>{{ user.mywhoosh_state }}</dd>
<dt>Garmin state</dt>
<dd>{{ user.garmin_state }}</dd>
<dt>Action reason</dt>
<dd>{{ user.action_reason or "-" }}</dd>
</dl>

View File

@@ -0,0 +1,6 @@
<form method="post" action="/users/{{ user.id }}/garmin-mfa" class="stacked-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label for="mfa_code">Garmin MFA code</label>
<input type="text" id="mfa_code" name="code" maxlength="20" required>
<button type="submit">Submit code</button>
</form>

View File

@@ -0,0 +1,5 @@
<form method="post" action="/sync-all" class="inline-form"
hx-post="/sync-all" hx-target="this" hx-swap="outerHTML" hx-disabled-elt="find button">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit">Sync all now</button>
</form>

View File

@@ -0,0 +1,45 @@
{% extends "base.html" %}
{% block title %}Sync result - MyWhoosh Garmin Sync{% endblock %}
{% block content %}
<h1>Sync result</h1>
<div class="page-actions">
<a class="btn secondary" href="/">Back to dashboard</a>
</div>
<div class="card">
<table>
<thead>
<tr>
<th>User</th>
<th>Status</th>
<th>Discovered</th>
<th>Imported</th>
<th>Skipped</th>
<th>Failed</th>
</tr>
</thead>
<tbody>
{% for outcome in outcomes %}
<tr>
<td>{{ outcome.user_id if outcome.user_id is not none else "unknown" }}</td>
<td><span class="badge badge-{{ outcome.status }}">{{ outcome.status }}</span></td>
<td>{{ outcome.discovered }}</td>
<td>{{ outcome.imported }}</td>
<td>{{ outcome.skipped }}</td>
<td>{{ outcome.failed }}</td>
</tr>
{% if outcome.message %}
<tr>
<td colspan="6" class="summary-error">{{ outcome.message }}</td>
</tr>
{% endif %}
{% else %}
<tr>
<td colspan="6" class="empty-state">No outcomes.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}

View File

@@ -0,0 +1,3 @@
<div id="toast-container" class="toast-container" hx-swap-oob="true">
<div class="toast toast-{{ level }}">{{ message }}</div>
</div>

View File

@@ -0,0 +1,24 @@
<li class="card user-card" id="user-row-{{ row.id }}"{% if oob %} hx-swap-oob="true"{% endif %}>
<div class="user-main">
<a class="user-name" href="/users/{{ row.id }}">{{ row.name }}</a>
<div class="user-meta">
<span class="badge badge-{{ row.health_state }}">{{ row.health_state.replace("_", " ") }}</span>
<span>{{ "enabled" if row.enabled else "disabled" }}</span>
<span>last sync: {{ row.last_sync_at or "-" }}</span>
<span>last activity: {{ row.last_activity_name or "-" }} ({{ row.last_activity_status or "-" }})</span>
</div>
{% if row.action_reason == "garmin_mfa_required" %}
<span class="action-required">Garmin MFA required &mdash; <a href="/users/{{ row.id }}">resolve</a></span>
{% elif row.action_reason == "mywhoosh_device_conflict" %}
<span class="action-required">MyWhoosh account logged in on another device &mdash; log out there, then <a href="/users/{{ row.id }}">retry</a></span>
{% endif %}
</div>
<div class="user-actions">
<form method="post" action="/users/{{ row.id }}/sync" class="inline-form"
hx-post="/users/{{ row.id }}/sync" hx-target="closest .user-card" hx-swap="outerHTML"
hx-disabled-elt="find button">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" class="secondary">Sync now</button>
</form>
</div>
</li>

View File

@@ -4,13 +4,16 @@
{% block content %}
<h1>Admin Login</h1>
{% if error %}
<p class="error">{{ error }}</p>
{% endif %}
<form method="post" action="/login">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label for="password">Password</label>
<input type="password" id="password" name="password" required autofocus>
<button type="submit">Log in</button>
</form>
<div class="card">
{% if error %}
<p class="error">{{ error }}</p>
{% endif %}
<form method="post" action="/login" class="stacked-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label for="password">Password</label>
<input type="password" id="password" name="password" required autofocus>
<button type="submit">Log in</button>
</form>
</div>
<p><a href="/account-login">Log in with your MyWhoosh or Garmin account instead</a></p>
{% endblock %}

View File

@@ -0,0 +1,82 @@
{% extends "base.html" %}
{% block title %}System - MyWhoosh Garmin Sync{% endblock %}
{% block content %}
<h1>System</h1>
<div class="card">
<dl class="info-grid">
<dt>Application version</dt>
<dd>{{ app_version }}</dd>
<dt>Last scheduler tick</dt>
<dd>{{ last_tick or "-" }}</dd>
<dt>Next scheduler tick</dt>
<dd>{{ next_tick or "-" }}</dd>
<dt>User count</dt>
<dd>{{ user_count }}</dd>
<dt>Activity count</dt>
<dd>{{ activity_count }}</dd>
</dl>
</div>
<form method="post" action="/sync-all" class="inline-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit">Sync all now</button>
</form>
<h2>Sync scheduling</h2>
<div class="card">
<form method="post" action="/system/scheduler-settings" class="stacked-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label for="day_start_hour">Day starts at (hour, 0-23)</label>
<input type="number" id="day_start_hour" name="day_start_hour" min="0" max="23"
value="{{ scheduler_settings.day_start_hour }}" required>
<label for="day_interval_minutes">Sync interval during the day (minutes)</label>
<input type="number" id="day_interval_minutes" name="day_interval_minutes" min="1"
value="{{ scheduler_settings.day_interval_minutes }}" required>
<label for="night_start_hour">Night starts at (hour, 0-23)</label>
<input type="number" id="night_start_hour" name="night_start_hour" min="0" max="23"
value="{{ scheduler_settings.night_start_hour }}" required>
<label for="night_interval_minutes">Sync interval during the night (minutes)</label>
<input type="number" id="night_interval_minutes" name="night_interval_minutes" min="1"
value="{{ scheduler_settings.night_interval_minutes }}" required>
<button type="submit">Save</button>
</form>
</div>
<h2>System log</h2>
<div class="card">
{% if log_entries %}
<table>
<thead>
<tr>
<th>Time</th>
<th>Source</th>
<th>Message</th>
</tr>
</thead>
<tbody>
{% for entry in log_entries %}
<tr>
<td>{{ entry.created_at }}</td>
<td>{{ entry.source }}</td>
<td>{{ entry.message }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="empty-state">No system log entries.</p>
{% endif %}
</div>
{% endblock %}

View File

@@ -4,28 +4,103 @@
{% block content %}
<h1>{{ user.name }}</h1>
<p><a href="/users/{{ user.id }}/edit">Edit</a> | <a href="/">Back to dashboard</a></p>
<div class="page-actions">
<a class="btn secondary" href="/users/{{ user.id }}/edit">Edit</a>
<a class="btn secondary" href="/">Back to dashboard</a>
</div>
<dl>
<dt>Enabled</dt>
<dd>{{ "Yes" if user.enabled else "No" }}</dd>
<div class="card">
<dl class="info-grid">
<dt>Status</dt>
<dd><span class="badge badge-{{ user.health_state.value }}">{{ user.health_state.value.replace("_", " ") }}</span></dd>
<dt>Health state</dt>
<dd>{{ user.health_state.value }}</dd>
<dt>Enabled</dt>
<dd>{{ "Yes" if user.enabled else "No" }}</dd>
<dt>MyWhoosh state</dt>
<dd>{{ user.mywhoosh_state }}</dd>
<dt>MyWhoosh state</dt>
<dd>{{ user.mywhoosh_state }}</dd>
<dt>Garmin state</dt>
<dd>{{ user.garmin_state }}</dd>
<dt>Garmin state</dt>
<dd>{{ user.garmin_state }}</dd>
<dt>Action reason</dt>
<dd>{{ user.action_reason or "-" }}</dd>
<dt>Action reason</dt>
<dd>{{ user.action_reason or "-" }}</dd>
<dt>Created at</dt>
<dd>{{ user.created_at }}</dd>
<dt>Created at</dt>
<dd>{{ user.created_at }}</dd>
<dt>Updated at</dt>
<dd>{{ user.updated_at }}</dd>
</dl>
<dt>Updated at</dt>
<dd>{{ user.updated_at }}</dd>
</dl>
</div>
{% if user.action_reason == "garmin_mfa_required" %}
<h2>Garmin MFA required</h2>
<div class="card">
{% include "fragments/mfa_form.html" %}
</div>
{% elif user.action_reason == "mywhoosh_device_conflict" %}
<h2>MyWhoosh device conflict</h2>
<div class="card">
<p>MyWhoosh reports this account is already logged in on another device. Log out of MyWhoosh there (app or website), then retry the sync.</p>
</div>
{% endif %}
<h2>Recent sync runs</h2>
<div class="card">
{% if recent_runs %}
<table>
<thead>
<tr>
<th>Started</th>
<th>Finished</th>
<th>Status</th>
<th>Discovered</th>
<th>Imported</th>
<th>Skipped</th>
<th>Failed</th>
</tr>
</thead>
<tbody>
{% for run in recent_runs %}
<tr>
<td>{{ run.started_at }}</td>
<td>{{ run.finished_at or "-" }}</td>
<td><span class="badge badge-{{ run.status.value }}">{{ run.status.value }}</span></td>
<td>{{ run.discovered_count }}</td>
<td>{{ run.imported_count }}</td>
<td>{{ run.skipped_count }}</td>
<td>{{ run.failed_count }}</td>
</tr>
{% if run.summary_error %}
<tr>
<td colspan="7" class="summary-error">{{ run.summary_error }}</td>
</tr>
{% endif %}
{% endfor %}
</tbody>
</table>
{% else %}
<p class="empty-state">No sync runs yet.</p>
{% endif %}
</div>
<h2>Activities</h2>
<div class="card">
<ul class="user-list">
{% for activity in activities %}
<li>
{{ activity.activity_name }} &mdash; {{ activity.status.value }}
{% if activity.status.value == "failed" and activity.retryable %}
<form method="post" action="/activities/{{ activity.id }}/retry" class="inline-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" class="secondary">Retry</button>
</form>
{% endif %}
</li>
{% else %}
<li class="empty-state">No pending activities.</li>
{% endfor %}
</ul>
</div>
{% endblock %}

View File

@@ -4,37 +4,49 @@
{% block content %}
<h1>{% if user %}Edit User{% else %}New User{% endif %}</h1>
<form method="post" action="{{ form_action }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<div class="card">
<form method="post" action="{{ form_action }}" class="stacked-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label for="name">Name</label>
<input type="text" id="name" name="name" value="{{ user.name if user else '' }}" required>
<label for="name">Name</label>
<input type="text" id="name" name="name" value="{{ user.name if user else '' }}" required>
<label for="mywhoosh_email">MyWhoosh Email</label>
<input type="email" id="mywhoosh_email" name="mywhoosh_email" value="{{ mywhoosh_email }}" required>
<label for="mywhoosh_email">MyWhoosh Email</label>
<input type="email" id="mywhoosh_email" name="mywhoosh_email" value="{{ mywhoosh_email }}" required>
<label for="mywhoosh_password">MyWhoosh Password</label>
<input type="password" id="mywhoosh_password" name="mywhoosh_password" autocomplete="new-password"
{% if not user %}required{% endif %}>
{% if user %}
<p class="hint">Leave blank to keep the existing password.</p>
{% endif %}
<label for="mywhoosh_password">MyWhoosh Password</label>
<input type="password" id="mywhoosh_password" name="mywhoosh_password" autocomplete="new-password"
{% if not user %}required{% endif %}>
{% if user %}
<p class="hint">Leave blank to keep the existing password.</p>
{% endif %}
<label for="garmin_email">Garmin Email</label>
<input type="email" id="garmin_email" name="garmin_email" value="{{ garmin_email }}" required>
<label for="garmin_email">Garmin Email</label>
<input type="email" id="garmin_email" name="garmin_email" value="{{ garmin_email }}" required>
<label for="garmin_password">Garmin Password</label>
<input type="password" id="garmin_password" name="garmin_password" autocomplete="new-password"
{% if not user %}required{% endif %}>
{% if user %}
<p class="hint">Leave blank to keep the existing password.</p>
{% endif %}
<label for="garmin_password">Garmin Password</label>
<input type="password" id="garmin_password" name="garmin_password" autocomplete="new-password"
{% if not user %}required{% endif %}>
{% if user %}
<p class="hint">Leave blank to keep the existing password.</p>
{% endif %}
<label for="enabled">
<input type="checkbox" id="enabled" name="enabled" {% if not user or user.enabled %}checked{% endif %}>
Enabled
</label>
<label for="enabled">
<input type="checkbox" id="enabled" name="enabled" {% if not user or user.enabled %}checked{% endif %}>
Enabled
</label>
<button type="submit">{% if user %}Save{% else %}Create{% endif %}</button>
</form>
<label for="notify_email_enabled">
<input type="checkbox" id="notify_email_enabled" name="notify_email_enabled"
{% if user and user.notify_email_enabled %}checked{% endif %}>
Email me when this account needs attention
</label>
<label for="notification_email">Notification email</label>
<input type="email" id="notification_email" name="notification_email"
value="{{ user.notification_email if user and user.notification_email else '' }}">
<button type="submit">{% if user %}Save{% else %}Create{% endif %}</button>
</form>
</div>
{% endblock %}

BIN
bike.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View File

@@ -0,0 +1,702 @@
# Live Sync Updates (HTMX) Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [x]`) syntax for tracking.
**Goal:** "Sync now" / "Sync all now" on the dashboard, and "Sync now" on the account page, update the affected rider row(s) or status block in place and show an auto-dismissing toast, instead of navigating to a separate result page.
**Architecture:** Vendor htmx (self-hosted) as the swap mechanism. Extract the dashboard row and the account status block into reusable Jinja partials so the same markup renders both the initial page and the post-sync response. Routes always return their own primary target's current state plus zero-or-more out-of-band updates plus exactly one out-of-band toast, so every swap is safe even on a no-op path.
**Tech Stack:** htmx v2.0.10 (vendored static file, no build step), plain CSS, a small addition to the existing vanilla `app.js`.
**Spec:** `docs/superpowers/specs/2026-08-16-live-sync-updates-design.md`
## Global Constraints
- No new Python dependencies; htmx is a single vendored static JS file (spec §3).
- `fragments/sync_result.html`, the activity retry route, and the Garmin MFA route are untouched — out of scope (spec §2).
- Every htmx POST route returns its own primary swap target's current state (never empty) plus exactly one toast; `/sync-all` additionally returns one OOB row per outcome with a resolvable `user_id` (spec §3).
- `POST /users/{id}/sync` and `POST /account/sync` return HTTP 200 for the "already running" case now (previously 409) — existing tests for that behavior must be updated to match, per spec §2.
- htmx's own swap/toast behavior has no meaningful Python-level test; it is verified manually via chrome-devtools, matching how the next-sync countdown was verified (spec §4).
---
### Task 1: `UserRepository.dashboard_row`
**Files:**
- Modify: `app/db/repositories.py`
- Test: `tests/db/test_repositories.py`
**Interfaces:**
- Produces: `UserRepository.dashboard_row(user_id: int) -> UserDashboardRow | None`, used by Task 4 and Task 5's routes.
- [x] **Step 1: Write the failing tests**
Add to `tests/db/test_repositories.py`:
```python
def test_dashboard_row_returns_row_for_known_user(user_repository) -> None:
user = _make_user(user_repository, "Alex")
row = user_repository.dashboard_row(user.id)
assert row is not None
assert row.id == user.id
assert row.name == "Alex"
def test_dashboard_row_returns_none_for_unknown_user(user_repository) -> None:
row = user_repository.dashboard_row(999)
assert row is None
```
- [x] **Step 2: Run to verify failure**
Run: `.venv/Scripts/python -m pytest tests/db/test_repositories.py -k dashboard_row -v`
Expected: both FAIL with `AttributeError: 'UserRepository' object has no attribute 'dashboard_row'`.
- [x] **Step 3: Extract the shared row builder and add `dashboard_row`**
In `app/db/repositories.py`, replace the body of `dashboard_rows` with a call to a new private helper, and add `dashboard_row`:
```python
def dashboard_rows(self) -> list[UserDashboardRow]:
return [self._build_dashboard_row(user) for user in self.list_all()]
def dashboard_row(self, user_id: int) -> UserDashboardRow | None:
user = self.get(user_id)
if user is None:
return None
return self._build_dashboard_row(user)
def _build_dashboard_row(self, user: SyncUser) -> UserDashboardRow:
last_run = self.session.scalar(
select(SyncRun).where(SyncRun.user_id == user.id).order_by(SyncRun.started_at.desc()).limit(1)
)
last_activity = self.session.scalar(
select(Activity).where(Activity.user_id == user.id).order_by(Activity.created_at.desc()).limit(1)
)
return UserDashboardRow(
id=user.id,
name=user.name,
enabled=user.enabled,
health_state=user.health_state.value,
action_reason=user.action_reason,
last_sync_at=last_run.finished_at if last_run else None,
last_activity_name=last_activity.activity_name if last_activity else None,
last_activity_status=last_activity.status.value if last_activity else None,
)
```
This is a pure refactor of the existing `dashboard_rows` loop body — behavior for `dashboard_rows()` itself must not change.
- [x] **Step 4: Run to verify pass**
Run: `.venv/Scripts/python -m pytest tests/db/test_repositories.py -v`
Expected: all tests pass, including the two new ones and the existing `dashboard_rows`-adjacent coverage (none currently exists directly, but nothing regresses).
- [x] **Step 5: Commit**
```bash
git add app/db/repositories.py tests/db/test_repositories.py
git commit -m "Add UserRepository.dashboard_row for single-row refresh"
```
---
### Task 2: Vendor htmx and wire up base.html + toast/loading CSS
**Files:**
- Create: `app/web/static/htmx.min.js` (vendored, v2.0.10)
- Modify: `app/web/templates/base.html`
- Modify: `app/web/static/style.css`
- Test: none (static asset + markup/CSS; full suite re-run at the end of this task to confirm no regressions)
**Interfaces:**
- Produces: the `#toast-container` element and `.toast`/`.toast-success`/`.toast-danger`/`.toast-info` classes that Task 3 and Task 5's `fragments/toast.html` renders into; the `.htmx-request` dimming rule.
- [x] **Step 1: Vendor htmx**
Download `https://unpkg.com/htmx.org@2.0.10/dist/htmx.min.js` and save it verbatim as `app/web/static/htmx.min.js` (already fetched once this session — reuse that content; if re-fetching, confirm the response is the same v2.0.10 minified build before saving).
- [x] **Step 2: Load htmx and add the toast container in `base.html`**
```html
<link rel="stylesheet" href="/static/style.css">
<script src="/static/htmx.min.js" defer></script>
<script src="/static/app.js" defer></script>
</head>
<body>
<header class="topbar">
```
(only the new `htmx.min.js` line is added — `app.js` stays second so htmx's global is present before app.js's own listeners are registered, though with `defer` both run in document order regardless of load timing)
And right before `</body>`:
```html
<div id="toast-container" class="toast-container"></div>
</body>
```
- [x] **Step 3: Add toast and htmx-request CSS to `style.css`**
```css
.toast-container {
position: fixed;
top: 1rem;
right: 1rem;
z-index: 100;
display: flex;
flex-direction: column;
gap: 0.5rem;
pointer-events: none;
}
.toast {
pointer-events: auto;
background: var(--surface-raised);
border: 1px solid var(--border);
border-left: 3px solid var(--text-muted);
border-radius: 8px;
padding: 0.6rem 0.9rem;
font-size: 0.85rem;
color: var(--text);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
max-width: 320px;
transition: opacity 200ms ease, transform 200ms ease;
}
.toast-success {
border-left-color: var(--success);
}
.toast-danger {
border-left-color: var(--danger);
}
.toast-info {
border-left-color: var(--info);
}
.toast-leaving {
opacity: 0;
transform: translateX(8px);
}
@media (prefers-reduced-motion: reduce) {
.toast {
transition: none;
}
}
button.htmx-request, .btn.htmx-request {
opacity: 0.6;
cursor: progress;
}
```
- [x] **Step 4: Run the full test suite**
Run: `.venv/Scripts/python -m pytest tests/ -q`
Expected: same pass count as the Task 1 baseline (no route/template behavior changed yet — only a new unused static file, an unreferenced-so-far toast container, and new CSS rules).
- [x] **Step 5: Commit**
```bash
git add app/web/static/htmx.min.js app/web/templates/base.html app/web/static/style.css
git commit -m "Vendor htmx and add toast/loading-state CSS"
```
---
### Task 3: Dashboard row and sync-all-form partials
**Files:**
- Create: `app/web/templates/fragments/user_row.html`
- Create: `app/web/templates/fragments/sync_all_form.html`
- Create: `app/web/templates/fragments/toast.html`
- Modify: `app/web/templates/dashboard.html`
- Test: `tests/web/test_dashboard_summary.py` (existing tests must keep passing — they assert on stat-tile markup, not row markup, but re-run to confirm)
**Interfaces:**
- Consumes: `UserDashboardRow` fields (spec §3), `csrf_token`.
- Produces: `fragments/user_row.html` renders one `<li id="user-row-<id>">`, accepting `row`, `csrf_token`, `oob` (default `False`) — Task 4's route renders this same template standalone. `fragments/toast.html` accepts `message`, `level` — Task 4 and Task 5 both render it standalone.
- [x] **Step 1: Create `fragments/user_row.html`**
```html
<li class="card user-card" id="user-row-{{ row.id }}"{% if oob %} hx-swap-oob="true"{% endif %}>
<div class="user-main">
<a class="user-name" href="/users/{{ row.id }}">{{ row.name }}</a>
<div class="user-meta">
<span class="badge badge-{{ row.health_state }}">{{ row.health_state.replace("_", " ") }}</span>
<span>{{ "enabled" if row.enabled else "disabled" }}</span>
<span>last sync: {{ row.last_sync_at or "-" }}</span>
<span>last activity: {{ row.last_activity_name or "-" }} ({{ row.last_activity_status or "-" }})</span>
</div>
{% if row.action_reason == "garmin_mfa_required" %}
<span class="action-required">Garmin MFA required &mdash; <a href="/users/{{ row.id }}">resolve</a></span>
{% elif row.action_reason == "mywhoosh_device_conflict" %}
<span class="action-required">MyWhoosh account logged in on another device &mdash; log out there, then <a href="/users/{{ row.id }}">retry</a></span>
{% endif %}
</div>
<div class="user-actions">
<form method="post" action="/users/{{ row.id }}/sync" class="inline-form"
hx-post="/users/{{ row.id }}/sync" hx-target="closest .user-card" hx-swap="outerHTML"
hx-disabled-elt="find button">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" class="secondary">Sync now</button>
</form>
</div>
</li>
```
- [x] **Step 2: Create `fragments/sync_all_form.html`**
```html
<form method="post" action="/sync-all" class="inline-form"
hx-post="/sync-all" hx-target="this" hx-swap="outerHTML" hx-disabled-elt="find button">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit">Sync all now</button>
</form>
```
- [x] **Step 3: Create `fragments/toast.html`**
```html
<div id="toast-container" hx-swap-oob="true">
<div class="toast toast-{{ level }}">{{ message }}</div>
</div>
```
- [x] **Step 4: Update `dashboard.html` to use the partials**
Replace the `<form ... action="/sync-all" ...>` block inside `.page-actions` with:
```html
{% include "fragments/sync_all_form.html" %}
```
Replace the `<li class="card user-card">...</li>` block inside the `{% for row in rows %}` loop with:
```html
{% include "fragments/user_row.html" %}
```
(the `{% else %}No users yet.{% endif %}` branch is unchanged)
- [x] **Step 5: Run the full test suite**
Run: `.venv/Scripts/python -m pytest tests/ -q`
Expected: same pass count as Task 2's baseline — this is a pure template refactor (the `{% include %}` inherits `row`/`csrf_token` from the enclosing loop/page context automatically), so no existing assertion on dashboard content should break. If `tests/web/test_dashboard_summary.py` or any dashboard test fails, stop and inspect — it means the include isn't inheriting context as expected.
- [x] **Step 6: Commit**
```bash
git add app/web/templates/fragments/user_row.html app/web/templates/fragments/sync_all_form.html app/web/templates/fragments/toast.html app/web/templates/dashboard.html
git commit -m "Extract dashboard row and sync-all form into reusable partials"
```
---
### Task 4: Live-update the dashboard sync routes
**Files:**
- Modify: `app/web/operations.py`
- Test: `tests/web/test_operations.py`
**Interfaces:**
- Consumes: `UserRepository.dashboard_row` (Task 1), `fragments/user_row.html` / `fragments/sync_all_form.html` / `fragments/toast.html` (Task 3).
- Produces: `_toast_html(message: str, level: str) -> str` and `_outcome_toast(outcome, label: str) -> tuple[str, str]`, imported by Task 5's `app/web/account.py`.
- [x] **Step 1: Write the failing tests**
Replace the existing `test_manual_sync_reports_already_running` in `tests/web/test_operations.py` (the 409 behavior is intentionally removed — see Global Constraints) and add new coverage:
```python
def test_manual_sync_updates_row_and_shows_toast(app, authenticated_client, fake_sync_manager) -> None:
with app.state.session_factory() as session:
from app.db.repositories import UserRepository
user = UserRepository(session).create(
name="Alex",
enabled=True,
mywhoosh_email_enc="mw",
mywhoosh_password_enc="mw-pw",
garmin_email_enc="g",
garmin_password_enc="g-pw",
)
user_id = user.id
response = authenticated_client.post(
f"/users/{user_id}/sync",
data={"csrf_token": authenticated_client.csrf_token},
)
assert response.status_code == 200
assert f'id="user-row-{user_id}"' in response.text
assert 'hx-swap-oob="true"' in response.text # the toast
assert "Alex" in response.text
assert "0 imported, 0 failed" in response.text
def test_manual_sync_reports_already_running_as_toast(authenticated_client, fake_sync_manager) -> None:
fake_sync_manager.raise_already_running = True
response = authenticated_client.post(
"/users/1/sync",
data={"csrf_token": authenticated_client.csrf_token},
)
assert response.status_code == 200
assert "already running" in response.text.lower()
def test_sync_all_updates_each_affected_row_and_shows_summary_toast(app, authenticated_client, fake_sync_manager) -> None:
from app.sync.states import SyncOutcome
with app.state.session_factory() as session:
from app.db.repositories import UserRepository
user = UserRepository(session).create(
name="Alex",
enabled=True,
mywhoosh_email_enc="mw",
mywhoosh_password_enc="mw-pw",
garmin_email_enc="g",
garmin_password_enc="g-pw",
)
user_id = user.id
async def fake_sync_all_enabled():
return [SyncOutcome(user_id=user_id, status="success", discovered=2, imported=2, skipped=0, failed=0)]
fake_sync_manager.sync_all_enabled = fake_sync_all_enabled
response = authenticated_client.post(
"/sync-all",
data={"csrf_token": authenticated_client.csrf_token},
)
assert response.status_code == 200
assert f'id="user-row-{user_id}"' in response.text
assert "Synced 1 riders" in response.text
def test_sync_all_shows_toast_when_nothing_to_sync(authenticated_client, fake_sync_manager) -> None:
response = authenticated_client.post(
"/sync-all",
data={"csrf_token": authenticated_client.csrf_token},
)
assert response.status_code == 200
assert "No riders to sync" in response.text
```
Remove the old `test_manual_sync_reports_already_running` test (it asserted `status_code == 409`, which this task intentionally changes).
- [x] **Step 2: Run to verify failure**
Run: `.venv/Scripts/python -m pytest tests/web/test_operations.py -v`
Expected: the four new/changed tests FAIL (old route still returns `fragments/sync_result.html` and a 409 for already-running); other existing tests in the file still pass unchanged.
- [x] **Step 3: Rewrite the routes**
In `app/web/operations.py`, add two module-level helpers near the top (after `_normalize_outcome`):
```python
def _toast_html(message: str, level: str) -> str:
return templates.get_template("fragments/toast.html").render(message=message, level=level)
def _outcome_toast(outcome, label: str) -> tuple[str, str]:
if outcome.status in ("success", "partial"):
return f"{label}: {outcome.imported} imported, {outcome.failed} failed", "success"
return f"{label}: sync failed — {outcome.message or 'unknown error'}", "danger"
```
Replace `manual_sync`:
```python
@router.post("/users/{user_id}/sync", response_class=HTMLResponse)
async def manual_sync(request: Request, user_id: int, csrf_token: str = Form(...)):
require_admin(request)
validate_csrf(request, csrf_token)
token = ensure_csrf_token(request)
try:
outcome = await request.app.state.sync_manager.sync_user(user_id)
with request.app.state.session_factory() as session:
row = UserRepository(session).dashboard_row(user_id)
label = row.name if row is not None else f"Rider #{user_id}"
message, level = _outcome_toast(outcome, label)
except SyncAlreadyRunning:
with request.app.state.session_factory() as session:
row = UserRepository(session).dashboard_row(user_id)
label = row.name if row is not None else f"Rider #{user_id}"
message, level = f"{label}: sync already running", "info"
row_html = templates.get_template("fragments/user_row.html").render(row=row, csrf_token=token, oob=False) if row else ""
return HTMLResponse(row_html + _toast_html(message, level))
```
Replace `manual_sync_all`:
```python
@router.post("/sync-all", response_class=HTMLResponse)
async def manual_sync_all(request: Request, csrf_token: str = Form(...)):
require_admin(request)
validate_csrf(request, csrf_token)
outcomes = await request.app.state.sync_manager.sync_all_enabled()
token = ensure_csrf_token(request)
parts = [templates.get_template("fragments/sync_all_form.html").render(csrf_token=token)]
ok = 0
failed = 0
with request.app.state.session_factory() as session:
repository = UserRepository(session)
for item in outcomes:
normalized = _normalize_outcome(item)
if normalized["status"] in ("success", "partial"):
ok += 1
else:
failed += 1
user_id = normalized["user_id"]
if user_id is not None:
row = repository.dashboard_row(user_id)
if row is not None:
parts.append(
templates.get_template("fragments/user_row.html").render(row=row, csrf_token=token, oob=True)
)
if not outcomes:
parts.append(_toast_html("No riders to sync", "info"))
else:
level = "success" if failed == 0 else "danger"
parts.append(_toast_html(f"Synced {len(outcomes)} riders — {ok} ok, {failed} failed", level))
return HTMLResponse("".join(parts))
```
Update the two remaining imports at the top of `app/web/operations.py`: `UserRepository` is already imported; `ensure_csrf_token` is already imported alongside `validate_csrf`.
- [x] **Step 4: Run to verify pass**
Run: `.venv/Scripts/python -m pytest tests/web/test_operations.py -v`
Expected: all pass.
- [x] **Step 5: Run the full test suite**
Run: `.venv/Scripts/python -m pytest tests/ -q`
Expected: same pass count as Task 3's baseline plus the net new tests in this task, minus the one removed 409 test (net +3 tests). No unrelated regressions.
- [x] **Step 6: Commit**
```bash
git add app/web/operations.py tests/web/test_operations.py
git commit -m "Live-update dashboard rows and show toasts after sync actions"
```
---
### Task 5: Live-update the account page sync route
**Files:**
- Create: `app/web/templates/fragments/account_status.html`
- Modify: `app/web/templates/account/detail.html`
- Modify: `app/web/account.py`
- Test: `tests/web/test_account_web.py`
**Interfaces:**
- Consumes: `_toast_html`, `_outcome_toast` (Task 4, imported from `app.web.operations`).
- Produces: nothing consumed by a later task.
- [x] **Step 1: Create `fragments/account_status.html`**
```html
<dl class="info-grid" id="account-status">
<dt>Status</dt>
<dd><span class="badge badge-{{ user.health_state.value }}">{{ user.health_state.value.replace("_", " ") }}</span></dd>
<dt>MyWhoosh state</dt>
<dd>{{ user.mywhoosh_state }}</dd>
<dt>Garmin state</dt>
<dd>{{ user.garmin_state }}</dd>
<dt>Action reason</dt>
<dd>{{ user.action_reason or "-" }}</dd>
</dl>
```
- [x] **Step 2: Update `account/detail.html`**
Replace the `<div class="card"><dl class="info-grid">...</dl></div>` block with:
```html
<div class="card">
{% include "fragments/account_status.html" %}
</div>
```
Replace the "Sync now" form in `.page-actions`:
```html
<form method="post" action="/account/sync" class="inline-form"
hx-post="/account/sync" hx-target="#account-status" hx-swap="outerHTML" hx-disabled-elt="find button">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit">Sync now</button>
</form>
```
- [x] **Step 3: Write the failing tests**
Update `test_account_sync_reports_already_running` in `tests/web/test_account_web.py`:
```python
def test_account_sync_reports_already_running_as_toast(app, client: TestClient, fake_sync_manager) -> None:
create_user_via_admin(client)
account_login(client, email="max@mywhoosh.example", password="mw-secret")
app.state.sync_manager = fake_sync_manager
fake_sync_manager.raise_already_running = True
page = client.get("/account")
csrf = extract_csrf(page.text)
response = client.post("/account/sync", data={"csrf_token": csrf})
assert response.status_code == 200
assert "already running" in response.text.lower()
```
Add:
```python
def test_account_sync_updates_status_block_and_shows_toast(app, client: TestClient, fake_sync_manager) -> None:
create_user_via_admin(client)
account_login(client, email="max@mywhoosh.example", password="mw-secret")
app.state.sync_manager = fake_sync_manager
page = client.get("/account")
csrf = extract_csrf(page.text)
response = client.post("/account/sync", data={"csrf_token": csrf})
assert response.status_code == 200
assert 'id="account-status"' in response.text
assert 'hx-swap-oob="true"' in response.text
assert "0 imported, 0 failed" in response.text
```
- [x] **Step 4: Run to verify failure**
Run: `.venv/Scripts/python -m pytest tests/web/test_account_web.py -v`
Expected: the new/changed tests FAIL (route still returns `fragments/sync_result.html` / 409).
- [x] **Step 5: Rewrite `account_sync` in `app/web/account.py`**
```python
from app.web.operations import _normalize_outcome, _outcome_toast, _toast_html
```
(add `_outcome_toast, _toast_html` to the existing import line from `app.web.operations`)
```python
@router.post("/account/sync", response_class=HTMLResponse)
async def account_sync(request: Request, csrf_token: str = Form(...)):
user_id = require_self_service(request)
validate_csrf(request, csrf_token)
try:
outcome = await request.app.state.sync_manager.sync_user(user_id)
with request.app.state.session_factory() as session:
user = UserRepository(session).get(user_id)
label = user.name if user is not None else "Account"
message, level = _outcome_toast(outcome, label)
except SyncAlreadyRunning:
with request.app.state.session_factory() as session:
user = UserRepository(session).get(user_id)
label = user.name if user is not None else "Account"
message, level = f"{label}: sync already running", "info"
status_html = (
templates.get_template("fragments/account_status.html").render(user=user) if user is not None else ""
)
return HTMLResponse(status_html + _toast_html(message, level))
```
- [x] **Step 6: Run to verify pass**
Run: `.venv/Scripts/python -m pytest tests/web/test_account_web.py -v`
Expected: all pass.
- [x] **Step 7: Run the full test suite**
Run: `.venv/Scripts/python -m pytest tests/ -q`
Expected: same pass count as Task 4's baseline plus this task's net new tests, no unrelated regressions.
- [x] **Step 8: Commit**
```bash
git add app/web/templates/fragments/account_status.html app/web/templates/account/detail.html app/web/account.py tests/web/test_account_web.py
git commit -m "Live-update the account page status block after sync"
```
---
### Task 6: Toast auto-dismiss and manual browser verification
**Files:**
- Modify: `app/web/static/app.js`
- Test: none (see Global Constraints — manual verification)
**Interfaces:**
- Consumes: the `#toast-container` element (Task 2) and the `hx-swap-oob` toast fragments (Task 4, Task 5) that htmx swaps into it, firing its `htmx:oobAfterSwap` event.
- [x] **Step 1: Add the auto-dismiss listener to `app.js`**
Append to `app/web/static/app.js` (after the existing `DOMContentLoaded` listener, as a new top-level statement):
```js
document.body.addEventListener("htmx:oobAfterSwap", (event) => {
if (event.detail.target.id !== "toast-container") {
return;
}
const toast = event.detail.target.querySelector(".toast");
if (!toast) {
return;
}
setTimeout(() => {
toast.classList.add("toast-leaving");
setTimeout(() => toast.remove(), 300);
}, 4000);
});
```
- [x] **Step 2: Manually verify in a real browser via chrome-devtools**
Start the app locally (same approach as prior manual verifications). Log in as admin, add a rider, then:
1. Click "Sync now" on the rider's row — confirm the row updates in place (no navigation, URL stays `/`), a toast appears top-right, and it fades out and disappears after ~4 seconds.
2. Click "Sync all now" — confirm the button re-renders, the row updates, and a summary toast appears.
3. Log in via `/account-login` as the same rider, click "Sync now" on the account page — confirm the status block updates in place and a toast appears.
4. Check the DevTools console (`list_console_messages`) for errors after each of the above.
5. Take a screenshot showing a toast visible on the dashboard.
Expected: no full-page navigation for any of the three actions, no console errors, toast appears and later disappears.
- [x] **Step 3: Commit**
```bash
git add app/web/static/app.js
git commit -m "Auto-dismiss sync toasts after a few seconds"
```
---
### Task 7: Final full-suite regression check
**Files:**
- None modified — verification only.
- [x] **Step 1: Run the full Python test suite**
Run: `.venv/Scripts/python -m pytest tests/ -q`
Expected: baseline pass count (from before this plan) plus this plan's net new/changed tests, with the same single pre-existing unrelated Windows file-permission failure (`test_tokenstore_round_trip_and_permissions`) and nothing else.
- [x] **Step 2: Report completion to the user**
Summarize what changed and point at the Task 6 Step 2 screenshot as evidence.

View File

@@ -0,0 +1,569 @@
# Visual Redesign ("Ride Computer" Dark Theme) Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [x]`) syntax for tracking.
**Goal:** Replace the app's light, generic look with a distinctive dark "bike computer cockpit" theme, and evolve the existing next-sync timestamp into a live ticking countdown, without changing any Python route, model, or template markup.
**Architecture:** A CSS design-token rewrite in `app/web/static/style.css` (color, typography, motion) that every existing template already picks up through shared classes (`.card`, `.badge`, `button`/`.btn`, `table`, `form.stacked-form`, `.sync-status`) — no template edits required. Separately, `app/web/static/app.js` gains a client-side countdown that ticks the already-rendered `time.next-sync[data-utc]` element down to zero, replacing the one-time UTC→local formatting it does today.
**Tech Stack:** Plain CSS custom properties, vanilla JS (no new dependencies, no build step — matches the existing project convention of zero frontend tooling).
**Spec:** `docs/superpowers/specs/2026-08-16-visual-redesign-design.md`
## Global Constraints
- No new dependencies, no build step — plain CSS and vanilla JS only (spec §2, §3).
- No route, model, or Python business-logic changes (spec §1).
- No template markup changes — every template already consumes the shared classes this plan restyles (spec §2, §6). `base.html`'s `.topbar-right` / `.sync-status` / `time.next-sync[data-utc]` structure from the prior next-sync feature is reused as-is.
- Dark theme only — no light-mode toggle (spec §2 "Out of scope").
- `data-utc` stays the server↔client contract; `tests/web/test_next_sync_display.py` must keep passing unmodified (spec §4 point 5).
- `base.html` needs **no edits** in this plan: the `.topbar-right` wrapper, the `.sync-status` span, and the `time.next-sync[data-utc]` element it needs already exist from the earlier next-sync feature. The spec's §6 rollout mentions `base.html` as a file touched by this work; in practice the existing markup already satisfies every hook Task 1's CSS and Task 2's JS need, so no template diff is required — this is a positive scope reduction, not a gap.
- CSS token value changes and the countdown's visual behavior have **no meaningful automated test** — the user explicitly chose manual/browser verification (via chrome-devtools screenshots) over introducing a JS test runner for this work. This is a deliberate, human-approved exception to normal TDD practice for these two tasks specifically; it does not extend to any future task that adds real branching logic without the user's sign-off.
---
### Task 1: Dark cockpit color, typography, and component tokens
**Files:**
- Modify: `app/web/static/style.css` (complete rewrite — every rule below)
- Test: none (pure CSS token values — see Global Constraints)
**Interfaces:**
- Consumes: nothing (leaf task, no code dependencies from other tasks).
- Produces: the `--bg`, `--surface`, `--surface-raised`, `--border`, `--text`, `--text-muted`, `--accent`, `--danger`, `--danger-bg`, `--success`, `--success-bg`, `--warning`, `--warning-bg`, `--neutral`, `--neutral-bg`, `--info`, `--info-bg`, `--radius`, `--shadow`, `--mono` custom properties and the `.sync-status` / `.sync-status .next-sync` selectors that Task 2's markup (already shipped in `base.html`) relies on for its visual presentation.
- [x] **Step 1: Replace `app/web/static/style.css` with the new token system and components**
Replace the entire file content with:
```css
:root {
--bg: #12151a;
--surface: #1a1f27;
--surface-raised: #232935;
--border: #2a3038;
--text: #e7eaf0;
--text-muted: #8b93a3;
--accent: #c8ff4d;
--danger: #ff5f6d;
--danger-bg: rgba(255, 95, 109, 0.16);
--success: #c8ff4d;
--success-bg: rgba(200, 255, 77, 0.16);
--warning: #ffb454;
--warning-bg: rgba(255, 180, 84, 0.16);
--neutral: #8b93a3;
--neutral-bg: rgba(139, 147, 163, 0.16);
--info: #5fd4ff;
--info-bg: rgba(95, 212, 255, 0.16);
--radius: 10px;
--shadow: none;
--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
background: var(--bg);
color: var(--text);
line-height: 1.5;
}
a {
color: var(--info);
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
a:focus-visible,
button:focus-visible,
.btn:focus-visible,
input:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.topbar {
background: var(--surface);
border-bottom: 1px solid var(--border);
padding: 0.9rem 1.5rem;
display: flex;
align-items: center;
justify-content: space-between;
}
.topbar .brand {
display: flex;
align-items: center;
gap: 0.6rem;
font-weight: 700;
font-size: 1.05rem;
color: var(--text);
}
.brand-logo {
display: block;
border-radius: 7px;
}
.topbar-right {
display: flex;
align-items: center;
gap: 1.25rem;
flex-wrap: wrap;
}
.topbar nav {
display: flex;
gap: 1.25rem;
font-size: 0.9rem;
}
.container {
max-width: 960px;
margin: 0 auto;
padding: 2rem 1.5rem 4rem;
}
h1, h2 {
text-transform: uppercase;
letter-spacing: 0.06em;
}
h1 {
font-size: 1.4rem;
margin: 0 0 1.25rem;
}
h2 {
font-size: 1rem;
margin: 2rem 0 0.75rem;
color: var(--text-muted);
}
.page-actions {
display: flex;
gap: 0.75rem;
align-items: center;
margin-bottom: 1.5rem;
flex-wrap: wrap;
}
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 1.25rem 1.4rem;
margin-bottom: 1rem;
animation: card-in 150ms ease-out;
}
@keyframes card-in {
from {
opacity: 0;
transform: translateY(4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (prefers-reduced-motion: reduce) {
.card {
animation: none;
}
}
.user-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.user-card {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 0.75rem 1.25rem;
}
.user-card .user-main {
display: flex;
flex-direction: column;
gap: 0.3rem;
min-width: 220px;
}
.user-card .user-name {
font-weight: 600;
font-size: 1rem;
}
.user-card .user-meta {
color: var(--text-muted);
font-size: 0.85rem;
display: flex;
flex-wrap: wrap;
gap: 0.4rem 0.9rem;
}
.user-card .user-actions {
display: flex;
align-items: center;
gap: 0.6rem;
flex-wrap: wrap;
}
.badge {
display: inline-flex;
align-items: center;
padding: 0.2rem 0.6rem;
border-radius: 999px;
font-size: 0.75rem;
font-weight: 600;
letter-spacing: 0.01em;
white-space: nowrap;
}
.badge-healthy {
background: var(--success-bg);
color: var(--success);
}
.badge-syncing {
background: var(--info-bg);
color: var(--info);
}
.badge-degraded {
background: var(--warning-bg);
color: var(--warning);
}
.badge-action_required {
background: var(--danger-bg);
color: var(--danger);
}
.badge-disabled {
background: var(--neutral-bg);
color: var(--neutral);
}
.badge-success {
background: var(--success-bg);
color: var(--success);
}
.badge-partial {
background: var(--warning-bg);
color: var(--warning);
}
.badge-failed,
.badge-error {
background: var(--danger-bg);
color: var(--danger);
}
.badge-running {
background: var(--info-bg);
color: var(--info);
}
.action-required {
color: var(--danger);
font-size: 0.85rem;
}
button, .btn {
font: inherit;
background: var(--accent);
color: var(--bg);
border: none;
border-radius: 8px;
padding: 0.45rem 0.9rem;
cursor: pointer;
font-size: 0.85rem;
font-weight: 600;
}
button:hover, .btn:hover {
filter: brightness(0.88);
text-decoration: none;
}
button.secondary, .btn.secondary {
background: var(--surface);
color: var(--text);
border: 1px solid var(--border);
}
button.secondary:hover, .btn.secondary:hover {
background: var(--surface-raised);
filter: none;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 0.88rem;
}
table th, table td {
text-align: left;
padding: 0.55rem 0.7rem;
border-bottom: 1px solid var(--border);
}
table th {
color: var(--text-muted);
font-weight: 600;
font-size: 0.78rem;
text-transform: uppercase;
letter-spacing: 0.03em;
}
table td {
font-family: var(--mono);
font-variant-numeric: tabular-nums;
}
table tr:last-child td {
border-bottom: none;
}
dl.info-grid {
display: grid;
grid-template-columns: max-content 1fr;
gap: 0.5rem 1.5rem;
margin: 0;
}
dl.info-grid dt {
color: var(--text-muted);
font-size: 0.85rem;
}
dl.info-grid dd {
margin: 0;
font-family: var(--mono);
font-variant-numeric: tabular-nums;
}
.error {
background: var(--danger-bg);
color: var(--danger);
padding: 0.6rem 0.9rem;
border-radius: 8px;
font-size: 0.9rem;
}
.hint {
color: var(--text-muted);
font-size: 0.8rem;
margin: -0.4rem 0 0.6rem;
}
.summary-error {
color: var(--danger);
font-size: 0.82rem;
}
.empty-state {
color: var(--text-muted);
font-size: 0.9rem;
padding: 1rem 0;
}
form.stacked-form {
display: flex;
flex-direction: column;
gap: 0.35rem;
max-width: 420px;
}
form.stacked-form label {
font-size: 0.85rem;
font-weight: 500;
margin-top: 0.6rem;
}
form.stacked-form input[type="text"],
form.stacked-form input[type="password"],
form.stacked-form input[type="email"] {
font: inherit;
color: var(--text);
padding: 0.5rem 0.65rem;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--surface);
}
form.stacked-form input[type="checkbox"] {
accent-color: var(--accent);
}
form.stacked-form button {
margin-top: 1rem;
align-self: flex-start;
}
.inline-form {
display: inline;
}
.sync-status {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.35rem 0.8rem;
border-radius: 999px;
background: var(--surface-raised);
border: 1px solid var(--border);
color: var(--text-muted);
font-size: 0.7rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
white-space: nowrap;
}
.sync-status .next-sync {
font-family: var(--mono);
font-variant-numeric: tabular-nums;
font-weight: 700;
font-size: 0.85rem;
letter-spacing: 0.02em;
color: var(--accent);
text-transform: none;
}
.sync-status .next-sync::before {
content: "\25b8 ";
}
```
Notes on deliberate deviations from a literal reading of the spec, decided during implementation for consistency and to avoid template edits:
- `table td` and `dl.info-grid dd` get monospace/tabular-nums globally (not just "stats" cells) since every table and info-grid in this app is already timestamp/count/state data, and templates aren't being touched to add per-cell classes. Badges inherit this too (they sit inside `td`), which reads like an instrument-panel status readout rather than a problem.
- `--primary`/`--primary-hover` are removed (replaced by `--info` for links and `--accent` for buttons) since nothing in the templates references them directly (verified via grep — no inline `style="var(--...)"` usage anywhere).
- Button hover uses `filter: brightness(0.88)` instead of a second hardcoded accent hex, keeping the palette to the named tokens in the spec.
- [x] **Step 2: Run the full test suite to confirm no regressions**
Run: `.venv/Scripts/python -m pytest tests/ -q`
Expected: same pass count as before this change (202 passed; the pre-existing unrelated `tests/mywhoosh/test_tokenstore.py::test_tokenstore_round_trip_and_permissions` failure on Windows is expected and untouched by this task).
- [x] **Step 3: Commit**
```bash
git add app/web/static/style.css
git commit -m "Redesign UI with dark cockpit color and typography tokens"
```
---
### Task 2: Live-ticking sync countdown
**Files:**
- Modify: `app/web/static/app.js` (complete rewrite)
- Test: none (see Global Constraints — user chose manual verification over introducing a JS test runner)
**Interfaces:**
- Consumes: the `time.next-sync[data-utc="<ISO-8601 instant>"]` element already rendered by `base.html` (from the previously shipped next-sync feature) and the `.sync-status` / `.sync-status .next-sync` CSS from Task 1.
- Produces: nothing consumed by a later task — this is the last code task.
- [x] **Step 1: Replace `app/web/static/app.js` with the countdown implementation**
```js
function formatCountdown(remainingMs) {
if (remainingMs <= 0) {
return "due now";
}
const totalSeconds = Math.floor(remainingMs / 1000);
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
const pad = (n) => String(n).padStart(2, "0");
if (hours > 0) {
return `${pad(hours)}:${pad(minutes)}:${pad(seconds)}`;
}
return `${pad(minutes)}:${pad(seconds)}`;
}
function startSyncCountdown(el) {
const target = new Date(el.dataset.utc);
if (Number.isNaN(target.getTime())) {
return;
}
el.title = `${target.toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" })} · ${el.dataset.utc} UTC`;
let intervalId = null;
const tick = () => {
const remaining = target.getTime() - Date.now();
el.textContent = formatCountdown(remaining);
if (remaining <= 0 && intervalId !== null) {
clearInterval(intervalId);
}
};
tick();
if (target.getTime() - Date.now() > 0) {
intervalId = setInterval(tick, 1000);
}
}
document.addEventListener("DOMContentLoaded", () => {
document.querySelectorAll("time.next-sync[data-utc]").forEach(startSyncCountdown);
});
```
Why `intervalId` is declared with `let` before `tick` runs once synchronously: the first `tick()` call happens before `setInterval` returns, so if the target is already due on page load, `tick` must not call `clearInterval` on a not-yet-assigned `const` — that would throw a `ReferenceError`. Declaring `intervalId` as `let intervalId = null` up front and only scheduling the interval at all when the target is still in the future avoids the bug entirely (an already-due countdown just renders "due now" once and never starts ticking).
- [x] **Step 2: Manually verify in a real browser via chrome-devtools**
Start the app locally (same approach as the next-sync feature verification: temp SQLite DB, a valid `CREDENTIAL_ENCRYPTION_KEY` from `Fernet.generate_key()`, `ADMIN_PASSWORD`/`SECRET_KEY` set, `uvicorn app.main:create_app --factory`), then:
1. Navigate to `/login` and confirm the countdown ticks down every second (e.g. `04:58``04:57`).
2. Navigate to `/` (dashboard, after admin login) and confirm the same ticking countdown appears there too.
3. Edge case: temporarily set the fake/real scheduler's `next_tick` to a timestamp in the past (or wait past it) and reload — confirm the element shows `due now` instead of a negative or malformed countdown, and confirm no JS error appears in the DevTools console (`list_console_messages`).
4. Take a screenshot of the dashboard and the login page to visually confirm Task 1's dark theme and Task 2's countdown render together correctly.
Expected: countdown ticks live, "due now" renders cleanly for an already-past target, no console errors, screenshots show the dark cockpit theme with the lime countdown readout in the topbar.
- [x] **Step 3: Commit**
```bash
git add app/web/static/app.js
git commit -m "Turn the next-sync indicator into a live ticking countdown"
```
---
### Task 3: Final full-suite regression check
**Files:**
- None modified — verification only.
**Interfaces:**
- Consumes: the completed state of Task 1 and Task 2.
- Produces: nothing (terminal task).
- [x] **Step 1: Run the full Python test suite**
Run: `.venv/Scripts/python -m pytest tests/ -q`
Expected: 202 passed, 1 pre-existing unrelated failure (`test_tokenstore_round_trip_and_permissions`, a Windows file-permission-bits issue predating this plan) — identical to the baseline recorded in Task 1 Step 2.
- [x] **Step 2: Report completion to the user**
Summarize what changed (dark cockpit theme across every page via shared CSS classes, live-ticking sync countdown) and point at the two screenshots taken in Task 2 Step 2 as evidence.

View File

@@ -0,0 +1,187 @@
# Live Sync Updates (HTMX) — Design
Date: 2026-08-16
Status: Draft for user review
## 1. Goal
Replace the full-page navigation that currently happens after clicking
"Sync now" / "Sync all now" with an in-place update: the affected rider
row(s) refresh with their new status, and a short toast reports the
outcome — without leaving the dashboard or account page.
## 2. Scope
### In scope
- Vendoring htmx (v2.0.10, self-hosted, no CDN) as the swap mechanism.
- Dashboard: per-rider "Sync now" and "Sync all now".
- Account page (self-service): "Sync now".
- A toast notification system (one at a time, auto-dismissing) built on
htmx out-of-band swaps.
- Removing the HTTP 409 special case for "sync already running" — it
becomes a normal toast instead of a distinct error page/status.
### Out of scope (unchanged in this pass)
- Activity retry button (`/activities/{id}/retry`) — still navigates to
the old `fragments/sync_result.html` page.
- Garmin MFA form (`/users/{id}/garmin-mfa`) — still navigates to the old
page; MFA failure often needs a fresh code anyway, so the extra step is
less costly there.
- Live-updating the "Recent sync runs" table on the account/user detail
pages — a completed sync's new row only appears after the next full
page load.
- Any change to `SyncManager`, `SyncOutcome`, or scheduler behavior.
## 3. Architecture
### htmx
`app/web/static/htmx.min.js` (vendored, v2.0.10) is loaded in `base.html`
via `<script src="/static/htmx.min.js" defer></script>`, alongside the
existing `app.js`.
### The "always return current state" rule
Every htmx-driven POST route in scope returns two things in one response
body:
1. The current, freshly-reloaded state of its own primary swap target
(even on a no-op path like "sync already running", or on the
`/sync-all` form itself, which always re-renders unchanged). This
makes every swap safe/idempotent — the target is never replaced with
nothing.
2. Exactly one out-of-band toast fragment (`fragments/toast.html`,
`hx-swap-oob="true"` on `#toast-container`) describing what happened.
`/sync-all` additionally emits one out-of-band row update
(`fragments/user_row.html` rendered with `oob=True`) per rider whose
outcome carries a known `user_id` — riders unaffected by that run (e.g.
disabled) are left alone.
### Shared row partial
`app/web/templates/fragments/user_row.html` renders one
`<li class="card user-card" id="user-row-{{ row.id }}">...</li>`, taking
`row` (a `UserDashboardRow`), `csrf_token`, and `oob` (default `False`,
adds `hx-swap-oob="true"` to the root element when `True`). `dashboard.html`
`{% include %}`s it once per row in its existing loop (`oob` omitted,
defaults to `False`) instead of inlining the `<li>` markup — this is the
only change to the existing loop, so the initial page render is
byte-for-byte equivalent to today's markup plus the new `hx-*` attributes
on the row and its form.
### Shared account status partial
`app/web/templates/fragments/account_status.html` renders the
`<dl class="info-grid" id="account-status">...</dl>` block (Status,
MyWhoosh state, Garmin state, Action reason) that today lives inline in
`account/detail.html`. Same include pattern.
### New repository method
`UserRepository.dashboard_row(user_id: int) -> UserDashboardRow | None`
in `app/db/repositories.py` — the existing `dashboard_rows()` loop body is
extracted into a private `_build_dashboard_row(user: SyncUser) -> UserDashboardRow`
helper that both `dashboard_rows()` and the new `dashboard_row(user_id)`
call, so there is exactly one place that assembles a row.
### Route changes
`app/web/operations.py`:
- `manual_sync` (`POST /users/{user_id}/sync`): on success, on
`SyncAlreadyRunning`, and on any other outcome, always ends by opening a
fresh session, calling `UserRepository(session).dashboard_row(user_id)`,
and rendering `fragments/user_row.html` (`oob=False`, since this row IS
the primary `hx-target`) followed by a toast whose message/level depend
on the outcome. Always returns HTTP 200 now (no more 409).
- `manual_sync_all` (`POST /sync-all`): re-renders the trigering `<form>`
unchanged as the primary swap content (`fragments/sync_all_form.html`,
a two-line partial holding just that form), then one
`fragments/user_row.html` (`oob=True`) per outcome with a resolvable
`user_id`, then one summary toast, e.g. `"Synced 3 riders — 2 ok, 1
failed"` or `"No riders to sync"` when the outcome list is empty.
`app/web/account.py`:
- `account_sync` (`POST /account/sync`): same "always return current
state + toast" shape, but the primary target is
`fragments/account_status.html` re-rendered from the freshly reloaded
`SyncUser`, not a row.
### Toast levels and copy
| Situation | Level | Message |
|---|---|---|
| `status in (success, partial)` | success | `"<name>: <imported> imported, <failed> failed"` |
| `status == failed` | danger | `"<name>: sync failed — <message or 'unknown error'>"` |
| `SyncAlreadyRunning` caught | info | `"<name>: sync already running"` |
| Exception (unexpected) | danger | `"<name>: sync error — <message>"` |
| `/sync-all` summary | success if all ok else danger | `"Synced <n> riders — <ok> ok, <failed> failed"` |
| `/sync-all` with zero enabled riders | info | `"No riders to sync"` |
`fragments/toast.html` takes `message: str` and `level: Literal["success",
"danger", "info"]`, rendering:
```html
<div id="toast-container" hx-swap-oob="true">
<div class="toast toast-{{ level }}">{{ message }}</div>
</div>
```
`base.html` gets an empty `<div id="toast-container" class="toast-container"></div>`
right before `</body>` so the very first toast has something to swap.
### Auto-dismiss
`app/web/static/app.js` gains an `htmx:oobAfterSwap` listener: when the
swapped element's id is `toast-container`, it schedules the `.toast`
child's removal after 4 seconds via a CSS class (`toast-leaving`, an
opacity/transform transition) added 300ms before the actual `remove()`
call, so it fades rather than disappearing instantly.
`prefers-reduced-motion: reduce` disables the CSS transition (the toast
still disappears at the same 4-second mark, just without animating).
### CSS
New `.toast-container` (fixed, top-right, stacked via flex column though
only one toast exists at a time), `.toast`, `.toast-success`,
`.toast-danger`, `.toast-info` rules using the existing color tokens
(`--success`/`--danger`/`--info` text on `--surface-raised` background,
consistent with the existing badge treatment). Existing
`button.htmx-request` / `.btn.htmx-request` rule dims the control
(`opacity: 0.6`) while a request is in flight — htmx adds/removes this
class automatically, no JS needed.
## 4. Testing
- `UserRepository.dashboard_row` — unit tests mirroring `dashboard_rows()`
coverage (found user returns expected fields, unknown id returns
`None`) in `tests/db/test_repositories.py`.
- Route-level tests (`tests/web/test_operations.py`,
`tests/web/test_account_web.py` or a new
`tests/web/test_live_sync_updates.py`) using the existing `TestClient` +
`fake_sync_manager` fixture, asserting on the returned HTML: the row's
`id="user-row-<id>"` element is present with updated fields, a
`hx-swap-oob="true"` toast div is present with the expected message
class, `/sync-all` emits one OOB row per outcome, and the
already-running path returns HTTP 200 (not 409) with an info toast.
- No htmx JS itself is unit-testable from Python; the actual in-browser
swap behavior (row updates without navigation, toast appears and
disappears) is verified manually via chrome-devtools, the same way the
next-sync countdown was verified.
## 5. Rollout
Files touched: `app/web/static/htmx.min.js` (new, vendored),
`app/web/static/app.js`, `app/web/static/style.css`, `app/web/templates/base.html`,
`app/web/templates/dashboard.html`, `app/web/templates/account/detail.html`,
`app/web/templates/fragments/user_row.html` (new),
`app/web/templates/fragments/account_status.html` (new),
`app/web/templates/fragments/toast.html` (new),
`app/web/templates/fragments/sync_all_form.html` (new),
`app/db/repositories.py`, `app/web/operations.py`, `app/web/account.py`.
`fragments/sync_result.html` is untouched (still used by retry/MFA,
out of scope).

View File

@@ -0,0 +1,157 @@
# Visual Redesign — "Ride Computer" Dark Theme — Design
Date: 2026-08-16
Status: Draft for user review
## 1. Goal
Replace the current light, generic admin-template look with a distinctive dark
"bike computer cockpit" theme grounded in the app's own subject matter (indoor
cycling sync, Garmin telemetry). The redesign is a shared foundation: it must
land before the planned dashboard summary tiles and live-update UX polish, so
those can be built directly in the new visual language instead of needing a
second pass.
This spec covers only the visual system and the already-shipped next-sync
indicator's evolution into a live countdown. It does not add new pages, new
data, or new business logic.
## 2. Scope
### In scope
- New CSS design-token system (color, spacing reuse, borders) replacing the
current light theme in `app/web/static/style.css`.
- Cockpit-style topbar (dark, brand mark, nav, live sync countdown).
- Restyled shared components: cards, buttons, badges, tables, forms, empty
states — applied globally via shared CSS classes so every existing template
(login, account-login, dashboard, account detail/edit, users
detail/new/edit, system, sync result fragment) picks it up without
structural rewrites.
- Typographic treatment: uppercase tracked labels for headings/section labels
(extends the existing table-header pattern), tabular monospace numerals for
all stats/timestamps/counters.
- Evolving the existing static next-sync timestamp (`app/web/static/app.js`,
`base.html`) into a live, client-ticking countdown.
- Accessible focus states (visible lime outline) and `prefers-reduced-motion`
handling for the one animated element (the countdown) and card entrance.
### Out of scope (separate follow-up specs)
- Dashboard summary/stat tiles (next phase, builds on this theme).
- Live sync results without full page reload (UX-polish phase).
- Any light-mode / theme-toggle support (explicitly rejected by user — dark
only).
- New charts/statistics views.
- Any change to routes, models, or sync/business logic.
## 3. Design tokens
### Color
| Token | Hex | Usage |
|---|---|---|
| `--bg` | `#12151A` | Page background |
| `--surface` | `#1A1F27` | Cards, topbar, table zebra-free rows |
| `--surface-raised` | `#232935` | Hover states, inputs |
| `--border` | `#2A3038` | Hairline dividers/card borders |
| `--text` | `#E7EAF0` | Primary text |
| `--text-muted` | `#8B93A3` | Secondary text, labels, hints |
| `--accent` | `#C8FF4D` | Electric lime — signature countdown, primary buttons, focus rings, "healthy" status |
Semantic status colors (each with a ~14% opacity tint of the same hue over
`--surface` for badge backgrounds, matching the existing `--*-bg` variable
pattern already in `style.css`):
| State | Hex | Meaning |
|---|---|---|
| Success / healthy | `--accent` `#C8FF4D` | Reuses the signature accent — a healthy sync *is* the good state the accent celebrates |
| Warning / degraded | `#FFB454` | Amber |
| Danger / action required / failed | `#FF5F6D` | Coral |
| Info / syncing / running | `#5FD4FF` | Cyan |
| Neutral / disabled | `#5A6472` | Slate |
These map 1:1 onto the existing `--success`, `--warning`, `--danger`,
`--info`, `--neutral` variable names already used by `.badge-*` classes in
`style.css` — only their values change, not the class structure, so templates
need no edits for badges.
### Typography
- Body/UI face: unchanged system stack (`-apple-system, BlinkMacSystemFont,
"Segoe UI", Roboto, Helvetica, Arial, sans-serif`) — no new font loads,
works offline in a self-hosted container.
- Monospace face for all numeric data (stats, timestamps, the countdown,
table numeric columns): `ui-monospace, SFMono-Regular, Menlo, Consolas,
"Liberation Mono", monospace`, with `font-variant-numeric: tabular-nums`.
- Headings and structural labels (`h1`/`h2`, `.badge`, table `th`, the new
topbar labels): uppercase, `letter-spacing: 0.06em`, extending the tracked
uppercase style `table th` already has today — applied consistently instead
of only in tables.
### Layout
- Topbar becomes the cockpit header: `--surface` background, hairline bottom
border, brand mark left, nav + live countdown grouped right (existing
`.topbar-right` wrapper from the next-sync work is reused).
- Cards: hairline `--border` outline, `--surface` background, no drop shadow
(shadows read poorly on dark; hairlines carry the "device bezel" feel
instead). Radius stays at the existing `--radius: 10px`, unchanged.
- Buttons: primary uses `--accent` background with dark text (for contrast
against the light lime); secondary keeps outline/ghost style against
`--surface`.
- Focus-visible: 2px `--accent` outline on all interactive elements (links,
buttons, inputs) — dark backgrounds need this to stay accessible since the
current subtle browser default focus ring is hard to see on `--surface`.
### Motion
- The live countdown ticks once per second (text content update only, no
layout shift).
- Cards fade/slide in ~150ms on initial page load, `translateY(4px) → 0`.
- Both respect `prefers-reduced-motion: reduce` (countdown text still updates,
since it's informational, not decorative; the card entrance animation is
skipped entirely).
## 4. Signature element: live sync countdown
`app/web/static/app.js` currently does a one-time UTC→local conversion of the
`time[data-utc]` element on `DOMContentLoaded`. It's extended to:
1. On load, read `data-utc` as the target instant.
2. If the target is in the future, start a `setInterval` (1s) that computes
the remaining duration and renders it as `HH:MM:SS` (or `MM:SS` under an
hour) in monospace, e.g. `NEXT SYNC ▸ 00:12:04`.
3. If the target is in the past (page left open past the sync time, or
`next_tick` not yet known on first boot), render `due now` instead of a
negative countdown.
4. The `title` attribute keeps showing the absolute local time (via
`toLocaleString()`) and the original UTC instant, so hovering still gives
an absolute reference — this preserves today's behavior as a fallback/aid.
5. `data-utc` stays the templating contract between server and client (same
attribute the current tests assert on), so no server-side route or test
changes are needed for this evolution — only `app.js` behavior and the
surrounding CSS/markup in `base.html` change.
No server-side change: `next_sync_tick()` in `app/web/routes.py` and the
`base.html` template variable wiring stay as they are; only the visual
presentation and `app.js` ticking logic change.
## 5. Testing
This is a CSS/JS-presentation change with one markup adjustment (countdown
wrapper element/label in `base.html`). Existing server-rendered tests assert
on `data-utc="..."` substrings and text content, not on CSS classes or exact
visual output, so no test breakage is expected. No new automated test is
meaningful for pure CSS token values; the existing
`tests/web/test_next_sync_display.py` continues to guard the server-side
contract (the attribute and value), and manual verification (screenshot) is
used to confirm the visual outcome, consistent with how the next-sync feature
was verified.
## 6. Rollout
Single pass across `style.css`, `base.html`, `app.js`. No template
restructuring needed beyond `base.html`'s topbar, since all other templates
already consume the shared `.card`, `.badge`, `button`/`.btn`, `table`, and
`form.stacked-form` classes this spec restyles centrally.

View File

@@ -16,7 +16,7 @@ dependencies = [
"python-multipart>=0.0.9,<1",
"itsdangerous>=2.1,<3",
"httpx>=0.27,<1",
"garminconnect>=0.2,<1",
"garminconnect>=0.3.10,<1",
]
[project.optional-dependencies]

108
tests/auth/test_account.py Normal file
View File

@@ -0,0 +1,108 @@
from cryptography.fernet import Fernet
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
from app.auth.account import authenticate_self_service
from app.db.models import Base
from app.db.repositories import UserRepository
from app.security.credentials import CredentialCipher
def _make_session_and_cipher():
engine = create_engine("sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool)
Base.metadata.create_all(engine)
factory = sessionmaker(bind=engine, expire_on_commit=False)
cipher = CredentialCipher(Fernet.generate_key().decode("ascii"))
return factory(), cipher
def _seed_user(session, cipher, **overrides):
values = dict(
name="Max",
enabled=True,
mywhoosh_email_enc=cipher.encrypt("max@mywhoosh.example"),
mywhoosh_password_enc=cipher.encrypt("mw-secret"),
garmin_email_enc=cipher.encrypt("max@garmin.example"),
garmin_password_enc=cipher.encrypt("garmin-secret"),
)
values.update(overrides)
return UserRepository(session).create(**values)
def test_authenticates_with_mywhoosh_credentials() -> None:
session, cipher = _make_session_and_cipher()
user = _seed_user(session, cipher)
result = authenticate_self_service(session, cipher, "max@mywhoosh.example", "mw-secret")
assert result is not None
assert result.id == user.id
def test_authenticates_with_garmin_credentials() -> None:
session, cipher = _make_session_and_cipher()
user = _seed_user(session, cipher)
result = authenticate_self_service(session, cipher, "max@garmin.example", "garmin-secret")
assert result is not None
assert result.id == user.id
def test_rejects_wrong_password() -> None:
session, cipher = _make_session_and_cipher()
_seed_user(session, cipher)
assert authenticate_self_service(session, cipher, "max@mywhoosh.example", "wrong") is None
def test_rejects_unknown_email() -> None:
session, cipher = _make_session_and_cipher()
_seed_user(session, cipher)
assert authenticate_self_service(session, cipher, "nobody@example.com", "mw-secret") is None
def test_rejects_mixed_email_and_password_from_different_accounts() -> None:
"""A MyWhoosh email paired with the Garmin password (or vice versa) for
the same user must not authenticate -- each pair is checked together."""
session, cipher = _make_session_and_cipher()
_seed_user(session, cipher)
assert authenticate_self_service(session, cipher, "max@mywhoosh.example", "garmin-secret") is None
assert authenticate_self_service(session, cipher, "max@garmin.example", "mw-secret") is None
def test_rejects_empty_password() -> None:
session, cipher = _make_session_and_cipher()
_seed_user(session, cipher)
assert authenticate_self_service(session, cipher, "max@mywhoosh.example", "") is None
def test_picks_correct_user_among_several() -> None:
session, cipher = _make_session_and_cipher()
_seed_user(
session,
cipher,
name="Anna",
mywhoosh_email_enc=cipher.encrypt("anna@mywhoosh.example"),
mywhoosh_password_enc=cipher.encrypt("anna-secret"),
garmin_email_enc=cipher.encrypt("anna@garmin.example"),
garmin_password_enc=cipher.encrypt("anna-garmin-secret"),
)
bob = _seed_user(
session,
cipher,
name="Bob",
mywhoosh_email_enc=cipher.encrypt("bob@mywhoosh.example"),
mywhoosh_password_enc=cipher.encrypt("bob-secret"),
garmin_email_enc=cipher.encrypt("bob@garmin.example"),
garmin_password_enc=cipher.encrypt("bob-garmin-secret"),
)
result = authenticate_self_service(session, cipher, "bob@mywhoosh.example", "bob-secret")
assert result is not None
assert result.id == bob.id

View File

@@ -8,8 +8,14 @@ from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.pool import StaticPool
from app.config import Settings
from app.db.models import Base
from app.db.repositories import ActivityRepository, UserRepository
from app.db.models import Activity, Base, HealthState
from app.db.repositories import (
ActivityRepository,
SchedulerSettingsRepository,
SyncRunRepository,
SystemLogRepository,
UserRepository,
)
from app.main import create_app
@@ -40,7 +46,22 @@ def activity_repository(db_session: Session) -> ActivityRepository:
@pytest.fixture
def client(tmp_path: Path) -> TestClient:
def sync_run_repository(db_session: Session) -> SyncRunRepository:
return SyncRunRepository(db_session)
@pytest.fixture
def scheduler_settings_repository(db_session: Session) -> SchedulerSettingsRepository:
return SchedulerSettingsRepository(db_session)
@pytest.fixture
def system_log_repository(db_session: Session) -> SystemLogRepository:
return SystemLogRepository(db_session)
@pytest.fixture
def app(tmp_path: Path):
settings = Settings(
ADMIN_PASSWORD="admin-secret",
SECRET_KEY="0123456789abcdef0123456789abcdef",
@@ -49,8 +70,80 @@ def client(tmp_path: Path) -> TestClient:
DATABASE_URL=f"sqlite:///{tmp_path / 'app.db'}",
SYNC_INTERVAL_MINUTES=5,
)
app = create_app(settings)
application = create_app(settings)
try:
yield TestClient(app)
yield application
finally:
app.state.db_engine.dispose()
application.state.db_engine.dispose()
@pytest.fixture
def client(app) -> TestClient:
return TestClient(app)
def _extract_csrf(html: str) -> str:
marker = 'name="csrf_token" value="'
start = html.index(marker) + len(marker)
end = html.index('"', start)
return html[start:end]
class FakeSyncManager:
def __init__(self) -> None:
self.user_calls: list[int] = []
self.all_calls = 0
self.raise_already_running = False
self.mfa_calls: list[tuple[int, str]] = []
async def sync_user(self, user_id: int, mfa_code: str | None = None):
if self.raise_already_running:
from app.sync.manager import SyncAlreadyRunning
raise SyncAlreadyRunning(f"sync already running for user {user_id}")
self.user_calls.append(user_id)
if mfa_code is not None:
self.mfa_calls.append((user_id, mfa_code))
from app.sync.states import SyncOutcome
return SyncOutcome(user_id=user_id, status="success", discovered=0, imported=0, skipped=0, failed=0)
async def sync_all_enabled(self):
self.all_calls += 1
return []
@pytest.fixture
def fake_sync_manager() -> FakeSyncManager:
return FakeSyncManager()
@pytest.fixture
def authenticated_client(app, client: TestClient, fake_sync_manager: FakeSyncManager) -> TestClient:
page = client.get("/login")
csrf = _extract_csrf(page.text)
response = client.post("/login", data={"password": "admin-secret", "csrf_token": csrf}, follow_redirects=False)
assert response.status_code == 303
app.state.sync_manager = fake_sync_manager
client.csrf_token = csrf
return client
@pytest.fixture
def seeded_activity(db_session: Session, user_repository: UserRepository, activity_repository: ActivityRepository) -> Activity:
user = user_repository.create(
name="Test User",
enabled=True,
health_state=HealthState.HEALTHY,
mywhoosh_email_enc="test@example.com",
mywhoosh_password_enc="password",
garmin_email_enc="test@garmin.com",
garmin_password_enc="garmin_password",
)
activity, _ = activity_repository.get_or_create_discovered(
user_id=user.id,
mywhoosh_activity_id="mw-test-123",
activity_name="Test Activity",
activity_timestamp=None,
)
return activity

View File

@@ -1,4 +1,6 @@
from app.db.models import ActivityStatus, HealthState
from datetime import datetime, timedelta, timezone
from app.db.models import ActivityStatus, HealthState, SyncRunStatus
def test_create_two_independent_users(db_session, user_repository) -> None:
@@ -64,3 +66,160 @@ def test_activity_external_id_is_unique_per_user(user_repository, activity_repos
assert inserted_again is False
assert created.id == same.id
assert same.status == ActivityStatus.DISCOVERED
def test_system_log_lists_most_recent_first(system_log_repository, user_repository) -> None:
user = user_repository.create(
name="Max",
enabled=True,
health_state=HealthState.HEALTHY,
mywhoosh_email_enc="mw-1",
mywhoosh_password_enc="mw-pw-1",
garmin_email_enc="g-1",
garmin_password_enc="g-pw-1",
)
system_log_repository.add(source="email_notification", message="first failure", user_id=user.id)
system_log_repository.add(source="email_notification", message="second failure", user_id=user.id)
entries = system_log_repository.list_recent()
assert [entry.message for entry in entries] == ["second failure", "first failure"]
assert entries[0].source == "email_notification"
assert entries[0].user_id == user.id
def test_system_log_respects_limit(system_log_repository) -> None:
for i in range(5):
system_log_repository.add(source="test", message=f"entry {i}")
entries = system_log_repository.list_recent(limit=2)
assert len(entries) == 2
def test_scheduler_settings_get_or_create_seeds_defaults(scheduler_settings_repository) -> None:
row = scheduler_settings_repository.get_or_create(default_minutes=7)
assert row.day_interval_minutes == 7
assert row.night_interval_minutes == 7
assert row.day_start_hour == 6
assert row.night_start_hour == 22
def test_scheduler_settings_get_or_create_is_idempotent_after_update(scheduler_settings_repository) -> None:
row = scheduler_settings_repository.get_or_create(default_minutes=5)
scheduler_settings_repository.update(row, day_interval_minutes=15)
reloaded = scheduler_settings_repository.get_or_create(default_minutes=5)
assert reloaded.day_interval_minutes == 15
def test_scheduler_settings_update_persists_all_fields(scheduler_settings_repository) -> None:
row = scheduler_settings_repository.get_or_create(default_minutes=5)
scheduler_settings_repository.update(
row,
day_start_hour=8,
night_start_hour=20,
day_interval_minutes=10,
night_interval_minutes=45,
)
reloaded = scheduler_settings_repository.get_or_create(default_minutes=5)
assert reloaded.day_start_hour == 8
assert reloaded.night_start_hour == 20
assert reloaded.day_interval_minutes == 10
assert reloaded.night_interval_minutes == 45
def _make_user(repo, name, *, enabled=True, health_state=HealthState.HEALTHY):
return repo.create(
name=name,
enabled=enabled,
health_state=health_state,
mywhoosh_email_enc=f"mw-{name}",
mywhoosh_password_enc=f"mw-pw-{name}",
garmin_email_enc=f"g-{name}",
garmin_password_enc=f"g-pw-{name}",
)
def test_dashboard_summary_counts_riders_total_and_enabled(user_repository) -> None:
_make_user(user_repository, "Alex", enabled=True)
_make_user(user_repository, "Jamie", enabled=True)
_make_user(user_repository, "Paused", enabled=False)
summary = user_repository.dashboard_summary(since=datetime.now(timezone.utc) - timedelta(days=7))
assert summary.rider_total == 3
assert summary.rider_enabled == 2
def test_dashboard_summary_counts_action_required_riders(user_repository) -> None:
_make_user(user_repository, "Healthy", health_state=HealthState.HEALTHY)
_make_user(user_repository, "Blocked", health_state=HealthState.ACTION_REQUIRED)
_make_user(user_repository, "AlsoBlocked", health_state=HealthState.ACTION_REQUIRED)
summary = user_repository.dashboard_summary(since=datetime.now(timezone.utc) - timedelta(days=7))
assert summary.action_required_count == 2
def test_dashboard_summary_sums_imported_count_within_window_only(user_repository, sync_run_repository) -> None:
user = _make_user(user_repository, "Alex")
now = datetime.now(timezone.utc)
recent_run = sync_run_repository.start(user.id)
recent_run.started_at = now - timedelta(days=1)
sync_run_repository.finish(
recent_run.id, status=SyncRunStatus.SUCCESS, discovered=5, imported=5, skipped=0, failed=0
)
old_run = sync_run_repository.start(user.id)
old_run.started_at = now - timedelta(days=30)
sync_run_repository.finish(
old_run.id, status=SyncRunStatus.SUCCESS, discovered=3, imported=3, skipped=0, failed=0
)
summary = user_repository.dashboard_summary(since=now - timedelta(days=7))
assert summary.imported_recent == 5
def test_dashboard_summary_computes_success_rate_from_finished_runs_in_window(
user_repository, sync_run_repository
) -> None:
user = _make_user(user_repository, "Alex")
now = datetime.now(timezone.utc)
for status in (SyncRunStatus.SUCCESS, SyncRunStatus.PARTIAL, SyncRunStatus.FAILED, SyncRunStatus.FAILED):
run = sync_run_repository.start(user.id)
run.started_at = now - timedelta(hours=1)
sync_run_repository.finish(run.id, status=status, discovered=1, imported=0, skipped=0, failed=1)
summary = user_repository.dashboard_summary(since=now - timedelta(days=7))
assert summary.success_rate_recent == 50.0
def test_dashboard_summary_success_rate_is_none_without_finished_runs_in_window(user_repository) -> None:
summary = user_repository.dashboard_summary(since=datetime.now(timezone.utc) - timedelta(days=7))
assert summary.success_rate_recent is None
def test_dashboard_row_returns_row_for_known_user(user_repository) -> None:
user = _make_user(user_repository, "Alex")
row = user_repository.dashboard_row(user.id)
assert row is not None
assert row.id == user.id
assert row.name == "Alex"
def test_dashboard_row_returns_none_for_unknown_user(user_repository) -> None:
row = user_repository.dashboard_row(999)
assert row is None

109
tests/db/test_session.py Normal file
View File

@@ -0,0 +1,109 @@
from sqlalchemy import create_engine, inspect, text
from sqlalchemy.pool import StaticPool
from app.db.models import Base
from app.db.session import initialize_schema
def test_initialize_schema_creates_fresh_database() -> None:
engine = create_engine("sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool)
try:
initialize_schema(engine)
columns = {col["name"] for col in inspect(engine).get_columns("sync_users")}
assert "notify_email_enabled" in columns
assert "notification_email" in columns
finally:
engine.dispose()
def test_initialize_schema_adds_missing_columns_without_dropping_existing_rows() -> None:
"""Regression test: a database created before notify_email_enabled/
notification_email existed must gain those columns in place, keeping
every already-stored user row intact -- create_all() alone would not add
columns to a table that already exists."""
engine = create_engine("sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool)
try:
# Simulate the pre-existing production schema by creating every
# table via the current models, then dropping the two new columns
# back off sync_users the only way sqlite allows: rebuild the table.
Base.metadata.create_all(engine)
with engine.begin() as conn:
conn.execute(text("ALTER TABLE sync_users RENAME TO sync_users_old"))
conn.execute(
text(
"""
CREATE TABLE sync_users (
id INTEGER PRIMARY KEY,
name VARCHAR(120) NOT NULL,
enabled BOOLEAN NOT NULL,
health_state VARCHAR NOT NULL,
mywhoosh_state VARCHAR(32) NOT NULL,
garmin_state VARCHAR(32) NOT NULL,
action_reason TEXT,
mywhoosh_email_enc TEXT NOT NULL,
mywhoosh_password_enc TEXT NOT NULL,
garmin_email_enc TEXT NOT NULL,
garmin_password_enc TEXT NOT NULL,
created_at DATETIME,
updated_at DATETIME
)
"""
)
)
conn.execute(
text(
"""
INSERT INTO sync_users (
id, name, enabled, health_state, mywhoosh_state, garmin_state,
action_reason, mywhoosh_email_enc, mywhoosh_password_enc,
garmin_email_enc, garmin_password_enc, created_at, updated_at
)
SELECT id, name, enabled, health_state, mywhoosh_state, garmin_state,
action_reason, mywhoosh_email_enc, mywhoosh_password_enc,
garmin_email_enc, garmin_password_enc, created_at, updated_at
FROM sync_users_old
"""
)
)
conn.execute(text("DROP TABLE sync_users_old"))
conn.execute(
text(
"""
INSERT INTO sync_users (
id, name, enabled, health_state, mywhoosh_state, garmin_state,
mywhoosh_email_enc, mywhoosh_password_enc, garmin_email_enc, garmin_password_enc
) VALUES (
1, 'Existing User', 1, 'healthy', 'connected', 'connected',
'enc-mw-email', 'enc-mw-pass', 'enc-garmin-email', 'enc-garmin-pass'
)
"""
)
)
columns_before = {col["name"] for col in inspect(engine).get_columns("sync_users")}
assert "notify_email_enabled" not in columns_before
initialize_schema(engine)
columns_after = {col["name"] for col in inspect(engine).get_columns("sync_users")}
assert "notify_email_enabled" in columns_after
assert "notification_email" in columns_after
with engine.connect() as conn:
row = conn.execute(text("SELECT name, notify_email_enabled, notification_email FROM sync_users")).one()
assert row.name == "Existing User"
assert row.notify_email_enabled == 0
assert row.notification_email is None
finally:
engine.dispose()
def test_initialize_schema_is_idempotent() -> None:
engine = create_engine("sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool)
try:
initialize_schema(engine)
initialize_schema(engine)
columns = {col["name"] for col in inspect(engine).get_columns("sync_users")}
assert "notify_email_enabled" in columns
finally:
engine.dispose()

132
tests/db/test_sync_state.py Normal file
View File

@@ -0,0 +1,132 @@
from app.db.models import ActivityStatus, SyncRunStatus
def test_failure_retains_last_completed_stage(activity_repository, seeded_activity) -> None:
activity_repository.mark_downloaded(seeded_activity.id, "/data/activities/1/a/source.fit")
activity_repository.mark_failed(seeded_activity.id, "Garmin timeout", retryable=True)
activity = activity_repository.get(seeded_activity.id)
assert activity.status == ActivityStatus.FAILED
assert activity.last_completed_stage == ActivityStatus.DOWNLOADED
assert activity.retryable is True
def test_converted_activity_is_pending_until_terminal(activity_repository, seeded_activity) -> None:
activity_repository.mark_converted(seeded_activity.id, "/data/activities/1/a/converted.fit")
ids = [item.id for item in activity_repository.list_pending_for_user(seeded_activity.user_id)]
assert seeded_activity.id in ids
def test_list_pending_excludes_imported(activity_repository, seeded_activity) -> None:
activity_repository.mark_downloaded(seeded_activity.id, "/data/activities/1/a/source.fit")
activity_repository.mark_converted(seeded_activity.id, "/data/activities/1/a/converted.fit")
activity_repository.mark_imported(seeded_activity.id, "garmin-123")
ids = [item.id for item in activity_repository.list_pending_for_user(seeded_activity.user_id)]
assert seeded_activity.id not in ids
def test_list_pending_excludes_duplicate(activity_repository, seeded_activity) -> None:
activity_repository.mark_downloaded(seeded_activity.id, "/data/activities/1/a/source.fit")
activity_repository.mark_duplicate(seeded_activity.id)
ids = [item.id for item in activity_repository.list_pending_for_user(seeded_activity.user_id)]
assert seeded_activity.id not in ids
def test_list_pending_excludes_non_retryable_failed(activity_repository, seeded_activity) -> None:
activity_repository.mark_downloaded(seeded_activity.id, "/data/activities/1/a/source.fit")
activity_repository.mark_failed(seeded_activity.id, "Cannot retry", retryable=False)
ids = [item.id for item in activity_repository.list_pending_for_user(seeded_activity.user_id)]
assert seeded_activity.id not in ids
def test_list_pending_includes_retryable_failed(activity_repository, seeded_activity) -> None:
activity_repository.mark_downloaded(seeded_activity.id, "/data/activities/1/a/source.fit")
activity_repository.mark_failed(seeded_activity.id, "Temporary error", retryable=True)
ids = [item.id for item in activity_repository.list_pending_for_user(seeded_activity.user_id)]
assert seeded_activity.id in ids
def test_sync_run_start_and_finish(sync_run_repository, user_repository) -> None:
user = user_repository.create(
name="Test User",
enabled=True,
health_state="healthy",
mywhoosh_email_enc="test@example.com",
mywhoosh_password_enc="password",
garmin_email_enc="test@garmin.com",
garmin_password_enc="garmin_password",
)
sync_run = sync_run_repository.start(user.id)
assert sync_run.status == SyncRunStatus.RUNNING
assert sync_run.discovered_count == 0
assert sync_run.imported_count == 0
assert sync_run.skipped_count == 0
assert sync_run.failed_count == 0
finished = sync_run_repository.finish(
sync_run.id,
status=SyncRunStatus.SUCCESS,
discovered=5,
imported=3,
skipped=1,
failed=1,
)
assert finished.status == SyncRunStatus.SUCCESS
assert finished.discovered_count == 5
assert finished.imported_count == 3
assert finished.skipped_count == 1
assert finished.failed_count == 1
assert finished.finished_at is not None
# Reload from DB to verify persisted
reloaded = sync_run_repository.get(sync_run.id)
assert reloaded.status == SyncRunStatus.SUCCESS
assert reloaded.discovered_count == 5
assert reloaded.imported_count == 3
def test_sync_run_finish_with_error(sync_run_repository, user_repository) -> None:
user = user_repository.create(
name="Test User",
enabled=True,
health_state="healthy",
mywhoosh_email_enc="test@example.com",
mywhoosh_password_enc="password",
garmin_email_enc="test@garmin.com",
garmin_password_enc="garmin_password",
)
sync_run = sync_run_repository.start(user.id)
error_msg = "Connection timeout"
finished = sync_run_repository.finish(
sync_run.id,
status=SyncRunStatus.FAILED,
discovered=0,
imported=0,
skipped=0,
failed=0,
summary_error=error_msg,
)
assert finished.summary_error == error_msg
# Verify truncation works
long_error = "x" * 5000
finished_long = sync_run_repository.finish(
sync_run.id,
status=SyncRunStatus.FAILED,
discovered=0,
imported=0,
skipped=0,
failed=0,
summary_error=long_error,
)
assert len(finished_long.summary_error) == 2000
assert finished_long.summary_error == long_error[:2000]

View File

@@ -2,7 +2,13 @@ from pathlib import Path
import pytest
from app.garmin.uploader import GarminUploadBlocked, GarminUploader
from app.garmin.uploader import (
GarminAuthError,
GarminImportRejected,
GarminTransientError,
GarminUploadBlocked,
GarminUploader,
)
class FakeGarmin:
@@ -78,3 +84,93 @@ def test_mfa_code_is_returned_only_to_prompt(tmp_path: Path) -> None:
)
uploader.import_fit(tmp_path / "ride.fit", mfa_code="123456")
assert seen == ["123456"]
def test_import_rejected_by_garmin_raises_garmin_import_rejected(tmp_path: Path) -> None:
rejected_response = {
"detailedImportResult": {
"successes": [],
"failures": [{"internalId": 1, "messages": ["Invalid FIT file"]}],
}
}
uploader = GarminUploader(
email="g@example.com",
password="pw",
tokenstore=tmp_path / "garmin",
client_factory=lambda *a, **kw: FakeGarmin(*a, **kw, import_result=rejected_response),
)
with pytest.raises(GarminImportRejected):
uploader.import_fit(tmp_path / "ride.fit")
def test_login_failure_with_429_is_transient(tmp_path: Path) -> None:
uploader = GarminUploader(
email="g@example.com",
password="pw",
tokenstore=tmp_path / "garmin",
client_factory=lambda *a, **kw: FakeGarmin(*a, **kw, login_error=RuntimeError("429 too many requests")),
)
with pytest.raises(GarminTransientError):
uploader.import_fit(tmp_path / "ride.fit")
def test_login_failure_with_timeout_is_transient(tmp_path: Path) -> None:
uploader = GarminUploader(
email="g@example.com",
password="pw",
tokenstore=tmp_path / "garmin",
client_factory=lambda *a, **kw: FakeGarmin(*a, **kw, login_error=RuntimeError("connection timeout")),
)
with pytest.raises(GarminTransientError):
uploader.import_fit(tmp_path / "ride.fit")
def test_login_failure_with_credential_message_is_auth_error(tmp_path: Path) -> None:
uploader = GarminUploader(
email="g@example.com",
password="pw",
tokenstore=tmp_path / "garmin",
client_factory=lambda *a, **kw: FakeGarmin(*a, **kw, login_error=RuntimeError("invalid credential")),
)
with pytest.raises(GarminAuthError):
uploader.import_fit(tmp_path / "ride.fit")
def test_login_failure_unrecognized_is_transient(tmp_path: Path) -> None:
uploader = GarminUploader(
email="g@example.com",
password="pw",
tokenstore=tmp_path / "garmin",
client_factory=lambda *a, **kw: FakeGarmin(*a, **kw, login_error=RuntimeError("something odd happened")),
)
with pytest.raises(GarminTransientError):
uploader.import_fit(tmp_path / "ride.fit")
def test_import_time_401_raises_auth_error(tmp_path: Path) -> None:
uploader = GarminUploader(
email="g@example.com",
password="pw",
tokenstore=tmp_path / "garmin",
client_factory=lambda *a, **kw: FakeGarmin(*a, **kw, import_error=RuntimeError("401 unauthorized")),
)
with pytest.raises(GarminAuthError):
uploader.import_fit(tmp_path / "ride.fit")
def test_mfa_wrapped_in_generic_exception_still_blocks(tmp_path: Path) -> None:
class WrappedMfaGarmin(FakeGarmin):
def login(self, tokenstore=None):
try:
self.prompt_mfa()
except GarminUploadBlocked as exc:
raise RuntimeError(f"Login failed: Garmin requested MFA ({exc})") from exc
uploader = GarminUploader(
email="g@example.com",
password="pw",
tokenstore=tmp_path / "garmin",
client_factory=WrappedMfaGarmin,
)
with pytest.raises(GarminUploadBlocked):
uploader.import_fit(tmp_path / "ride.fit")

View File

@@ -134,7 +134,7 @@ async def test_download_fit_fetches_signed_url_bytes(tmp_path) -> None:
@pytest.mark.asyncio
async def test_list_activities_raises_integration_error_on_missing_stable_id(tmp_path) -> None:
async def test_list_activities_skips_row_missing_stable_id(tmp_path) -> None:
async def handler(request: httpx.Request) -> httpx.Response:
if request.url.path.endswith("/activities"):
return httpx.Response(
@@ -147,7 +147,13 @@ async def test_list_activities_raises_integration_error_on_missing_stable_id(tmp
"title": "Ride without id",
"activityFileId": "f-1",
"startDatetime": "2026-08-15T06:00:00.000Z",
}
},
{
"id": "a-2",
"title": "Ride with id",
"activityFileId": "f-2",
"startDatetime": "2026-08-15T06:00:00.000Z",
},
],
}
},
@@ -158,5 +164,108 @@ async def test_list_activities_raises_integration_error_on_missing_stable_id(tmp
store.save(MyWhooshToken(access_token="cached", refresh_token=None, whoosh_id=None))
client = MyWhooshClient(store, http_client=httpx.AsyncClient(transport=httpx.MockTransport(handler)))
activities = await client.list_activities("rider@example.com", "secret")
assert [a.id for a in activities] == ["a-2"]
@pytest.mark.asyncio
async def test_list_activities_skips_row_with_unparseable_start_datetime(tmp_path) -> None:
async def handler(request: httpx.Request) -> httpx.Response:
if request.url.path.endswith("/activities"):
return httpx.Response(
200,
json={
"data": {
"totalPages": 1,
"results": [
{
"id": "a-1",
"title": "Ride with bad date",
"activityFileId": "f-1",
"startDatetime": "not-a-date",
},
{
"id": "a-2",
"title": "Ride with good date",
"activityFileId": "f-2",
"startDatetime": "2026-08-15T06:00:00.000Z",
},
],
}
},
)
raise AssertionError(request.url)
store = MyWhooshTokenStore(tmp_path / "mywhoosh.json")
store.save(MyWhooshToken(access_token="cached", refresh_token=None, whoosh_id=None))
client = MyWhooshClient(store, http_client=httpx.AsyncClient(transport=httpx.MockTransport(handler)))
activities = await client.list_activities("rider@example.com", "secret")
assert [a.id for a in activities] == ["a-2"]
@pytest.mark.asyncio
async def test_list_activities_raises_integration_error_on_envelope_shape_failure(tmp_path) -> None:
async def handler(request: httpx.Request) -> httpx.Response:
if request.url.path.endswith("/activities"):
return httpx.Response(200, json={"data": {"totalPages": 1}})
raise AssertionError(request.url)
store = MyWhooshTokenStore(tmp_path / "mywhoosh.json")
store.save(MyWhooshToken(access_token="cached", refresh_token=None, whoosh_id=None))
client = MyWhooshClient(store, http_client=httpx.AsyncClient(transport=httpx.MockTransport(handler)))
with pytest.raises(MyWhooshIntegrationError):
await client.list_activities("rider@example.com", "secret")
@pytest.mark.asyncio
async def test_list_activities_respects_max_pages(tmp_path) -> None:
calls = []
async def handler(request: httpx.Request) -> httpx.Response:
if request.url.path.endswith("/activities"):
payload = json.loads(request.content)
page = payload["page"]
calls.append(page)
result = {
"data": {
"totalPages": 5,
"results": [
{
"id": f"a-{page}",
"title": f"Ride {page}",
"activityFileId": f"f-{page}",
"startDatetime": "2026-08-15T06:00:00.000Z",
}
],
}
}
return httpx.Response(200, json=result)
raise AssertionError(request.url)
store = MyWhooshTokenStore(tmp_path / "mywhoosh.json")
store.save(MyWhooshToken(access_token="cached", refresh_token=None, whoosh_id=None))
client = MyWhooshClient(store, http_client=httpx.AsyncClient(transport=httpx.MockTransport(handler)))
activities = await client.list_activities("rider@example.com", "secret", max_pages=1)
assert calls == [1]
assert [a.id for a in activities] == ["a-1"]
@pytest.mark.asyncio
async def test_download_fit_raises_integration_error_on_invalid_json(tmp_path) -> None:
async def handler(request: httpx.Request) -> httpx.Response:
if request.url.path.endswith("/download-activity-file"):
return httpx.Response(200, content=b"not json", headers={"content-type": "application/json"})
raise AssertionError(request.url)
store = MyWhooshTokenStore(tmp_path / "mywhoosh.json")
store.save(MyWhooshToken(access_token="cached", refresh_token=None, whoosh_id=None))
client = MyWhooshClient(store, http_client=httpx.AsyncClient(transport=httpx.MockTransport(handler)))
with pytest.raises(MyWhooshIntegrationError):
await client.download_fit("f-1", "rider@example.com", "secret")

View File

@@ -1,7 +1,15 @@
import json
import httpx
import pytest
from app.mywhoosh.client import MyWhooshClient, MyWhooshAuthError
from app.mywhoosh.client import (
MyWhooshAuthError,
MyWhooshClient,
MyWhooshDeviceConflictError,
MyWhooshIntegrationError,
MyWhooshTransientError,
)
from app.mywhoosh.models import MyWhooshToken
from app.mywhoosh.tokenstore import MyWhooshTokenStore
@@ -37,3 +45,129 @@ async def test_invalid_credentials_raise_auth_error(tmp_path) -> None:
)
with pytest.raises(MyWhooshAuthError):
await client.login("rider@example.com", "bad")
@pytest.mark.asyncio
async def test_device_conflict_message_raises_device_conflict_error(tmp_path) -> None:
async def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(
200, json={"Success": False, "Message": "You are already logged in from another device."}
)
client = MyWhooshClient(
MyWhooshTokenStore(tmp_path / "mywhoosh.json"),
http_client=httpx.AsyncClient(transport=httpx.MockTransport(handler)),
)
with pytest.raises(MyWhooshDeviceConflictError):
await client.login("rider@example.com", "secret")
@pytest.mark.asyncio
async def test_login_reuses_stable_device_id_across_calls(tmp_path) -> None:
seen_device_ids = []
async def handler(request: httpx.Request) -> httpx.Response:
seen_device_ids.append(json.loads(request.content)["DeviceId"])
return httpx.Response(
200,
json={"Success": True, "AccessToken": "access", "RefreshToken": "refresh", "WhooshId": "w-1"},
)
store = MyWhooshTokenStore(tmp_path / "mywhoosh.json")
client = MyWhooshClient(store, http_client=httpx.AsyncClient(transport=httpx.MockTransport(handler)))
await client.login("rider@example.com", "secret")
await client.login("rider@example.com", "secret")
assert len(seen_device_ids) == 2
assert seen_device_ids[0] == seen_device_ids[1]
assert seen_device_ids[0] == store.get_or_create_device_id()
@pytest.mark.asyncio
async def test_login_returns_json_array_raises_integration_error(tmp_path) -> None:
async def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(200, json=["not", "an", "object"])
client = MyWhooshClient(
MyWhooshTokenStore(tmp_path / "mywhoosh.json"),
http_client=httpx.AsyncClient(transport=httpx.MockTransport(handler)),
)
with pytest.raises(MyWhooshIntegrationError):
await client.login("rider@example.com", "bad")
@pytest.mark.asyncio
async def test_login_5xx_raises_transient_error(tmp_path) -> None:
async def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(503, text="Service Unavailable")
client = MyWhooshClient(
MyWhooshTokenStore(tmp_path / "mywhoosh.json"),
http_client=httpx.AsyncClient(transport=httpx.MockTransport(handler)),
)
with pytest.raises(MyWhooshTransientError):
await client.login("rider@example.com", "secret")
@pytest.mark.asyncio
async def test_second_401_after_reauth_raises_auth_error(tmp_path) -> None:
login_call_count = 0
async def handler(request: httpx.Request) -> httpx.Response:
nonlocal login_call_count
if request.url.path.endswith("/activities"):
return httpx.Response(401, json={"message": "expired"})
if request.url.path.endswith("/login"):
login_call_count += 1
return httpx.Response(
200,
json={
"Success": True,
"AccessToken": "fresh-access",
"RefreshToken": "fresh-refresh",
"WhooshId": "w-1",
},
)
raise AssertionError(request.url)
store = MyWhooshTokenStore(tmp_path / "mywhoosh.json")
store.save(MyWhooshToken(access_token="stale", refresh_token=None, whoosh_id=None))
client = MyWhooshClient(store, http_client=httpx.AsyncClient(transport=httpx.MockTransport(handler)))
with pytest.raises(MyWhooshAuthError):
await client.list_activities("rider@example.com", "secret")
assert login_call_count == 1
@pytest.mark.asyncio
async def test_aclose_closes_self_owned_http_client(tmp_path) -> None:
store = MyWhooshTokenStore(tmp_path / "mywhoosh.json")
client = MyWhooshClient(store)
await client.aclose()
assert client.http.is_closed is True
@pytest.mark.asyncio
async def test_aclose_does_not_close_injected_http_client(tmp_path) -> None:
store = MyWhooshTokenStore(tmp_path / "mywhoosh.json")
injected = httpx.AsyncClient()
client = MyWhooshClient(store, http_client=injected)
await client.aclose()
assert injected.is_closed is False
await injected.aclose()
@pytest.mark.asyncio
async def test_client_usable_as_async_context_manager(tmp_path) -> None:
store = MyWhooshTokenStore(tmp_path / "mywhoosh.json")
async with MyWhooshClient(store) as client:
http_client = client.http
assert http_client.is_closed is False
assert http_client.is_closed is True

View File

@@ -16,3 +16,39 @@ def test_tokenstore_round_trip_and_permissions(tmp_path: Path) -> None:
def test_missing_token_returns_none(tmp_path: Path) -> None:
store = MyWhooshTokenStore(tmp_path / "missing.json")
assert store.load() is None
def test_corrupt_token_file_returns_none(tmp_path: Path) -> None:
path = tmp_path / "mywhoosh.json"
path.write_bytes(b"not valid json {{{")
store = MyWhooshTokenStore(path)
assert store.load() is None
def test_token_file_missing_access_token_returns_none(tmp_path: Path) -> None:
path = tmp_path / "mywhoosh.json"
path.write_text('{"refresh_token": "r", "whoosh_id": "w"}', encoding="utf-8")
store = MyWhooshTokenStore(path)
assert store.load() is None
def test_clear_removes_token_and_load_returns_none(tmp_path: Path) -> None:
store = MyWhooshTokenStore(tmp_path / "tokens" / "mywhoosh.json")
token = MyWhooshToken(access_token="access", refresh_token="refresh", whoosh_id="whoosh-1")
store.save(token)
store.clear()
assert store.load() is None
assert not store.path.exists()
def test_get_or_create_device_id_persists_and_is_stable(tmp_path: Path) -> None:
path = tmp_path / "tokens" / "7" / "mywhoosh.json"
device_id = MyWhooshTokenStore(path).get_or_create_device_id()
reloaded_id = MyWhooshTokenStore(path).get_or_create_device_id()
assert reloaded_id == device_id
device_id_path = path.with_name("device_id")
assert device_id_path.read_text("utf-8").strip() == device_id

View File

@@ -0,0 +1,69 @@
from unittest.mock import MagicMock, patch
from app.notifications.emailer import EmailNotifier
def _notifier(**overrides) -> EmailNotifier:
defaults = dict(
host="smtp.example.com",
port=587,
username="user@example.com",
password="secret",
from_address="sync@example.com",
use_tls=True,
)
defaults.update(overrides)
return EmailNotifier(**defaults)
def test_unconfigured_notifier_is_not_configured() -> None:
notifier = _notifier(host=None)
assert notifier.configured is False
def test_configured_notifier_is_configured() -> None:
assert _notifier().configured is True
def test_send_skips_silently_when_not_configured() -> None:
notifier = _notifier(host=None)
with patch("app.notifications.emailer.smtplib.SMTP") as smtp_cls:
notifier.send(to_address="user@example.com", subject="s", body="b")
smtp_cls.assert_not_called()
def test_send_uses_starttls_and_login_when_configured() -> None:
notifier = _notifier()
smtp_instance = MagicMock()
smtp_instance.__enter__.return_value = smtp_instance
with patch("app.notifications.emailer.smtplib.SMTP", return_value=smtp_instance) as smtp_cls:
notifier.send(to_address="rider@example.com", subject="Action required", body="Check the dashboard")
smtp_cls.assert_called_once_with("smtp.example.com", 587, timeout=10)
smtp_instance.starttls.assert_called_once()
smtp_instance.login.assert_called_once_with("user@example.com", "secret")
assert smtp_instance.send_message.call_count == 1
sent_message = smtp_instance.send_message.call_args[0][0]
assert sent_message["To"] == "rider@example.com"
assert sent_message["From"] == "sync@example.com"
assert sent_message["Subject"] == "Action required"
def test_send_skips_login_without_credentials() -> None:
notifier = _notifier(username=None, password=None)
smtp_instance = MagicMock()
smtp_instance.__enter__.return_value = smtp_instance
with patch("app.notifications.emailer.smtplib.SMTP", return_value=smtp_instance):
notifier.send(to_address="rider@example.com", subject="s", body="b")
smtp_instance.login.assert_not_called()
def test_send_skips_starttls_when_disabled() -> None:
notifier = _notifier(use_tls=False)
smtp_instance = MagicMock()
smtp_instance.__enter__.return_value = smtp_instance
with patch("app.notifications.emailer.smtplib.SMTP", return_value=smtp_instance):
notifier.send(to_address="rider@example.com", subject="s", body="b")
smtp_instance.starttls.assert_not_called()

0
tests/sync/__init__.py Normal file
View File

200
tests/sync/conftest.py Normal file
View File

@@ -0,0 +1,200 @@
from pathlib import Path
from typing import Callable
import pytest
from cryptography.fernet import Fernet
from sqlalchemy import create_engine, select
from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.pool import StaticPool
from app.db.models import Activity, ActivityStatus, Base, SyncUser
from app.db.repositories import ActivityRepository, UserRepository
from app.mywhoosh.models import MyWhooshActivity
from app.security.credentials import CredentialCipher
from app.sync.manager import SyncManager
from tests.sync.fakes import FakeGarminUploader, FakeMyWhooshClient
class FakeFitConverter:
"""Fit converter stub that mimics convert_fit_device's side effect of
writing bytes to output_path, without doing any real FIT parsing."""
def __init__(self) -> None:
self.calls = 0
def __call__(self, source_path: Path, output_path: Path):
self.calls += 1
output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_bytes(b"fake-fit-bytes")
return None
class StubSettings:
"""Minimal stand-in for app.config.Settings exposing only the two
properties SyncManager needs; avoids constructing a full Settings with
its several required env-backed fields."""
def __init__(self, tmp_path: Path) -> None:
self.tokens_dir = tmp_path / "tokens"
self.activities_dir = tmp_path / "activities"
@pytest.fixture
def session_factory():
engine = create_engine(
"sqlite://",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
Base.metadata.create_all(engine)
factory = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False)
try:
yield factory
finally:
engine.dispose()
@pytest.fixture
def cipher() -> CredentialCipher:
return CredentialCipher(Fernet.generate_key().decode("ascii"))
@pytest.fixture
def settings(tmp_path: Path) -> StubSettings:
return StubSettings(tmp_path)
@pytest.fixture
def load_only_activity(session_factory) -> Callable[[int], Activity]:
def _load(user_id: int) -> Activity:
with session_factory() as session:
activities = list(session.scalars(select(Activity).where(Activity.user_id == user_id)))
assert len(activities) == 1, f"expected exactly one activity for user {user_id}, found {len(activities)}"
return activities[0]
return _load
def _create_user(session: Session, cipher: CredentialCipher) -> SyncUser:
return UserRepository(session).create(
name="Test User",
enabled=True,
mywhoosh_email_enc=cipher.encrypt("mywhoosh@example.com"),
mywhoosh_password_enc=cipher.encrypt("mywhoosh-pass"),
garmin_email_enc=cipher.encrypt("garmin@example.com"),
garmin_password_enc=cipher.encrypt("garmin-pass"),
)
@pytest.fixture
def seeded_user(session_factory, cipher: CredentialCipher) -> SyncUser:
with session_factory() as session:
return _create_user(session, cipher)
@pytest.fixture
def manager_factory(session_factory, cipher: CredentialCipher, settings: StubSettings):
"""Build a SyncManager plus its injected fakes, wired so the fake
MyWhoosh client's single remote activity matches the given (already
seeded) Activity's mywhoosh_activity_id -- so get_or_create_discovered
resolves to the existing row instead of creating a new one."""
def _factory(activity: Activity):
remote = MyWhooshActivity(
id=activity.mywhoosh_activity_id,
title=activity.activity_name,
activity_file_id=f"file-{activity.mywhoosh_activity_id}",
started_at=activity.activity_timestamp,
)
mywhoosh = FakeMyWhooshClient(activities=[remote], fit_bytes=b"source-bytes")
converter = FakeFitConverter()
garmin = FakeGarminUploader()
manager = SyncManager(
session_factory=session_factory,
credential_cipher=cipher,
settings=settings,
mywhoosh_factory=lambda token_store: mywhoosh,
garmin_factory=lambda email, password, tokenstore: garmin,
fit_converter=converter,
)
return manager, mywhoosh, converter, garmin
return _factory
@pytest.fixture
def manager(session_factory, cipher: CredentialCipher, settings: StubSettings, seeded_user: SyncUser):
"""A manager wired for the happy-path new-activity scenario: one remote
MyWhoosh activity that seeded_user has never seen before."""
remote = MyWhooshActivity(
id="mw-1",
title="Morning Ride",
activity_file_id="file-mw-1",
started_at=None,
)
mywhoosh = FakeMyWhooshClient(activities=[remote], fit_bytes=b"source-bytes")
converter = FakeFitConverter()
garmin = FakeGarminUploader()
sync_manager = SyncManager(
session_factory=session_factory,
credential_cipher=cipher,
settings=settings,
mywhoosh_factory=lambda token_store: mywhoosh,
garmin_factory=lambda email, password, tokenstore: garmin,
fit_converter=converter,
)
# Exposed for tests that want to introspect fakes without a
# manager_factory-style scenario.
sync_manager.fake_mywhoosh = mywhoosh
sync_manager.fake_converter = converter
sync_manager.fake_garmin = garmin
return sync_manager
@pytest.fixture
def seeded_activity_factory(session_factory, cipher: CredentialCipher):
def _factory(
*,
status: ActivityStatus,
last_completed_stage: ActivityStatus,
retryable: bool,
mywhoosh_activity_id: str = "mw-1",
) -> Activity:
with session_factory() as session:
user = _create_user(session, cipher)
activity_repo = ActivityRepository(session)
activity, _created = activity_repo.get_or_create_discovered(
user_id=user.id,
mywhoosh_activity_id=mywhoosh_activity_id,
activity_name="Test Activity",
activity_timestamp=None,
)
activity.status = status
activity.last_completed_stage = last_completed_stage
activity.retryable = retryable
if status in (
ActivityStatus.DOWNLOADED,
ActivityStatus.CONVERTED,
ActivityStatus.IMPORTED,
ActivityStatus.DUPLICATE,
) or last_completed_stage in (
ActivityStatus.DOWNLOADED,
ActivityStatus.CONVERTED,
ActivityStatus.IMPORTED,
ActivityStatus.DUPLICATE,
):
activity.source_fit_path = "seed-source.fit"
if status in (
ActivityStatus.CONVERTED,
ActivityStatus.IMPORTED,
ActivityStatus.DUPLICATE,
) or last_completed_stage in (
ActivityStatus.CONVERTED,
ActivityStatus.IMPORTED,
ActivityStatus.DUPLICATE,
):
activity.converted_fit_path = "seed-converted.fit"
session.commit()
return activity
return _factory

38
tests/sync/fakes.py Normal file
View File

@@ -0,0 +1,38 @@
from app.garmin.uploader import UploadResult
class FakeMyWhooshClient:
def __init__(self, activities, fit_bytes: bytes) -> None:
self.activities = activities
self.fit_bytes = fit_bytes
self.list_calls = 0
self.download_calls = 0
async def list_activities(self, email: str, password: str):
self.list_calls += 1
return list(self.activities)
async def download_fit(self, activity_file_id: str, email: str, password: str) -> bytes:
self.download_calls += 1
return self.fit_bytes
class FakeGarminUploader:
def __init__(self, result: UploadResult | None = None, error: Exception | None = None) -> None:
self.result = result or UploadResult("imported", False, "g-1", {"activityId": "g-1"})
self.error = error
self.calls = 0
def import_fit(self, fit_path, mfa_code=None):
self.calls += 1
if self.error is not None:
raise self.error
return self.result
class FakeNotifier:
def __init__(self) -> None:
self.sent: list[dict] = []
def send(self, *, to_address: str, subject: str, body: str) -> None:
self.sent.append({"to_address": to_address, "subject": subject, "body": body})

View File

@@ -0,0 +1,163 @@
import asyncio
import pytest
from app.db.repositories import UserRepository
from app.mywhoosh.client import MyWhooshAuthError
from app.mywhoosh.models import MyWhooshActivity
from app.sync.manager import SyncAlreadyRunning, SyncManager
from tests.sync.conftest import FakeFitConverter, _create_user
from tests.sync.fakes import FakeGarminUploader
class BlockingMyWhooshClient:
"""Fake MyWhoosh client whose list_activities() blocks on test-controlled
events, so a test can deterministically observe "sync has started but not
finished" without any production-only test hooks."""
def __init__(self, first_started: asyncio.Event, release: asyncio.Event) -> None:
self.first_started = first_started
self.release = release
self.list_calls = 0
async def list_activities(self, email: str, password: str):
self.list_calls += 1
self.first_started.set()
await self.release.wait()
return []
async def download_fit(self, activity_file_id: str, email: str, password: str) -> bytes:
raise AssertionError("download_fit should not be reached in this test")
class ConditionalFailureMyWhooshClient:
"""Fake MyWhoosh client that raises MyWhooshAuthError only for a specific
account email, letting one user's sync fail while others succeed."""
def __init__(self, activities, fit_bytes: bytes, failing_email: str) -> None:
self.activities = activities
self.fit_bytes = fit_bytes
self.failing_email = failing_email
self.list_calls = 0
self.download_calls = 0
async def list_activities(self, email: str, password: str):
self.list_calls += 1
if email == self.failing_email:
raise MyWhooshAuthError("simulated auth failure")
return list(self.activities)
async def download_fit(self, activity_file_id: str, email: str, password: str) -> bytes:
self.download_calls += 1
return self.fit_bytes
@pytest.fixture
def user_a(session_factory, cipher):
with session_factory() as session:
return _create_user(session, cipher)
@pytest.fixture
def user_b(session_factory, cipher):
with session_factory() as session:
return _create_user(session, cipher)
@pytest.mark.asyncio
async def test_same_user_cannot_run_twice(session_factory, cipher, settings, seeded_user) -> None:
first_started = asyncio.Event()
release_first = asyncio.Event()
mywhoosh = BlockingMyWhooshClient(first_started, release_first)
manager = SyncManager(
session_factory=session_factory,
credential_cipher=cipher,
settings=settings,
mywhoosh_factory=lambda token_store: mywhoosh,
garmin_factory=lambda email, password, tokenstore: FakeGarminUploader(),
fit_converter=FakeFitConverter(),
)
first = asyncio.create_task(manager.sync_user(seeded_user.id))
await first_started.wait()
with pytest.raises(SyncAlreadyRunning):
await manager.sync_user(seeded_user.id)
release_first.set()
outcome = await first
assert outcome.user_id == seeded_user.id
assert mywhoosh.list_calls == 1
@pytest.mark.asyncio
async def test_different_users_can_run_concurrently(manager, user_a, user_b) -> None:
results = await asyncio.gather(manager.sync_user(user_a.id), manager.sync_user(user_b.id))
assert {result.user_id for result in results} == {user_a.id, user_b.id}
assert all(result.status == "success" for result in results)
@pytest.mark.asyncio
async def test_sync_all_enabled_isolates_failures(session_factory, cipher, settings) -> None:
with session_factory() as session:
failing_user = UserRepository(session).create(
name="Failing User",
enabled=True,
mywhoosh_email_enc=cipher.encrypt("failing-mw@example.com"),
mywhoosh_password_enc=cipher.encrypt("failing-mw-pass"),
garmin_email_enc=cipher.encrypt("failing-garmin@example.com"),
garmin_password_enc=cipher.encrypt("failing-garmin-pass"),
)
healthy_user = UserRepository(session).create(
name="Healthy User",
enabled=True,
mywhoosh_email_enc=cipher.encrypt("healthy-mw@example.com"),
mywhoosh_password_enc=cipher.encrypt("healthy-mw-pass"),
garmin_email_enc=cipher.encrypt("healthy-garmin@example.com"),
garmin_password_enc=cipher.encrypt("healthy-garmin-pass"),
)
remote = MyWhooshActivity(
id="mw-shared",
title="Shared Ride",
activity_file_id="file-mw-shared",
started_at=None,
)
mywhoosh = ConditionalFailureMyWhooshClient(
activities=[remote],
fit_bytes=b"source-bytes",
failing_email="failing-mw@example.com",
)
converter = FakeFitConverter()
garmin = FakeGarminUploader()
manager = SyncManager(
session_factory=session_factory,
credential_cipher=cipher,
settings=settings,
mywhoosh_factory=lambda token_store: mywhoosh,
garmin_factory=lambda email, password, tokenstore: garmin,
fit_converter=converter,
)
results = await manager.sync_all_enabled()
assert len(results) == 2
for result in results:
assert not isinstance(result, Exception)
by_user = {result.user_id: result for result in results}
failing_outcome = by_user[failing_user.id]
healthy_outcome = by_user[healthy_user.id]
# The failing user's auth error is classified by _sync_user_locked's own
# exception handling and returned as a non-success SyncOutcome rather than
# raised -- so asyncio.gather never sees an exception for this failure
# mode. It must not affect the healthy user's independent outcome.
assert failing_outcome.status != "success"
assert failing_outcome.message is not None
assert failing_outcome.discovered == 0
assert healthy_outcome.status == "success"
assert healthy_outcome.imported == 1
assert healthy_outcome.failed == 0

412
tests/sync/test_manager.py Normal file
View File

@@ -0,0 +1,412 @@
from pathlib import Path
import pytest
from sqlalchemy import select
from app.db.models import ActivityStatus, HealthState, SyncRun, SyncRunStatus, SyncUser
from app.db.repositories import SystemLogRepository, UserRepository
from app.garmin.uploader import UploadResult
from app.mywhoosh.client import MyWhooshDeviceConflictError
from app.mywhoosh.models import MyWhooshActivity
from app.notifications.emailer import EmailNotifier
from app.sync.manager import SyncManager
from tests.sync.conftest import FakeFitConverter, _create_user
from tests.sync.fakes import FakeGarminUploader, FakeMyWhooshClient, FakeNotifier
@pytest.mark.asyncio
async def test_new_activity_downloads_converts_and_imports(manager, seeded_user: SyncUser, load_only_activity) -> None:
outcome = await manager.sync_user(seeded_user.id)
assert outcome.discovered == 1
assert outcome.imported == 1
assert outcome.failed == 0
activity = load_only_activity(seeded_user.id)
assert activity.status == ActivityStatus.IMPORTED
assert Path(activity.source_fit_path).exists()
assert Path(activity.converted_fit_path).exists()
@pytest.mark.asyncio
@pytest.mark.parametrize(
("status", "last_stage", "expected_downloads", "expected_conversions", "expected_imports"),
[
(ActivityStatus.DOWNLOADED, ActivityStatus.DOWNLOADED, 0, 1, 1),
(ActivityStatus.CONVERTED, ActivityStatus.CONVERTED, 0, 0, 1),
(ActivityStatus.IMPORTED, ActivityStatus.IMPORTED, 0, 0, 0),
(ActivityStatus.FAILED, ActivityStatus.CONVERTED, 0, 0, 1),
],
)
async def test_resume_from_durable_stage(
manager_factory,
seeded_activity_factory,
status,
last_stage,
expected_downloads,
expected_conversions,
expected_imports,
) -> None:
activity = seeded_activity_factory(status=status, last_completed_stage=last_stage, retryable=True)
manager, mywhoosh, converter, garmin = manager_factory(activity)
await manager.sync_user(activity.user_id)
assert mywhoosh.download_calls == expected_downloads
assert converter.calls == expected_conversions
assert garmin.calls == expected_imports
@pytest.mark.asyncio
async def test_non_retryable_failed_activity_is_never_retried(
manager_factory,
seeded_activity_factory,
load_only_activity,
) -> None:
activity = seeded_activity_factory(
status=ActivityStatus.FAILED,
last_completed_stage=ActivityStatus.CONVERTED,
retryable=False,
)
manager, mywhoosh, converter, garmin = manager_factory(activity)
outcome = await manager.sync_user(activity.user_id)
assert mywhoosh.download_calls == 0
assert converter.calls == 0
assert garmin.calls == 0
assert outcome.imported == 0
assert outcome.skipped == 0
assert outcome.failed == 0
reloaded = load_only_activity(activity.user_id)
assert reloaded.status == ActivityStatus.FAILED
assert reloaded.retryable is False
@pytest.mark.asyncio
async def test_sync_run_repository_wiring_records_run(manager, seeded_user: SyncUser, session_factory) -> None:
await manager.sync_user(seeded_user.id)
with session_factory() as session:
runs = list(session.scalars(select(SyncRun).where(SyncRun.user_id == seeded_user.id)))
assert len(runs) == 1
run = runs[0]
assert run.status == SyncRunStatus.SUCCESS
assert run.discovered_count == 1
assert run.imported_count == 1
assert run.finished_at is not None
class RecordingGarminUploader:
"""Fake Garmin uploader that records the mfa_code it was actually called
with, so a test can prove the value genuinely threads through
SyncManager._sync_user_locked's asyncio.to_thread(garmin.import_fit, ...)
call rather than just through the web route's own fake manager."""
def __init__(self) -> None:
self.received_mfa_codes: list[str | None] = []
def import_fit(self, fit_path, mfa_code=None):
self.received_mfa_codes.append(mfa_code)
return UploadResult("imported", False, "g-1", {"activityId": "g-1"})
@pytest.mark.asyncio
async def test_garmin_action_required_not_cleared_by_run_with_no_garmin_work(
session_factory, cipher, settings
) -> None:
"""Regression test for the health-state-reset bug: a user stuck at
garmin_auth_required must NOT be silently cleared back to healthy just
because a run's MyWhoosh listing succeeded trivially (zero remote
activities means zero Garmin work was attempted -- no evidence Garmin
was actually fixed)."""
with session_factory() as session:
user = _create_user(session, cipher)
user.health_state = HealthState.ACTION_REQUIRED
user.garmin_state = "auth_required"
user.action_reason = "garmin_auth_required"
session.commit()
user_id = user.id
mywhoosh = FakeMyWhooshClient(activities=[], fit_bytes=b"unused")
converter = FakeFitConverter()
garmin = RecordingGarminUploader()
manager = SyncManager(
session_factory=session_factory,
credential_cipher=cipher,
settings=settings,
mywhoosh_factory=lambda token_store: mywhoosh,
garmin_factory=lambda email, password, tokenstore: garmin,
fit_converter=converter,
)
outcome = await manager.sync_user(user_id)
assert outcome.status == "success"
assert outcome.discovered == 0
with session_factory() as session:
reloaded = UserRepository(session).get(user_id)
assert reloaded.action_reason == "garmin_auth_required"
assert reloaded.health_state == HealthState.ACTION_REQUIRED
@pytest.mark.asyncio
async def test_garmin_action_required_cleared_after_successful_import(
session_factory, cipher, settings
) -> None:
"""Companion to the regression test above: the same starting
action_required/garmin_auth_required state IS cleared once this run
actually succeeds at a real Garmin import -- proving the fix doesn't just
always refuse to clear."""
with session_factory() as session:
user = _create_user(session, cipher)
user.health_state = HealthState.ACTION_REQUIRED
user.garmin_state = "auth_required"
user.action_reason = "garmin_auth_required"
session.commit()
user_id = user.id
remote = MyWhooshActivity(
id="mw-recovery-1", title="Recovery Ride", activity_file_id="file-mw-recovery-1", started_at=None
)
mywhoosh = FakeMyWhooshClient(activities=[remote], fit_bytes=b"source-bytes")
converter = FakeFitConverter()
garmin = RecordingGarminUploader()
manager = SyncManager(
session_factory=session_factory,
credential_cipher=cipher,
settings=settings,
mywhoosh_factory=lambda token_store: mywhoosh,
garmin_factory=lambda email, password, tokenstore: garmin,
fit_converter=converter,
)
outcome = await manager.sync_user(user_id)
assert outcome.status == "success"
assert outcome.imported == 1
with session_factory() as session:
reloaded = UserRepository(session).get(user_id)
assert reloaded.action_reason is None
assert reloaded.health_state == HealthState.HEALTHY
class DeviceConflictMyWhooshClient:
"""Fake MyWhoosh client that always raises MyWhooshDeviceConflictError
from list_activities, simulating MyWhoosh's "already logged in from
another device" response."""
async def list_activities(self, email: str, password: str):
raise MyWhooshDeviceConflictError("You are already logged in from another device.")
async def download_fit(self, activity_file_id: str, email: str, password: str) -> bytes:
raise AssertionError("download_fit should not be reached in this test")
@pytest.mark.asyncio
async def test_device_conflict_sets_distinct_action_reason(seeded_user: SyncUser, session_factory, cipher, settings) -> None:
"""A MyWhoosh device-conflict response must be distinguishable in the UI
from a generic auth failure, so users get an actionable hint instead of
being told to re-check their password."""
mywhoosh = DeviceConflictMyWhooshClient()
converter = FakeFitConverter()
garmin = FakeGarminUploader()
manager = SyncManager(
session_factory=session_factory,
credential_cipher=cipher,
settings=settings,
mywhoosh_factory=lambda token_store: mywhoosh,
garmin_factory=lambda email, password, tokenstore: garmin,
fit_converter=converter,
)
outcome = await manager.sync_user(seeded_user.id)
assert outcome.status == "failed"
assert "another device" in outcome.message
with session_factory() as session:
reloaded = UserRepository(session).get(seeded_user.id)
assert reloaded.action_reason == "mywhoosh_device_conflict"
assert reloaded.mywhoosh_state == "device_conflict"
assert reloaded.health_state == HealthState.ACTION_REQUIRED
@pytest.mark.asyncio
async def test_notifies_on_new_action_required_when_opted_in(session_factory, cipher, settings) -> None:
with session_factory() as session:
user = _create_user(session, cipher)
user.notify_email_enabled = True
user.notification_email = "alerts@example.com"
session.commit()
user_id = user.id
notifier = FakeNotifier()
manager = SyncManager(
session_factory=session_factory,
credential_cipher=cipher,
settings=settings,
mywhoosh_factory=lambda token_store: DeviceConflictMyWhooshClient(),
garmin_factory=lambda email, password, tokenstore: FakeGarminUploader(),
fit_converter=FakeFitConverter(),
notifier=notifier,
)
await manager.sync_user(user_id)
assert len(notifier.sent) == 1
assert notifier.sent[0]["to_address"] == "alerts@example.com"
assert "another device" in notifier.sent[0]["body"]
@pytest.mark.asyncio
async def test_does_not_notify_when_not_opted_in(session_factory, cipher, settings) -> None:
user_id = None
with session_factory() as session:
user = _create_user(session, cipher)
user_id = user.id
notifier = FakeNotifier()
manager = SyncManager(
session_factory=session_factory,
credential_cipher=cipher,
settings=settings,
mywhoosh_factory=lambda token_store: DeviceConflictMyWhooshClient(),
garmin_factory=lambda email, password, tokenstore: FakeGarminUploader(),
fit_converter=FakeFitConverter(),
notifier=notifier,
)
await manager.sync_user(user_id)
assert notifier.sent == []
@pytest.mark.asyncio
async def test_does_not_renotify_for_unresolved_unchanged_reason(session_factory, cipher, settings) -> None:
with session_factory() as session:
user = _create_user(session, cipher)
user.notify_email_enabled = True
user.notification_email = "alerts@example.com"
session.commit()
user_id = user.id
notifier = FakeNotifier()
manager = SyncManager(
session_factory=session_factory,
credential_cipher=cipher,
settings=settings,
mywhoosh_factory=lambda token_store: DeviceConflictMyWhooshClient(),
garmin_factory=lambda email, password, tokenstore: FakeGarminUploader(),
fit_converter=FakeFitConverter(),
notifier=notifier,
)
await manager.sync_user(user_id)
await manager.sync_user(user_id)
assert len(notifier.sent) == 1
class FailingNotifier:
def send(self, *, to_address: str, subject: str, body: str) -> None:
raise RuntimeError("SMTP connection refused")
@pytest.mark.asyncio
async def test_email_send_failure_is_recorded_in_system_log_and_does_not_break_sync(
session_factory, cipher, settings
) -> None:
with session_factory() as session:
user = _create_user(session, cipher)
user.notify_email_enabled = True
user.notification_email = "alerts@example.com"
session.commit()
user_id = user.id
manager = SyncManager(
session_factory=session_factory,
credential_cipher=cipher,
settings=settings,
mywhoosh_factory=lambda token_store: DeviceConflictMyWhooshClient(),
garmin_factory=lambda email, password, tokenstore: FakeGarminUploader(),
fit_converter=FakeFitConverter(),
notifier=FailingNotifier(),
)
outcome = await manager.sync_user(user_id)
assert outcome.status == "failed"
with session_factory() as session:
entries = SystemLogRepository(session).list_recent()
assert len(entries) == 1
assert entries[0].source == "email_notification"
assert entries[0].user_id == user_id
assert "SMTP connection refused" in entries[0].message
assert "alerts@example.com" in entries[0].message
@pytest.mark.asyncio
async def test_unconfigured_smtp_is_recorded_in_system_log_not_silently_dropped(
session_factory, cipher, settings
) -> None:
"""Regression test: an EmailNotifier with no SMTP_HOST configured skips
sending without raising, which used to look identical to "notifications
disabled" -- an opted-in user got neither an email nor any trace of why,
with nothing to debug from. This must now leave a system log entry."""
with session_factory() as session:
user = _create_user(session, cipher)
user.notify_email_enabled = True
user.notification_email = "alerts@example.com"
session.commit()
user_id = user.id
unconfigured_notifier = EmailNotifier(
host=None, port=587, username=None, password=None, from_address=None, use_tls=True
)
manager = SyncManager(
session_factory=session_factory,
credential_cipher=cipher,
settings=settings,
mywhoosh_factory=lambda token_store: DeviceConflictMyWhooshClient(),
garmin_factory=lambda email, password, tokenstore: FakeGarminUploader(),
fit_converter=FakeFitConverter(),
notifier=unconfigured_notifier,
)
await manager.sync_user(user_id)
with session_factory() as session:
entries = SystemLogRepository(session).list_recent()
assert len(entries) == 1
assert entries[0].source == "email_notification"
assert "SMTP is not configured" in entries[0].message
assert "alerts@example.com" in entries[0].message
@pytest.mark.asyncio
async def test_mfa_code_reaches_real_garmin_uploader_via_sync_manager(
session_factory, cipher, settings, seeded_user: SyncUser
) -> None:
"""Proves mfa_code genuinely threads through _sync_user_locked's
asyncio.to_thread(garmin.import_fit, converted_path, mfa_code) call in the
real (non-web-route) code path -- tests/web/test_mfa.py already covers the
web route's own fake manager, but not the real SyncManager/GarminUploader
interface."""
remote = MyWhooshActivity(
id="mw-mfa-1", title="MFA Ride", activity_file_id="file-mw-mfa-1", started_at=None
)
mywhoosh = FakeMyWhooshClient(activities=[remote], fit_bytes=b"source-bytes")
converter = FakeFitConverter()
garmin = RecordingGarminUploader()
manager = SyncManager(
session_factory=session_factory,
credential_cipher=cipher,
settings=settings,
mywhoosh_factory=lambda token_store: mywhoosh,
garmin_factory=lambda email, password, tokenstore: garmin,
fit_converter=converter,
)
outcome = await manager.sync_user(seeded_user.id, mfa_code="123456")
assert outcome.status == "success"
assert garmin.received_mfa_codes == ["123456"]

View File

@@ -0,0 +1,136 @@
import asyncio
from datetime import datetime
import pytest
from sqlalchemy import create_engine
from sqlalchemy.pool import StaticPool
from app.db.models import Base
from app.db.repositories import SchedulerSettingsRepository
from app.db.session import create_session_factory
from app.sync.scheduler import DayNightIntervalProvider, SyncScheduler, is_daytime
class FakeSyncManager:
def __init__(self, results=None) -> None:
self.results = results if results is not None else []
self.calls = 0
async def sync_all_enabled(self):
self.calls += 1
if self.results and isinstance(self.results[0], Exception):
raise self.results[0]
return list(self.results)
@pytest.mark.asyncio
async def test_scheduler_calls_sync_all_and_survives_failure() -> None:
fake = FakeSyncManager(results=[RuntimeError("one user failed")])
scheduler = SyncScheduler(fake, interval_seconds=0.01)
await scheduler.start()
await asyncio.sleep(0.035)
await scheduler.stop()
assert fake.calls >= 2
assert scheduler.last_tick is not None
@pytest.mark.asyncio
async def test_scheduler_stop_cancels_the_loop() -> None:
fake = FakeSyncManager()
scheduler = SyncScheduler(fake, interval_seconds=0.01)
await scheduler.start()
await asyncio.sleep(0.035)
await scheduler.stop()
assert scheduler._task is None
calls_after_stop = fake.calls
await asyncio.sleep(0.05)
assert fake.calls == calls_after_stop
@pytest.mark.parametrize(
("hour", "day_start", "night_start", "expected"),
[
(6, 6, 22, True),
(21, 6, 22, True),
(5, 6, 22, False),
(22, 6, 22, False),
(0, 6, 22, False),
# wraps past midnight: day period is [20, 6)
(23, 20, 6, True),
(2, 20, 6, True),
(10, 20, 6, False),
(20, 20, 6, True),
(6, 20, 6, False),
# degenerate: identical start hours means always day
(13, 9, 9, True),
],
)
def test_is_daytime(hour, day_start, night_start, expected) -> None:
assert is_daytime(hour, day_start, night_start) is expected
@pytest.fixture
def db_session_factory():
engine = create_engine("sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool)
Base.metadata.create_all(engine)
factory = create_session_factory(engine)
try:
yield factory
finally:
engine.dispose()
@pytest.mark.asyncio
async def test_interval_provider_uses_day_interval_during_the_day(db_session_factory) -> None:
provider = DayNightIntervalProvider(
db_session_factory, default_minutes=5, now=lambda: datetime(2026, 1, 1, 12, 0)
)
assert provider() == 5 * 60
@pytest.mark.asyncio
async def test_interval_provider_uses_night_interval_at_night(db_session_factory) -> None:
with db_session_factory() as session:
repo = SchedulerSettingsRepository(session)
row = repo.get_or_create(default_minutes=5)
repo.update(row, night_interval_minutes=30)
provider = DayNightIntervalProvider(
db_session_factory, default_minutes=5, now=lambda: datetime(2026, 1, 1, 23, 0)
)
assert provider() == 30 * 60
@pytest.mark.asyncio
async def test_scheduler_uses_interval_provider_for_next_tick(db_session_factory) -> None:
with db_session_factory() as session:
repo = SchedulerSettingsRepository(session)
row = repo.get_or_create(default_minutes=5)
repo.update(row, day_interval_minutes=1, night_interval_minutes=1)
provider = DayNightIntervalProvider(
db_session_factory, default_minutes=5, now=lambda: datetime(2026, 1, 1, 12, 0)
)
fake = FakeSyncManager()
scheduler = SyncScheduler(fake, interval_seconds=999, interval_provider=provider)
await scheduler.run_once()
expected_seconds = 60 # day_interval_minutes=1 -> 60s, not the unrelated interval_seconds=999 fallback
delta = (scheduler.next_tick - scheduler.last_tick).total_seconds()
assert abs(delta - expected_seconds) < 1
@pytest.mark.asyncio
async def test_scheduler_falls_back_when_interval_provider_raises() -> None:
def broken_provider():
raise RuntimeError("db unavailable")
fake = FakeSyncManager()
scheduler = SyncScheduler(fake, interval_seconds=42, interval_provider=broken_provider)
await scheduler.run_once()
delta = (scheduler.next_tick - scheduler.last_tick).total_seconds()
assert abs(delta - 42) < 1

268
tests/test_acceptance.py Normal file
View File

@@ -0,0 +1,268 @@
"""Application-level, multi-user acceptance tests for the full MyWhoosh -> Garmin
sync pipeline (Task 7 of the sync-scheduler-web plan).
These tests build a real `SyncManager` wired to a real SQLite database and a
real `CredentialCipher`, but with fake MyWhoosh/Garmin factories, so they
exercise the whole state machine end-to-end for two independent users without
touching any real external service.
"""
from __future__ import annotations
from pathlib import Path
import pytest
from cryptography.fernet import Fernet
from sqlalchemy import func, select
from sqlalchemy.orm import sessionmaker
from app.db.models import Activity, ActivityStatus, HealthState, SyncUser
from app.db.repositories import UserRepository
from app.db.session import create_db_engine, create_session_factory, initialize_schema
from app.garmin.uploader import GarminUploadBlocked, UploadResult
from app.mywhoosh.models import MyWhooshActivity
from app.security.credentials import CredentialCipher
from app.sync.manager import SyncManager
class StubSettings:
"""Minimal stand-in for app.config.Settings exposing only the two
properties SyncManager needs."""
def __init__(self, tmp_path: Path) -> None:
self.tokens_dir = tmp_path / "tokens"
self.activities_dir = tmp_path / "activities"
class FakeMyWhooshClient:
def __init__(self, activities, fit_bytes: bytes) -> None:
self.activities = activities
self.fit_bytes = fit_bytes
self.list_calls = 0
self.download_calls = 0
async def list_activities(self, email, password):
self.list_calls += 1
return list(self.activities)
async def download_fit(self, activity_file_id, email, password):
self.download_calls += 1
return self.fit_bytes
class FakeGarminUploader:
def __init__(self, result: UploadResult | None = None, error: Exception | None = None) -> None:
self.result = result or UploadResult("imported", False, "g-1", {"activityId": "g-1"})
self.error = error
self.calls = 0
def import_fit(self, fit_path, mfa_code=None):
self.calls += 1
if self.error is not None:
raise self.error
return self.result
def fake_fit_converter(source_path: Path, output_path: Path) -> None:
output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_bytes(b"fake-converted-fit-bytes")
def count_terminal_activities(session_factory: sessionmaker, user_id: int) -> int:
with session_factory() as session:
return session.scalar(
select(func.count())
.select_from(Activity)
.where(
Activity.user_id == user_id,
Activity.status.in_([ActivityStatus.IMPORTED, ActivityStatus.DUPLICATE]),
)
)
def _create_user(session, cipher: CredentialCipher, *, name: str) -> SyncUser:
return UserRepository(session).create(
name=name,
enabled=True,
mywhoosh_email_enc=cipher.encrypt(f"{name.lower()}-mywhoosh@example.com"),
mywhoosh_password_enc=cipher.encrypt("mw-secret"),
garmin_email_enc=cipher.encrypt(f"{name.lower()}-garmin@example.com"),
garmin_password_enc=cipher.encrypt("garmin-secret"),
)
@pytest.fixture
def session_factory(tmp_path: Path):
db_path = tmp_path / "acceptance.db"
engine = create_db_engine(f"sqlite:///{db_path}")
initialize_schema(engine)
factory = create_session_factory(engine)
try:
yield factory
finally:
engine.dispose()
@pytest.fixture
def cipher() -> CredentialCipher:
return CredentialCipher(Fernet.generate_key().decode("ascii"))
@pytest.fixture
def settings(tmp_path: Path) -> StubSettings:
return StubSettings(tmp_path)
@pytest.fixture
def two_users(session_factory, cipher: CredentialCipher):
with session_factory() as session:
user_a = _create_user(session, cipher, name="Alice")
user_b = _create_user(session, cipher, name="Bob")
return user_a, user_b
def _build_manager(
*,
session_factory,
cipher: CredentialCipher,
settings: StubSettings,
user_a: SyncUser,
user_b: SyncUser,
fake_mw_a: FakeMyWhooshClient,
fake_mw_b: FakeMyWhooshClient,
fake_garmin_a: FakeGarminUploader,
fake_garmin_b: FakeGarminUploader,
):
mywhoosh_fakes = {str(user_a.id): fake_mw_a, str(user_b.id): fake_mw_b}
garmin_fakes = {str(user_a.id): fake_garmin_a, str(user_b.id): fake_garmin_b}
garmin_factory_calls: list[tuple[str, str, Path]] = []
def mywhoosh_factory(token_store):
user_key = token_store.path.parent.name
return mywhoosh_fakes[user_key]
def garmin_factory(email, password, tokenstore):
garmin_factory_calls.append((email, password, tokenstore))
user_key = tokenstore.parent.name
return garmin_fakes[user_key]
manager = SyncManager(
session_factory=session_factory,
credential_cipher=cipher,
settings=settings,
mywhoosh_factory=mywhoosh_factory,
garmin_factory=garmin_factory,
fit_converter=fake_fit_converter,
)
return manager, garmin_factory_calls
@pytest.mark.asyncio
async def test_two_user_happy_path(session_factory, cipher, settings, two_users):
user_a, user_b = two_users
remote_a = MyWhooshActivity(
id="remote-a-1", title="Alice Ride", activity_file_id="file-a-1", started_at=None
)
remote_b = MyWhooshActivity(
id="remote-b-1", title="Bob Ride", activity_file_id="file-b-1", started_at=None
)
fake_mw_a = FakeMyWhooshClient(activities=[remote_a], fit_bytes=b"alice-source-bytes")
fake_mw_b = FakeMyWhooshClient(activities=[remote_b], fit_bytes=b"bob-source-bytes")
fake_garmin_a = FakeGarminUploader()
fake_garmin_b = FakeGarminUploader()
manager, garmin_factory_calls = _build_manager(
session_factory=session_factory,
cipher=cipher,
settings=settings,
user_a=user_a,
user_b=user_b,
fake_mw_a=fake_mw_a,
fake_mw_b=fake_mw_b,
fake_garmin_a=fake_garmin_a,
fake_garmin_b=fake_garmin_b,
)
results = await manager.sync_all_enabled()
assert all(result.status == "success" for result in results)
assert count_terminal_activities(session_factory, user_a.id) == 1
assert count_terminal_activities(session_factory, user_b.id) == 1
with session_factory() as session:
activity_a = session.scalar(select(Activity).where(Activity.user_id == user_a.id))
activity_b = session.scalar(select(Activity).where(Activity.user_id == user_b.id))
assert Path(activity_a.source_fit_path).exists()
assert Path(activity_b.source_fit_path).exists()
assert Path(activity_a.source_fit_path).parent != Path(activity_b.source_fit_path).parent
tokenstores = {str(call[2]) for call in garmin_factory_calls}
assert len(tokenstores) == 2
# Idempotency (spec 26.8): re-running sync_all_enabled with no new remote
# activities must not re-download/re-convert/re-import anything, and must
# not create a second terminal Activity row for the same MyWhoosh activity.
download_calls_a_before = fake_mw_a.download_calls
download_calls_b_before = fake_mw_b.download_calls
garmin_calls_a_before = fake_garmin_a.calls
garmin_calls_b_before = fake_garmin_b.calls
second_results = await manager.sync_all_enabled()
assert all(result.status == "success" for result in second_results)
assert fake_mw_a.download_calls == download_calls_a_before
assert fake_mw_b.download_calls == download_calls_b_before
assert fake_garmin_a.calls == garmin_calls_a_before
assert fake_garmin_b.calls == garmin_calls_b_before
assert count_terminal_activities(session_factory, user_a.id) == 1
assert count_terminal_activities(session_factory, user_b.id) == 1
@pytest.mark.asyncio
async def test_isolation_when_one_user_requires_mfa(session_factory, cipher, settings, two_users):
user_a, user_b = two_users
remote_a = MyWhooshActivity(
id="remote-a-1", title="Alice Ride", activity_file_id="file-a-1", started_at=None
)
remote_b = MyWhooshActivity(
id="remote-b-1", title="Bob Ride", activity_file_id="file-b-1", started_at=None
)
fake_mw_a = FakeMyWhooshClient(activities=[remote_a], fit_bytes=b"alice-source-bytes")
fake_mw_b = FakeMyWhooshClient(activities=[remote_b], fit_bytes=b"bob-source-bytes")
fake_garmin_a = FakeGarminUploader()
fake_garmin_b = FakeGarminUploader(error=GarminUploadBlocked("Garmin requested MFA"))
manager, garmin_factory_calls = _build_manager(
session_factory=session_factory,
cipher=cipher,
settings=settings,
user_a=user_a,
user_b=user_b,
fake_mw_a=fake_mw_a,
fake_mw_b=fake_mw_b,
fake_garmin_a=fake_garmin_a,
fake_garmin_b=fake_garmin_b,
)
results = await manager.sync_all_enabled()
results_by_user = {result.user_id: result for result in results}
assert results_by_user[user_a.id].status == "success"
assert results_by_user[user_b.id].status != "success"
assert results_by_user[user_b.id].status == "failed"
assert count_terminal_activities(session_factory, user_a.id) == 1
with session_factory() as session:
reloaded_b = UserRepository(session).get(user_b.id)
assert reloaded_b.health_state == HealthState.ACTION_REQUIRED
assert reloaded_b.action_reason == "garmin_mfa_required"
reloaded_a = UserRepository(session).get(user_a.id)
assert reloaded_a.health_state == HealthState.HEALTHY
assert reloaded_a.action_reason is None
activity_a = session.scalar(select(Activity).where(Activity.user_id == user_a.id))
assert activity_a.status in (ActivityStatus.IMPORTED, ActivityStatus.DUPLICATE)

View File

@@ -0,0 +1,30 @@
from pathlib import Path
from cryptography.fernet import Fernet
from fastapi.testclient import TestClient
from app.config import Settings
from app.main import create_app
from app.sync.manager import SyncManager
def test_lifespan_wires_sync_manager_and_scheduler(tmp_path: Path) -> None:
settings = Settings(
ADMIN_PASSWORD="admin-secret",
SECRET_KEY="0123456789abcdef0123456789abcdef",
CREDENTIAL_ENCRYPTION_KEY=Fernet.generate_key().decode("ascii"),
DATA_DIR=str(tmp_path),
DATABASE_URL=f"sqlite:///{tmp_path / 'app.db'}",
SYNC_INTERVAL_MINUTES=5,
)
app = create_app(settings)
# No users are seeded in this database, so sync_all_enabled() has nothing
# to iterate over and the real MyWhoosh/Garmin factories are never invoked.
with TestClient(app):
assert app.state.sync_manager is not None
assert isinstance(app.state.sync_manager, SyncManager)
assert app.state.scheduler is not None
assert app.state.scheduler.last_tick is not None
app.state.db_engine.dispose()

View File

@@ -0,0 +1,335 @@
from fastapi.testclient import TestClient
from app.db.repositories import UserRepository
from app.security.credentials import CredentialCipher
def extract_csrf(html: str) -> str:
marker = 'name="csrf_token" value="'
start = html.index(marker) + len(marker)
end = html.index('"', start)
return html[start:end]
def admin_login(client: TestClient) -> None:
page = client.get("/login")
csrf = extract_csrf(page.text)
response = client.post(
"/login",
data={"password": "admin-secret", "csrf_token": csrf},
follow_redirects=False,
)
assert response.status_code == 303
def create_user_via_admin(client: TestClient, **overrides) -> int:
admin_login(client)
page = client.get("/users/new")
csrf = extract_csrf(page.text)
payload = {
"csrf_token": csrf,
"name": "Max",
"mywhoosh_email": "max@mywhoosh.example",
"mywhoosh_password": "mw-secret",
"garmin_email": "max@garmin.example",
"garmin_password": "garmin-secret",
"enabled": "on",
}
payload.update(overrides)
response = client.post("/users", data=payload, follow_redirects=False)
assert response.status_code == 303
user_id = int(response.headers["location"].rsplit("/", 1)[-1])
client.cookies.clear()
return user_id
def account_login(client: TestClient, *, email: str, password: str):
page = client.get("/account-login")
csrf = extract_csrf(page.text)
return client.post(
"/account-login",
data={"csrf_token": csrf, "email": email, "password": password},
follow_redirects=False,
)
def test_login_with_mywhoosh_credentials_succeeds(client: TestClient) -> None:
create_user_via_admin(client)
response = account_login(client, email="max@mywhoosh.example", password="mw-secret")
assert response.status_code == 303
assert response.headers["location"] == "/account"
def test_login_with_garmin_credentials_succeeds(client: TestClient) -> None:
create_user_via_admin(client)
response = account_login(client, email="max@garmin.example", password="garmin-secret")
assert response.status_code == 303
assert response.headers["location"] == "/account"
def test_login_with_wrong_password_is_rejected(client: TestClient) -> None:
create_user_via_admin(client)
response = account_login(client, email="max@mywhoosh.example", password="wrong")
assert response.status_code == 401
assert "Invalid email or password" in response.text
def test_login_never_makes_the_stored_password_appear_in_response(client: TestClient) -> None:
create_user_via_admin(client)
response = account_login(client, email="max@mywhoosh.example", password="wrong")
assert "mw-secret" not in response.text
def test_account_detail_requires_login(client: TestClient) -> None:
response = client.get("/account", follow_redirects=False)
assert response.status_code == 303
assert response.headers["location"] == "/account-login"
def test_account_detail_shows_own_status_only(client: TestClient) -> None:
create_user_via_admin(client)
account_login(client, email="max@mywhoosh.example", password="mw-secret")
response = client.get("/account")
assert response.status_code == 200
assert "Max" in response.text
assert "mw-secret" not in response.text
assert "garmin-secret" not in response.text
def test_account_edit_page_prefills_emails_not_passwords(client: TestClient) -> None:
create_user_via_admin(client)
account_login(client, email="max@mywhoosh.example", password="mw-secret")
response = client.get("/account/edit")
assert response.status_code == 200
assert "max@mywhoosh.example" in response.text
assert "max@garmin.example" in response.text
assert "mw-secret" not in response.text
assert "garmin-secret" not in response.text
def test_account_update_blank_password_preserves_existing_password(client: TestClient) -> None:
user_id = create_user_via_admin(client)
account_login(client, email="max@mywhoosh.example", password="mw-secret")
edit_page = client.get("/account/edit")
csrf = extract_csrf(edit_page.text)
response = client.post(
"/account/edit",
data={
"csrf_token": csrf,
"mywhoosh_email": "max@mywhoosh.example",
"mywhoosh_password": "",
"garmin_email": "max@garmin.example",
"garmin_password": "",
},
follow_redirects=False,
)
assert response.status_code == 303
with client.app.state.session_factory() as session:
user = UserRepository(session).get(user_id)
cipher = CredentialCipher(client.app.state.settings.credential_encryption_key)
assert cipher.decrypt(user.mywhoosh_password_enc) == "mw-secret"
assert cipher.decrypt(user.garmin_password_enc) == "garmin-secret"
def test_account_update_can_set_new_password(client: TestClient) -> None:
user_id = create_user_via_admin(client)
account_login(client, email="max@mywhoosh.example", password="mw-secret")
edit_page = client.get("/account/edit")
csrf = extract_csrf(edit_page.text)
response = client.post(
"/account/edit",
data={
"csrf_token": csrf,
"mywhoosh_email": "max@mywhoosh.example",
"mywhoosh_password": "new-mw-secret",
"garmin_email": "max@garmin.example",
"garmin_password": "",
},
follow_redirects=False,
)
assert response.status_code == 303
with client.app.state.session_factory() as session:
user = UserRepository(session).get(user_id)
cipher = CredentialCipher(client.app.state.settings.credential_encryption_key)
assert cipher.decrypt(user.mywhoosh_password_enc) == "new-mw-secret"
def test_account_update_cannot_change_name_or_enabled(client: TestClient) -> None:
"""Self-service editing must not expose name/enabled -- those stay
administrative decisions, not something the account owner can flip."""
user_id = create_user_via_admin(client)
account_login(client, email="max@mywhoosh.example", password="mw-secret")
edit_page = client.get("/account/edit")
csrf = extract_csrf(edit_page.text)
client.post(
"/account/edit",
data={
"csrf_token": csrf,
"mywhoosh_email": "max@mywhoosh.example",
"mywhoosh_password": "",
"garmin_email": "max@garmin.example",
"garmin_password": "",
},
follow_redirects=False,
)
with client.app.state.session_factory() as session:
user = UserRepository(session).get(user_id)
assert user.name == "Max"
assert user.enabled is True
def test_account_update_persists_notification_preferences(client: TestClient) -> None:
user_id = create_user_via_admin(client)
account_login(client, email="max@mywhoosh.example", password="mw-secret")
edit_page = client.get("/account/edit")
csrf = extract_csrf(edit_page.text)
response = client.post(
"/account/edit",
data={
"csrf_token": csrf,
"mywhoosh_email": "max@mywhoosh.example",
"mywhoosh_password": "",
"garmin_email": "max@garmin.example",
"garmin_password": "",
"notify_email_enabled": "on",
"notification_email": "alerts@example.com",
},
follow_redirects=False,
)
assert response.status_code == 303
with client.app.state.session_factory() as session:
user = UserRepository(session).get(user_id)
assert user.notify_email_enabled is True
assert user.notification_email == "alerts@example.com"
def test_account_edit_rejects_invalid_csrf(client: TestClient) -> None:
create_user_via_admin(client)
account_login(client, email="max@mywhoosh.example", password="mw-secret")
response = client.post(
"/account/edit",
data={
"csrf_token": "invalid-token",
"mywhoosh_email": "max@mywhoosh.example",
"mywhoosh_password": "",
"garmin_email": "max@garmin.example",
"garmin_password": "",
},
)
assert response.status_code == 403
def test_logout_clears_session(client: TestClient) -> None:
create_user_via_admin(client)
account_login(client, email="max@mywhoosh.example", password="mw-secret")
page = client.get("/account")
csrf = extract_csrf(page.text)
response = client.post("/account-logout", data={"csrf_token": csrf}, follow_redirects=False)
assert response.status_code == 303
response = client.get("/account", follow_redirects=False)
assert response.status_code == 303
assert response.headers["location"] == "/account-login"
def test_cannot_view_other_users_account(client: TestClient) -> None:
"""Each self-service session is bound to the user_id captured at login;
another user created afterwards must not be reachable from it."""
create_user_via_admin(client, name="Max")
account_login(client, email="max@mywhoosh.example", password="mw-secret")
with client.app.state.session_factory() as session:
UserRepository(session).create(
name="Other",
enabled=True,
mywhoosh_email_enc="unused",
mywhoosh_password_enc="unused",
garmin_email_enc="unused",
garmin_password_enc="unused",
)
response = client.get("/account")
assert response.status_code == 200
assert "Max" in response.text
assert "Other" not in response.text
def test_account_sync_triggers_own_user_only(app, client: TestClient, fake_sync_manager) -> None:
user_id = create_user_via_admin(client)
account_login(client, email="max@mywhoosh.example", password="mw-secret")
app.state.sync_manager = fake_sync_manager
page = client.get("/account")
csrf = extract_csrf(page.text)
response = client.post("/account/sync", data={"csrf_token": csrf})
assert response.status_code == 200
assert fake_sync_manager.user_calls == [user_id]
def test_account_sync_reports_already_running_as_toast(app, client: TestClient, fake_sync_manager) -> None:
create_user_via_admin(client)
account_login(client, email="max@mywhoosh.example", password="mw-secret")
app.state.sync_manager = fake_sync_manager
fake_sync_manager.raise_already_running = True
page = client.get("/account")
csrf = extract_csrf(page.text)
response = client.post("/account/sync", data={"csrf_token": csrf})
assert response.status_code == 200
assert "already running" in response.text.lower()
def test_account_sync_updates_status_block_and_shows_toast(app, client: TestClient, fake_sync_manager) -> None:
create_user_via_admin(client)
account_login(client, email="max@mywhoosh.example", password="mw-secret")
app.state.sync_manager = fake_sync_manager
page = client.get("/account")
csrf = extract_csrf(page.text)
response = client.post("/account/sync", data={"csrf_token": csrf})
assert response.status_code == 200
assert 'id="account-status"' in response.text
assert 'hx-swap-oob="true"' in response.text
assert "0 imported, 0 failed" in response.text
def test_account_sync_requires_login(client: TestClient) -> None:
response = client.post("/account/sync", data={"csrf_token": "whatever"}, follow_redirects=False)
assert response.status_code == 303
assert response.headers["location"] == "/account-login"
def test_account_sync_rejects_invalid_csrf(app, client: TestClient, fake_sync_manager) -> None:
create_user_via_admin(client)
account_login(client, email="max@mywhoosh.example", password="mw-secret")
app.state.sync_manager = fake_sync_manager
response = client.post("/account/sync", data={"csrf_token": "invalid-token"})
assert response.status_code == 403
assert fake_sync_manager.user_calls == []

View File

@@ -0,0 +1,43 @@
from datetime import datetime, timedelta, timezone
from app.db.models import HealthState, SyncRunStatus
from app.db.repositories import SyncRunRepository, UserRepository
def _seed_user(session, name, *, enabled=True, health_state=HealthState.HEALTHY):
return UserRepository(session).create(
name=name,
enabled=enabled,
health_state=health_state,
mywhoosh_email_enc=f"mw-{name}",
mywhoosh_password_enc=f"mw-pw-{name}",
garmin_email_enc=f"g-{name}",
garmin_password_enc=f"g-pw-{name}",
)
def test_dashboard_shows_summary_tiles(app, authenticated_client) -> None:
with app.state.session_factory() as session:
alex = _seed_user(session, "Alex", enabled=True)
_seed_user(session, "Jamie", enabled=False, health_state=HealthState.ACTION_REQUIRED)
run_repo = SyncRunRepository(session)
run = run_repo.start(alex.id)
run.started_at = datetime.now(timezone.utc) - timedelta(hours=2)
run_repo.finish(run.id, status=SyncRunStatus.SUCCESS, discovered=3, imported=3, skipped=0, failed=0)
response = authenticated_client.get("/")
assert response.status_code == 200
assert '<span class="stat-tile-value">2</span>' in response.text
assert "1 enabled" in response.text
assert '<span class="stat-tile-value">3</span>' in response.text
assert '<span class="stat-tile-value">100%</span>' in response.text
assert '<span class="stat-tile-value">1</span>' in response.text
def test_dashboard_shows_dash_for_success_rate_without_recent_runs(authenticated_client) -> None:
response = authenticated_client.get("/")
assert response.status_code == 200
assert '<span class="stat-tile-value">&ndash;</span>' in response.text

139
tests/web/test_mfa.py Normal file
View File

@@ -0,0 +1,139 @@
import logging
from sqlalchemy import text
from app.db.models import Activity, ActivityStatus
def test_mfa_code_is_used_once_and_not_persisted(authenticated_client, fake_sync_manager, app, caplog) -> None:
with caplog.at_level(logging.DEBUG):
response = authenticated_client.post(
"/users/1/garmin-mfa",
data={"csrf_token": authenticated_client.csrf_token, "code": "123456"},
)
assert response.status_code == 200
assert fake_sync_manager.mfa_calls == [(1, "123456")]
# Defense-in-depth: the MFA code must never end up written to the real
# app database (the one authenticated_client's requests actually hit),
# not some unrelated in-memory db.
with app.state.session_factory() as session:
persisted_text = " ".join(str(row) for row in session.execute(text("select * from sync_runs")).all())
assert "123456" not in persisted_text
# The check that actually matters: the code must never be logged,
# regardless of which SyncManager implementation is in play.
assert "123456" not in caplog.text
def test_mfa_code_rejects_empty_code(authenticated_client, fake_sync_manager) -> None:
response = authenticated_client.post(
"/users/1/garmin-mfa",
data={"csrf_token": authenticated_client.csrf_token, "code": " "},
)
assert response.status_code == 400
assert fake_sync_manager.mfa_calls == []
def test_mfa_code_rejects_overlong_code(authenticated_client, fake_sync_manager) -> None:
response = authenticated_client.post(
"/users/1/garmin-mfa",
data={"csrf_token": authenticated_client.csrf_token, "code": "1" * 21},
)
assert response.status_code == 400
assert fake_sync_manager.mfa_calls == []
def _seed_activity(app, *, status: ActivityStatus, last_completed_stage: ActivityStatus, retryable: bool, last_error: str | None = None) -> tuple[int, int]:
"""Seed a real Activity (and its owning user) in the app fixture's actual
database -- the same database authenticated_client's HTTP requests hit --
and return (user_id, activity_id)."""
from app.db.repositories import ActivityRepository, UserRepository
with app.state.session_factory() as session:
user = UserRepository(session).create(
name="MFA Test User",
enabled=True,
mywhoosh_email_enc="mw@example.com",
mywhoosh_password_enc="mw-pass",
garmin_email_enc="garmin@example.com",
garmin_password_enc="garmin-pass",
)
activity_repo = ActivityRepository(session)
activity, _ = activity_repo.get_or_create_discovered(
user_id=user.id,
mywhoosh_activity_id="mw-activity-1",
activity_name="Test Activity",
activity_timestamp=None,
)
activity.status = status
activity.last_completed_stage = last_completed_stage
activity.retryable = retryable
if last_error is not None:
activity.last_error = last_error
session.commit()
return user.id, activity.id
def test_retry_resets_and_calls_sync_for_retryable_failed_activity(authenticated_client, fake_sync_manager, app) -> None:
user_id, activity_id = _seed_activity(
app,
status=ActivityStatus.FAILED,
last_completed_stage=ActivityStatus.CONVERTED,
retryable=True,
last_error="some transient error",
)
response = authenticated_client.post(
f"/activities/{activity_id}/retry",
data={"csrf_token": authenticated_client.csrf_token},
)
assert response.status_code == 200
assert fake_sync_manager.user_calls == [user_id]
with app.state.session_factory() as session:
reloaded = session.get(Activity, activity_id)
assert reloaded.status == ActivityStatus.CONVERTED
assert reloaded.last_error is None
def test_retry_rejects_non_retryable_activity(authenticated_client, fake_sync_manager, app) -> None:
user_id, activity_id = _seed_activity(
app,
status=ActivityStatus.FAILED,
last_completed_stage=ActivityStatus.CONVERTED,
retryable=False,
last_error="permanent failure",
)
response = authenticated_client.post(
f"/activities/{activity_id}/retry",
data={"csrf_token": authenticated_client.csrf_token},
)
assert response.status_code == 409
assert fake_sync_manager.user_calls == []
with app.state.session_factory() as session:
reloaded = session.get(Activity, activity_id)
assert reloaded.status == ActivityStatus.FAILED
assert reloaded.retryable is False
assert reloaded.last_error == "permanent failure"
def test_retry_rejects_non_failed_activity(authenticated_client, fake_sync_manager, app) -> None:
user_id, activity_id = _seed_activity(
app,
status=ActivityStatus.DISCOVERED,
last_completed_stage=ActivityStatus.DISCOVERED,
retryable=True,
)
response = authenticated_client.post(
f"/activities/{activity_id}/retry",
data={"csrf_token": authenticated_client.csrf_token},
)
assert response.status_code == 409
assert fake_sync_manager.user_calls == []

View File

@@ -0,0 +1,36 @@
from datetime import datetime, timezone
from fastapi.testclient import TestClient
class _FakeScheduler:
def __init__(self, next_tick=None) -> None:
self.last_tick = None
self.next_tick = next_tick
def test_login_page_shows_next_sync_time(app, client: TestClient) -> None:
next_tick = datetime(2026, 8, 16, 14, 32, tzinfo=timezone.utc)
app.state.scheduler = _FakeScheduler(next_tick=next_tick)
response = client.get("/login")
assert response.status_code == 200
assert 'data-utc="2026-08-16T14:32:00+00:00"' in response.text
def test_dashboard_shows_next_sync_time(app, authenticated_client) -> None:
next_tick = datetime(2026, 8, 16, 15, 0, tzinfo=timezone.utc)
app.state.scheduler = _FakeScheduler(next_tick=next_tick)
response = authenticated_client.get("/")
assert response.status_code == 200
assert 'data-utc="2026-08-16T15:00:00+00:00"' in response.text
def test_login_page_renders_without_scheduler(client: TestClient) -> None:
response = client.get("/login")
assert response.status_code == 200
assert "data-utc" not in response.text

View File

@@ -0,0 +1,260 @@
from fastapi.testclient import TestClient
from app.db.repositories import SchedulerSettingsRepository, SystemLogRepository, UserRepository
from app.sync.states import SyncOutcome
def _create_user(app, name="Alex") -> int:
with app.state.session_factory() as session:
user = UserRepository(session).create(
name=name,
enabled=True,
mywhoosh_email_enc="mw",
mywhoosh_password_enc="mw-pw",
garmin_email_enc="g",
garmin_password_enc="g-pw",
)
return user.id
def test_manual_sync_calls_shared_manager(authenticated_client, fake_sync_manager) -> None:
response = authenticated_client.post(
"/users/1/sync",
data={"csrf_token": authenticated_client.csrf_token},
)
assert response.status_code == 200
assert fake_sync_manager.user_calls == [1]
def test_manual_sync_updates_row_and_shows_toast(app, authenticated_client, fake_sync_manager) -> None:
user_id = _create_user(app, "Alex")
response = authenticated_client.post(
f"/users/{user_id}/sync",
data={"csrf_token": authenticated_client.csrf_token},
)
assert response.status_code == 200
assert f'id="user-row-{user_id}"' in response.text
assert 'hx-swap-oob="true"' in response.text
assert "Alex" in response.text
assert "0 imported, 0 failed" in response.text
def test_manual_sync_reports_already_running_as_toast(authenticated_client, fake_sync_manager) -> None:
fake_sync_manager.raise_already_running = True
response = authenticated_client.post(
"/users/1/sync",
data={"csrf_token": authenticated_client.csrf_token},
)
assert response.status_code == 200
assert "already running" in response.text.lower()
def test_sync_all_calls_shared_manager(authenticated_client, fake_sync_manager) -> None:
response = authenticated_client.post(
"/sync-all",
data={"csrf_token": authenticated_client.csrf_token},
)
assert response.status_code == 200
assert fake_sync_manager.all_calls == 1
def test_sync_all_updates_each_affected_row_and_shows_summary_toast(app, authenticated_client, fake_sync_manager) -> None:
user_id = _create_user(app, "Alex")
async def fake_sync_all_enabled():
return [SyncOutcome(user_id=user_id, status="success", discovered=2, imported=2, skipped=0, failed=0)]
fake_sync_manager.sync_all_enabled = fake_sync_all_enabled
response = authenticated_client.post(
"/sync-all",
data={"csrf_token": authenticated_client.csrf_token},
)
assert response.status_code == 200
assert f'id="user-row-{user_id}"' in response.text
assert "Synced 1 riders" in response.text
def test_sync_all_shows_toast_when_nothing_to_sync(authenticated_client, fake_sync_manager) -> None:
response = authenticated_client.post(
"/sync-all",
data={"csrf_token": authenticated_client.csrf_token},
)
assert response.status_code == 200
assert "No riders to sync" in response.text
def test_manual_sync_requires_admin(client: TestClient) -> None:
response = client.post(
"/users/1/sync",
data={"csrf_token": "whatever"},
follow_redirects=False,
)
assert response.status_code == 303
assert response.headers["location"] == "/login"
def test_manual_sync_rejects_invalid_csrf(authenticated_client) -> None:
response = authenticated_client.post(
"/users/1/sync",
data={"csrf_token": "invalid-token"},
)
assert response.status_code == 403
def test_system_page_shows_scheduler_state(app, authenticated_client) -> None:
class FakeScheduler:
def __init__(self) -> None:
self.last_tick = None
self.next_tick = None
app.state.scheduler = FakeScheduler()
response = authenticated_client.get("/system")
assert response.status_code == 200
assert "1.0.0" in response.text
assert "5" in response.text # sync_interval_minutes
assert "0" in response.text # user_count / activity_count fresh DB
class _FakeScheduler:
def __init__(self) -> None:
self.last_tick = None
self.next_tick = None
def test_system_page_shows_empty_log_state(app, authenticated_client) -> None:
app.state.scheduler = _FakeScheduler()
response = authenticated_client.get("/system")
assert response.status_code == 200
assert "No system log entries" in response.text
def test_system_page_shows_recorded_log_entries(app, authenticated_client) -> None:
app.state.scheduler = _FakeScheduler()
with app.state.session_factory() as session:
SystemLogRepository(session).add(
source="email_notification", message="Failed to email alerts@example.com: SMTP timeout"
)
response = authenticated_client.get("/system")
assert response.status_code == 200
assert "email_notification" in response.text
assert "SMTP timeout" in response.text
def _extract_csrf(html: str) -> str:
marker = 'name="csrf_token" value="'
start = html.index(marker) + len(marker)
end = html.index('"', start)
return html[start:end]
def test_scheduler_settings_page_prefills_defaults(app, authenticated_client) -> None:
app.state.scheduler = _FakeScheduler()
response = authenticated_client.get("/system")
assert response.status_code == 200
assert 'id="day_start_hour"' in response.text
assert 'value="6"' in response.text
assert 'value="22"' in response.text
def test_update_scheduler_settings_persists_and_takes_effect_next_tick(app, authenticated_client) -> None:
app.state.scheduler = _FakeScheduler()
page = authenticated_client.get("/system")
csrf = _extract_csrf(page.text)
response = authenticated_client.post(
"/system/scheduler-settings",
data={
"csrf_token": csrf,
"day_start_hour": "8",
"night_start_hour": "20",
"day_interval_minutes": "3",
"night_interval_minutes": "45",
},
follow_redirects=False,
)
assert response.status_code == 303
assert response.headers["location"] == "/system"
with app.state.session_factory() as session:
row = SchedulerSettingsRepository(session).get_or_create(default_minutes=5)
assert row.day_start_hour == 8
assert row.night_start_hour == 20
assert row.day_interval_minutes == 3
assert row.night_interval_minutes == 45
def test_update_scheduler_settings_rejects_out_of_range_hour(app, authenticated_client) -> None:
app.state.scheduler = _FakeScheduler()
page = authenticated_client.get("/system")
csrf = _extract_csrf(page.text)
response = authenticated_client.post(
"/system/scheduler-settings",
data={
"csrf_token": csrf,
"day_start_hour": "24",
"night_start_hour": "22",
"day_interval_minutes": "5",
"night_interval_minutes": "5",
},
)
assert response.status_code == 400
def test_update_scheduler_settings_rejects_non_positive_interval(app, authenticated_client) -> None:
app.state.scheduler = _FakeScheduler()
page = authenticated_client.get("/system")
csrf = _extract_csrf(page.text)
response = authenticated_client.post(
"/system/scheduler-settings",
data={
"csrf_token": csrf,
"day_start_hour": "6",
"night_start_hour": "22",
"day_interval_minutes": "0",
"night_interval_minutes": "5",
},
)
assert response.status_code == 400
def test_update_scheduler_settings_requires_admin(client: TestClient) -> None:
response = client.post(
"/system/scheduler-settings",
data={
"csrf_token": "whatever",
"day_start_hour": "6",
"night_start_hour": "22",
"day_interval_minutes": "5",
"night_interval_minutes": "5",
},
follow_redirects=False,
)
assert response.status_code == 303
assert response.headers["location"] == "/login"
def test_update_scheduler_settings_rejects_invalid_csrf(app, authenticated_client) -> None:
app.state.scheduler = _FakeScheduler()
response = authenticated_client.post(
"/system/scheduler-settings",
data={
"csrf_token": "invalid-token",
"day_start_hour": "6",
"night_start_hour": "22",
"day_interval_minutes": "5",
"night_interval_minutes": "5",
},
)
assert response.status_code == 403

View File

@@ -0,0 +1,12 @@
import re
from fastapi.testclient import TestClient
def test_static_assets_are_served_with_a_cache_busting_version(client: TestClient) -> None:
response = client.get("/login")
assert response.status_code == 200
assert re.search(r'/static/style\.css\?v=[0-9a-f]{6,}"', response.text)
assert re.search(r'/static/app\.js\?v=[0-9a-f]{6,}"', response.text)
assert re.search(r'/static/htmx\.min\.js\?v=[0-9a-f]{6,}"', response.text)

View File

@@ -1,6 +1,7 @@
from fastapi.testclient import TestClient
from app.db.repositories import UserRepository
from app.db.models import SyncRunStatus
from app.db.repositories import SyncRunRepository, UserRepository
from app.security.credentials import CredentialCipher
@@ -171,6 +172,30 @@ def test_user_detail_page_shows_no_secrets(client: TestClient) -> None:
assert "Max" in response.text
def test_user_detail_page_shows_recent_sync_runs(client: TestClient) -> None:
login(client)
user_id = create_user_via_http(client)
with client.app.state.session_factory() as session:
repo = SyncRunRepository(session)
run = repo.start(user_id)
repo.finish(
run.id,
status=SyncRunStatus.SUCCESS,
discovered=3,
imported=2,
skipped=1,
failed=0,
)
response = client.get(f"/users/{user_id}")
assert response.status_code == 200
assert "Recent sync runs" in response.text
assert "success" in response.text
assert ">3<" in response.text
assert ">2<" in response.text
def test_unknown_user_returns_404_for_detail(client: TestClient) -> None:
login(client)
response = client.get("/users/999999")
@@ -341,6 +366,106 @@ def test_update_user_rejects_empty_mywhoosh_email(client: TestClient) -> None:
assert response.status_code == 400
def test_create_user_without_notifications_requires_no_email(client: TestClient) -> None:
login(client)
page = client.get("/users/new")
csrf = extract_csrf(page.text)
response = client.post(
"/users",
data={"csrf_token": csrf, **_create_payload()},
follow_redirects=False,
)
assert response.status_code == 303
def test_create_user_rejects_notify_enabled_without_email(client: TestClient) -> None:
login(client)
page = client.get("/users/new")
csrf = extract_csrf(page.text)
response = client.post(
"/users",
data={"csrf_token": csrf, **_create_payload(notify_email_enabled="on")},
)
assert response.status_code == 400
def test_create_user_persists_notification_preferences(client: TestClient) -> None:
login(client)
page = client.get("/users/new")
csrf = extract_csrf(page.text)
response = client.post(
"/users",
data={
"csrf_token": csrf,
**_create_payload(notify_email_enabled="on", notification_email="alerts@example.com"),
},
follow_redirects=False,
)
assert response.status_code == 303
user_id = int(response.headers["location"].rsplit("/", 1)[-1])
with client.app.state.session_factory() as session:
user = UserRepository(session).get(user_id)
assert user is not None
assert user.notify_email_enabled is True
assert user.notification_email == "alerts@example.com"
def test_update_user_can_enable_notifications_without_reentering_passwords(client: TestClient) -> None:
login(client)
user_id = create_user_via_http(client)
edit_page = client.get(f"/users/{user_id}/edit")
csrf = extract_csrf(edit_page.text)
response = client.post(
f"/users/{user_id}",
data={
"csrf_token": csrf,
"name": "Max",
"mywhoosh_email": "max@example.com",
"mywhoosh_password": "",
"garmin_email": "max-garmin@example.com",
"garmin_password": "",
"enabled": "on",
"notify_email_enabled": "on",
"notification_email": "alerts@example.com",
},
follow_redirects=True,
)
assert response.status_code == 200
with client.app.state.session_factory() as session:
user = UserRepository(session).get(user_id)
assert user is not None
assert user.notify_email_enabled is True
assert user.notification_email == "alerts@example.com"
cipher = CredentialCipher(client.app.state.settings.credential_encryption_key)
assert cipher.decrypt(user.mywhoosh_password_enc) == "mw-secret"
assert cipher.decrypt(user.garmin_password_enc) == "garmin-secret"
def test_update_user_rejects_notify_enabled_without_email(client: TestClient) -> None:
login(client)
user_id = create_user_via_http(client)
edit_page = client.get(f"/users/{user_id}/edit")
csrf = extract_csrf(edit_page.text)
response = client.post(
f"/users/{user_id}",
data={
"csrf_token": csrf,
"name": "Max",
"mywhoosh_email": "max@example.com",
"mywhoosh_password": "",
"garmin_email": "max-garmin@example.com",
"garmin_password": "",
"enabled": "on",
"notify_email_enabled": "on",
"notification_email": "",
},
)
assert response.status_code == 400
def test_update_user_rejects_empty_garmin_email(client: TestClient) -> None:
login(client)
user_id = create_user_via_http(client)