9 Commits

Author SHA1 Message Date
release-bot 36022cd5f4 bump: version 0.1.0 → 0.2.0 2026-07-20 14:07:18 +00:00
Chris Farhood 272519d8b0 ci: run the test job on uv instead of setup-python
build-image / test (push) Successful in 11s
build-image / build (push) Successful in 3s
Replace actions/setup-python (which fails on a cold runner toolcache with a
broken python-versions prebuilt) with the self-contained uv installer, matching
release.yaml. Uses `uv sync --all-extras --locked --python 3.12` and
`uv run --locked pytest`, so the test gate — and therefore the versioned image
build behind it — no longer depends on a warm toolcache.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NGzHtDvJur9U7ysgRKRUTN
2026-07-20 10:02:42 -04:00
Chris Farhood 7f8500199d ci: tag release image with semver so it can be pinned
Trigger the image build on v* tags and, on a version tag, publish the semver
(X.Y.Z) alongside :latest and the commit SHA. Previously only :latest and
:<sha> were pushed and the build never fired on the release tag, so there was
no stable version to pin against.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NGzHtDvJur9U7ysgRKRUTN
2026-07-20 09:59:26 -04:00
Chris Farhood f1ac56609e fix(wellness): harden Form/TSB and date rendering from code review
Address findings from the pre-merge review:
- Form (TSB) now computes only when ctl/atl are numeric (isinstance guard),
  so a non-numeric value can no longer raise out of format_wellness_entry and
  take down the entire wellness render.
- The Date line uses `or` chaining so a present-but-null `date` falls back to
  `id` instead of rendering "Date: None".
- update_wellness injects the written date into the API echo when it lacks
  id/date, so the confirmation body can't read "Date: N/A" under a dated header.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NGzHtDvJur9U7ysgRKRUTN
2026-07-20 09:51:39 -04:00
Chris Farhood ba441aa1ee chore: clean up pre-existing ruff violations in tests
Remove unused imports (F401) and strip trailing whitespace (W291/W293) flagged
by ruff in test files. Pre-existing debt unrelated to any single feature; CI runs
pytest but not ruff, so these had accumulated. No behavior change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NGzHtDvJur9U7ysgRKRUTN
2026-07-20 09:34:27 -04:00
Chris Farhood f2159d3ca5 feat(wellness): add update_wellness write tool, computed Form (TSB), date-label fix
Add an update_wellness MCP tool that writes nutrition macros, hydration, vitals,
sleep, and subjective ratings to Intervals.icu via PUT /athlete/{id}/wellness/{date}.
Only provided fields are sent; pass -1 to clear a numeric field and locked=True to
stop device/app syncs from overwriting the values. Sleep is taken in hours and
stored as seconds (with -1 passing through as the clear sentinel).

Also surface computed Form (TSB = CTL - ATL) in wellness output, and prefer an
explicit `date` field over the record `id` for the Date label.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NGzHtDvJur9U7ysgRKRUTN
2026-07-20 09:28:31 -04:00
Chris Farhood 74fb09972a fix: reset version to 0.1.0 baseline; let cz bump own versioning
The manual 0.1.0->0.2.0 bump conflicted with the automated release
workflow, which would have double-bumped to 0.3.0. Version is now driven
solely by `cz bump` from conventional commits (last tag v0.1.0 is the
baseline); CHANGELOG entries are generated at release time.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NGzHtDvJur9U7ysgRKRUTN
2026-07-20 09:07:56 -04:00
Chris Farhood 407239296b ci: add release workflow (manual dispatch: cz bump + tag + Gitea release)
Cutting a release is now a workflow, not a local command. Uses uv/commitizen
(actions/setup-python is broken on the current runners). Supports auto-detect or
forced increment, and a dry_run mode.
2026-07-20 08:49:04 -04:00
Chris Farhood f766e68d0d chore: set up semantic versioning (Commitizen + CHANGELOG); bump 0.1.0 -> 0.2.0
Establishes the release process for the upcoming nutrition/wellness features (a
minor bump). Source of truth is pyproject [project].version; `uvx cz bump` derives
future versions from conventional commits and tags vX.Y.Z.
2026-07-20 08:43:11 -04:00
15 changed files with 496 additions and 34 deletions
+28 -16
View File
@@ -3,6 +3,7 @@ name: build-image
on:
push:
branches: [main]
tags: ["v*"]
workflow_dispatch:
permissions:
@@ -18,13 +19,14 @@ jobs:
ATHLETE_ID: i1
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install (editable, with dev extras)
run: pip install --quiet -e ".[dev]"
- name: Install uv (self-contained; avoids the cold-cache setup-python failure)
run: |
curl -LsSf https://astral.sh/uv/install.sh | sh
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
- name: Install dependencies (Python 3.12, locked)
run: uv sync --all-extras --locked --python 3.12
- name: Test + coverage gate (>=90%)
run: pytest
run: uv run --locked pytest
build:
needs: test
@@ -46,15 +48,25 @@ jobs:
run: |
echo "${{ secrets.REGISTRY_TOKEN }}" | docker login git.farh.net -u cpfarhood --password-stdin
- name: Build image
- name: Build and push image
run: |
docker build --progress=plain \
-t "${IMAGE}:latest" \
-t "${IMAGE}:${GITHUB_SHA}" \
.
# Always tag the immutable commit SHA. On a version tag (refs/tags/vX.Y.Z)
# also publish the semver (X.Y.Z) so images can be pinned, and move :latest.
# On a main-branch push, publish :latest.
REFS="${IMAGE}:${GITHUB_SHA}"
case "${GITHUB_REF}" in
refs/tags/v*)
VERSION="${GITHUB_REF#refs/tags/v}"
REFS="${REFS} ${IMAGE}:${VERSION} ${IMAGE}:latest"
;;
*)
REFS="${REFS} ${IMAGE}:latest"
;;
esac
- name: Push image
run: |
docker push "${IMAGE}:latest"
docker push "${IMAGE}:${GITHUB_SHA}"
echo "pushed ${IMAGE}:latest and ${IMAGE}:${GITHUB_SHA}"
BUILD_ARGS=""
for r in ${REFS}; do BUILD_ARGS="${BUILD_ARGS} -t ${r}"; done
docker build --progress=plain ${BUILD_ARGS} .
for r in ${REFS}; do docker push "${r}"; done
echo "pushed: ${REFS}"
+88
View File
@@ -0,0 +1,88 @@
name: release
# Cut a release as an action, not a local command. Manually triggered ("Run
# workflow"): bumps the version from conventional commits (or a forced level),
# updates CHANGELOG.md, tags vX.Y.Z, pushes both, and creates a Gitea release.
# Uses uv/commitizen because actions/setup-python is broken on these runners.
on:
workflow_dispatch:
inputs:
increment:
description: "Bump level (auto = detect from conventional commits)"
required: false
default: auto
type: choice
options: [auto, PATCH, MINOR, MAJOR]
dry_run:
description: "Dry run — compute + show, but do NOT push or release"
required: false
default: false
type: boolean
permissions:
contents: write
jobs:
release:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout (full history + tags)
uses: actions/checkout@v4
with:
fetch-depth: 0
# Needs push + release rights. The auto token works with contents:write;
# if your runner's token can't push, set a PAT secret RELEASE_TOKEN.
token: ${{ secrets.RELEASE_TOKEN || github.token }}
- name: Install uv (self-contained; avoids the broken setup-python)
run: |
curl -LsSf https://astral.sh/uv/install.sh | sh
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
- name: Git identity
run: |
git config user.name "release-bot"
git config user.email "release-bot@farhoodlabs.com"
- name: Bump version + changelog + tag
id: bump
run: |
before=$(uvx --from commitizen cz version --project)
args="--yes"
[ "${{ inputs.increment }}" != "auto" ] && args="$args --increment ${{ inputs.increment }}"
if [ "${{ inputs.dry_run }}" = "true" ]; then
echo "== DRY RUN =="
uvx --from commitizen cz bump $args --dry-run || true
echo "do_release=false" >> "$GITHUB_OUTPUT"
exit 0
fi
if uvx --from commitizen cz bump $args; then
after=$(uvx --from commitizen cz version --project)
echo "version=$after" >> "$GITHUB_OUTPUT"
echo "do_release=true" >> "$GITHUB_OUTPUT"
echo "Bumped $before -> $after"
else
echo "No releasable commits since the last tag — nothing to do."
echo "do_release=false" >> "$GITHUB_OUTPUT"
fi
- name: Push commit + tag
if: ${{ steps.bump.outputs.do_release == 'true' }}
run: git push origin HEAD:${{ github.ref_name }} --follow-tags
- name: Create Gitea release
if: ${{ steps.bump.outputs.do_release == 'true' }}
run: |
v="${{ steps.bump.outputs.version }}"
# Body = this version's section from CHANGELOG.md (fallback to a stub).
body=$(awk "/^## \\[$v\\]/{f=1;next} /^## \\[/{f=0} f" CHANGELOG.md)
[ -z "$body" ] && body="Release v$v"
jq -n --arg tag "v$v" --arg name "v$v" --arg body "$body" \
'{tag_name:$tag, name:$name, body:$body, draft:false, prerelease:false}' \
| curl -sf -X POST \
-H "Authorization: token ${{ secrets.RELEASE_TOKEN || github.token }}" \
-H "Content-Type: application/json" \
--data @- \
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases"
echo "Created release v$v"
+36
View File
@@ -0,0 +1,36 @@
# Changelog
All notable changes to this project are documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this
project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). While the
project is pre-1.0, new features bump the **minor** version and fixes bump the **patch**
version. Versions are cut with Commitizen (`uvx --from commitizen cz bump`), which updates
this file and tags `vX.Y.Z`.
## [0.1.0] — 2026-07-20
Baseline: multi-tenant fork of `intervals-mcp-server`. Per-user OAuth credentials with
AES-256-GCM-encrypted Intervals.icu API keys (Better Auth), streamable-HTTP transport with
CORS, and the full activity / event / wellness / power-curve / gear / custom-item toolset.
[0.1.0]: https://git.farh.net/farhoodlabs/intervalsicu-mcp/releases/tag/v0.1.0
## v0.2.0 (2026-07-20)
### Feat
- **wellness**: add update_wellness write tool, computed Form (TSB), date-label fix
### Fix
- **wellness**: harden Form/TSB and date rendering from code review
- reset version to 0.1.0 baseline; let cz bump own versioning
## v0.1.0 (2026-07-20)
### Feat
- **db**: Alembic migration for users table (async env, DATABASE_URL)
- **multi-tenant**: resolve per-caller credentials in every tool
- **multi-tenant**: data layer, encryption, and per-request credential resolver
+12 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "intervalsicu-mcp"
version = "0.1.0"
version = "0.2.0"
description = "A Model Context Protocol server for Intervals.icu (FastMCP, native OAuth)"
readme = { file = "README.md", content-type = "text/markdown" }
requires-python = ">=3.12"
@@ -124,3 +124,14 @@ asyncio_default_fixture_loop_scope = "function"
[tool.uv]
index-url = "https://pypi.org/simple"
# Semantic versioning via Commitizen (conventional commits). Source of truth is the
# [project].version above. Bump + changelog + tag with: uvx --from commitizen cz bump
# (feat -> minor, fix -> patch; pre-1.0 so breaking changes stay in 0.x). Tags are vX.Y.Z.
[tool.commitizen]
name = "cz_conventional_commits"
version_provider = "pep621"
tag_format = "v$version"
major_version_zero = true
update_changelog_on_bump = true
changelog_file = "CHANGELOG.md"
+6 -1
View File
@@ -86,7 +86,10 @@ from intervals_mcp_server.tools.events import ( # pylint: disable=wrong-import-
get_events,
)
from intervals_mcp_server.tools.gear import get_gear_list # pylint: disable=wrong-import-position # noqa: E402
from intervals_mcp_server.tools.wellness import get_wellness_data # pylint: disable=wrong-import-position # noqa: E402
from intervals_mcp_server.tools.wellness import ( # pylint: disable=wrong-import-position # noqa: E402
get_wellness_data,
update_wellness,
)
from intervals_mcp_server.tools.power_curves import get_athlete_power_curves # pylint: disable=wrong-import-position # noqa: E402
from intervals_mcp_server.tools.custom_items import ( # pylint: disable=wrong-import-position # noqa: E402
create_custom_item,
@@ -113,6 +116,8 @@ __all__ = [
"delete_events_by_date_range",
"add_or_update_event",
"get_wellness_data",
"update_wellness",
"get_gear_list",
"get_athlete_power_curves",
"get_custom_items",
"get_custom_item_by_id",
+5 -1
View File
@@ -32,7 +32,10 @@ from intervals_mcp_server.tools.power_curves import ( # noqa: F401
get_athlete_power_curves,
)
from intervals_mcp_server.tools.gear import get_gear_list # noqa: F401
from intervals_mcp_server.tools.wellness import get_wellness_data # noqa: F401
from intervals_mcp_server.tools.wellness import ( # noqa: F401
get_wellness_data,
update_wellness,
)
def register_tools(mcp_instance: FastMCP) -> None:
@@ -70,4 +73,5 @@ __all__ = [
"get_athlete_power_curves",
"get_gear_list",
"get_wellness_data",
"update_wellness",
]
+123 -1
View File
@@ -4,11 +4,14 @@ Wellness-related MCP tools for Intervals.icu.
This module contains tools for retrieving athlete wellness data.
"""
from datetime import datetime
from typing import Any
from intervals_mcp_server import credentials
from intervals_mcp_server.api.client import make_intervals_request
from intervals_mcp_server.credentials import CredentialError
from intervals_mcp_server.utils.formatting import format_wellness_entry
from intervals_mcp_server.utils.validation import resolve_date_params
from intervals_mcp_server.utils.validation import resolve_date_params, validate_date
# Import mcp instance from shared module for tool registration
from intervals_mcp_server.mcp_instance import mcp # noqa: F401
@@ -65,3 +68,122 @@ async def get_wellness_data(
wellness_summary += format_wellness_entry(entry, include_all_fields=include_all_fields) + "\n\n"
return wellness_summary
@mcp.tool()
async def update_wellness( # pylint: disable=too-many-arguments,too-many-positional-arguments,too-many-locals
date: str | None = None,
weight: float | None = None,
resting_hr: int | None = None,
hrv: float | None = None,
sleep_hours: float | None = None,
sleep_quality: int | None = None,
calories_consumed: int | None = None,
carbohydrates: float | None = None,
protein: float | None = None,
fat: float | None = None,
hydration_volume: float | None = None,
hydration_score: int | None = None,
soreness: int | None = None,
fatigue: int | None = None,
stress: int | None = None,
mood: int | None = None,
motivation: int | None = None,
injury: int | None = None,
comments: str | None = None,
locked: bool | None = None,
) -> str:
"""Create or update the signed-in athlete's wellness record for a single day.
Writes nutrition, hydration, vitals, sleep, and subjective ratings to
Intervals.icu (PUT /athlete/{id}/wellness/{date}). Only the fields you pass are
sent; anything omitted is left untouched. To CLEAR an existing numeric value,
pass ``-1``. Set ``locked=True`` to stop Intervals.icu from overwriting these
values on its next sync from a connected device or app.
Args:
date: Day to update in YYYY-MM-DD format (optional, defaults to today).
weight: Body weight in kg.
resting_hr: Resting heart rate in bpm.
hrv: Heart rate variability (rMSSD).
sleep_hours: Sleep duration in hours (stored by Intervals.icu as seconds).
Pass -1 to clear.
sleep_quality: Sleep quality rating, as used in the Intervals.icu app.
calories_consumed: Energy intake in kcal.
carbohydrates: Carbohydrate intake in grams.
protein: Protein intake in grams.
fat: Fat intake in grams.
hydration_volume: Fluid intake volume, in your Intervals.icu units.
hydration_score: Subjective hydration score, as used in the app.
soreness: Subjective soreness rating, as used in the Intervals.icu app.
fatigue: Subjective fatigue rating, as used in the Intervals.icu app.
stress: Subjective stress rating, as used in the Intervals.icu app.
mood: Subjective mood rating, as used in the Intervals.icu app.
motivation: Subjective motivation rating, as used in the Intervals.icu app.
injury: Injury level rating, as used in the Intervals.icu app.
comments: Free-text note for the day.
locked: If True, lock the record so device/app syncs won't overwrite it.
"""
try:
athlete_id_to_use, api_key = await credentials.resolve_caller_credentials()
except CredentialError as exc:
return str(exc)
if not date:
date = datetime.now().strftime("%Y-%m-%d")
try:
date = validate_date(date)
except ValueError as exc:
return f"Error: {exc}"
# Sleep is passed in hours but stored as seconds; -1 is the clear sentinel and
# must pass through unscaled.
sleep_secs: int | None = None
if sleep_hours is not None:
sleep_secs = -1 if sleep_hours == -1 else int(sleep_hours * 3600)
# Map snake_case tool params to the Intervals.icu camelCase wellness fields.
field_map: list[tuple[str, Any]] = [
("weight", weight),
("restingHR", resting_hr),
("hrv", hrv),
("sleepSecs", sleep_secs),
("sleepQuality", sleep_quality),
("kcalConsumed", calories_consumed),
("carbohydrates", carbohydrates),
("protein", protein),
("fatTotal", fat),
("hydrationVolume", hydration_volume),
("hydration", hydration_score),
("soreness", soreness),
("fatigue", fatigue),
("stress", stress),
("mood", mood),
("motivation", motivation),
("injury", injury),
("comments", comments),
("locked", locked),
]
payload: dict[str, Any] = {k: v for k, v in field_map if v is not None}
if not payload:
return "No wellness fields provided. Pass at least one field to update."
result = await make_intervals_request(
url=f"/athlete/{athlete_id_to_use}/wellness/{date}",
api_key=api_key,
method="PUT",
data=payload,
)
if isinstance(result, dict) and "error" in result:
return f"Error updating wellness data: {result.get('message')}"
# Intervals.icu echoes back the full updated record; render it for confirmation.
# If the echo omits the date, inject the one we wrote to so the confirmation
# body doesn't read "Date: N/A" under a dated header.
if isinstance(result, dict):
if not result.get("id") and not result.get("date"):
result["date"] = date
return f"Updated wellness for {date}:\n\n" + format_wellness_entry(result)
return f"Updated wellness for {date}."
+19 -1
View File
@@ -161,6 +161,20 @@ def _format_training_metrics(entries: dict[str, Any]) -> list[str]:
for k, label in [
("ctl", "Fitness (CTL)"),
("atl", "Fatigue (ATL)"),
]:
if entries.get(k) is not None:
training_metrics.append(f"- {label}: {entries[k]}")
# Form (a.k.a. TSB, Training Stress Balance) = CTL - ATL. Intervals.icu does
# not return this on the wellness record, so compute it when both components
# are present AND numeric. The isinstance guard keeps a non-numeric value from
# raising out of the formatter and taking down the whole wellness render.
# Positive = fresher/tapered, negative = carrying fatigue.
ctl, atl = entries.get("ctl"), entries.get("atl")
if isinstance(ctl, (int, float)) and isinstance(atl, (int, float)):
training_metrics.append(f"- Form (TSB): {ctl - atl:.1f}")
for k, label in [
("rampRate", "Ramp Rate"),
("ctlLoad", "CTL Load"),
("atlLoad", "ATL Load"),
@@ -337,7 +351,11 @@ def format_wellness_entry(entries: dict[str, Any], include_all_fields: bool = Fa
entries.get("tempRestingHR")
lines = ["Wellness Data:"]
lines.append(f"Date: {entries.get('id', 'N/A')}")
# The wellness record's own date lives in `id` (e.g. "2025-05-24"); some call
# sites also inject an explicit `date`. Prefer `date`, fall back to `id`. Use
# `or` chaining (not get-with-default) so a present-but-null `date` still falls
# back rather than rendering "Date: None".
lines.append(f"Date: {entries.get('date') or entries.get('id') or 'N/A'}")
lines.append("")
training_metrics = _format_training_metrics(entries)
@@ -4,6 +4,7 @@ Date: 2025-05-24
Training Metrics:
- Fitness (CTL): 70.87253
- Fatigue (ATL): 91.97159
- Form (TSB): -21.1
- Ramp Rate: 6.997368
- CTL Load: 299
- ATL Load: 299
-2
View File
@@ -12,10 +12,8 @@ import time
import types
import jwt
import pytest
from cryptography.hazmat.primitives.asymmetric import ed25519, rsa
from intervals_mcp_server import auth as auth_mod
from intervals_mcp_server.auth import AuthentikTokenVerifier, _audience_variants, build_auth
ISSUER = "https://auth.example/application/o/x/"
+1 -2
View File
@@ -8,7 +8,6 @@ and the error/empty branches.
import asyncio
import pytest
from intervals_mcp_server.tools import custom_items
@@ -60,7 +59,7 @@ def test_create_builds_full_payload(monkeypatch):
rec = _patch(monkeypatch, lambda _k: {"id": 9, "name": "Chart", "type": "FITNESS_CHART"})
out = _run(
custom_items.create_custom_item(
name="Chart", item_type="FITNESS_CHART",
name="Chart", item_type="FITNESS_CHART",
description="desc", content={"a": 1}, visibility="PRIVATE",
)
)
+2 -2
View File
@@ -112,7 +112,7 @@ def test_add_event_posts_when_no_event_id(monkeypatch):
rec = _patch(monkeypatch, lambda _k: {"id": "e99"})
out = _run(
events.add_or_update_event(
workout_type="Ride", name="Threshold",
workout_type="Ride", name="Threshold",
start_date="2026-07-10", moving_time=3600, distance=40000,
workout_doc=WorkoutDoc(description="d", steps=[Step(duration=600)]),
)
@@ -128,7 +128,7 @@ def test_update_event_puts_when_event_id(monkeypatch):
rec = _patch(monkeypatch, lambda _k: {"id": "e5"})
_run(
events.add_or_update_event(
workout_type="Ride", name="Threshold",
workout_type="Ride", name="Threshold",
event_id="e5", start_date="2026-07-10",
)
)
+32
View File
@@ -64,6 +64,38 @@ def test_format_wellness_entry():
assert result == expected_result
def test_format_wellness_entry_computes_form_tsb():
"""Form (TSB) is computed as CTL - ATL when both are present."""
result = format_wellness_entry({"id": "2024-06-01", "ctl": 50, "atl": 65})
assert "Form (TSB): -15.0" in result
def test_format_wellness_entry_no_form_without_both_components():
"""Form is omitted if either CTL or ATL is missing."""
result = format_wellness_entry({"id": "2024-06-01", "ctl": 50})
assert "Form (TSB)" not in result
def test_format_wellness_entry_prefers_explicit_date_over_id():
"""An explicit `date` field wins over `id` for the Date line."""
result = format_wellness_entry({"id": "2024-06-01", "date": "2024-06-02", "ctl": 50})
assert "Date: 2024-06-02" in result
def test_format_wellness_entry_null_date_falls_back_to_id():
"""A present-but-null `date` falls back to `id`, not 'None'."""
result = format_wellness_entry({"id": "2024-06-01", "date": None, "ctl": 50})
assert "Date: 2024-06-01" in result
assert "Date: None" not in result
def test_format_wellness_entry_non_numeric_ctl_atl_does_not_crash():
"""Non-numeric ctl/atl must not raise; Form is simply omitted."""
result = format_wellness_entry({"id": "2024-06-01", "ctl": "n/a", "atl": "n/a"})
assert "Form (TSB)" not in result
assert "Date: 2024-06-01" in result
def test_format_wellness_entry_include_all_fields():
"""
Test that format_wellness_entry with include_all_fields=True includes additional unknown fields.
+7 -7
View File
@@ -465,7 +465,7 @@ def test_get_athlete_power_curves(monkeypatch):
result = asyncio.run(
get_athlete_power_curves(
activity_type="Ride",
)
)
assert "Power Curves (Ride):" in result
@@ -492,7 +492,7 @@ def test_get_athlete_power_curves_custom_durations(monkeypatch):
get_athlete_power_curves(
activity_type="Ride",
durations=[5, 60],
)
)
assert "5s:" in result
@@ -518,7 +518,7 @@ def test_get_athlete_power_curves_without_normalised(monkeypatch):
get_athlete_power_curves(
activity_type="Ride",
include_normalised=False,
)
)
assert "W/kg" not in result
@@ -542,7 +542,7 @@ def test_get_athlete_power_curves_date_validation(monkeypatch):
get_athlete_power_curves(
activity_type="Ride",
start_date="2026-01-01",
)
)
assert "Error" in result
@@ -566,7 +566,7 @@ def test_get_athlete_power_curves_no_curves_selected(monkeypatch):
activity_type="Ride",
this_season=False,
last_season=False,
)
)
assert "Error" in result
@@ -675,7 +675,7 @@ def test_create_custom_item_with_string_content(monkeypatch):
create_custom_item(
name="Activity Field",
item_type="ACTIVITY_FIELD",
content='{"expression": "icu_training_load"}', # type: ignore[arg-type]
)
)
@@ -744,7 +744,7 @@ def test_create_custom_item_with_invalid_json_content(monkeypatch):
create_custom_item(
name="Bad Item",
item_type="FITNESS_CHART",
content="not valid json", # type: ignore[arg-type]
)
)
+136
View File
@@ -0,0 +1,136 @@
"""
Tests for intervals_mcp_server.tools.wellness.
Covers the wellness write tool (``update_wellness``): field mapping to the
Intervals.icu camelCase schema, the sleep hours->seconds conversion and the
``-1`` clear sentinel, request shape (PUT + path), the empty-payload guard, and
the error / credential branches. The default caller credentials come from the
autouse fixture in conftest (athlete ``i1``).
"""
import asyncio
from intervals_mcp_server import credentials
from intervals_mcp_server.credentials import CredentialError
from intervals_mcp_server.tools import wellness
def _patch_request(monkeypatch, result):
"""Patch make_intervals_request; capture the call kwargs, return ``result``."""
calls: list[dict] = []
async def fake(**kwargs):
calls.append(kwargs)
return result
monkeypatch.setattr(wellness, "make_intervals_request", fake)
return calls
def test_update_wellness_maps_fields_and_puts(monkeypatch):
calls = _patch_request(monkeypatch, {"id": "2025-05-24", "weight": 78})
out = asyncio.run(
wellness.update_wellness(
date="2025-05-24",
weight=78,
resting_hr=50,
hrv=65.5,
sleep_hours=8,
calories_consumed=2200,
carbohydrates=300,
protein=140,
fat=70,
hydration_volume=2.5,
comments="felt good",
)
)
assert len(calls) == 1
call = calls[0]
assert call["method"] == "PUT"
assert call["url"] == "/athlete/i1/wellness/2025-05-24"
assert call["api_key"] == "testkey"
payload = call["data"]
assert payload["weight"] == 78
assert payload["restingHR"] == 50
assert payload["hrv"] == 65.5
assert payload["sleepSecs"] == 8 * 3600 # hours -> seconds
assert payload["kcalConsumed"] == 2200
assert payload["carbohydrates"] == 300
assert payload["protein"] == 140
assert payload["fatTotal"] == 70
assert payload["hydrationVolume"] == 2.5
assert payload["comments"] == "felt good"
# Omitted fields must not be sent.
assert "mood" not in payload
assert "locked" not in payload
assert "Updated wellness for 2025-05-24" in out
def test_update_wellness_defaults_date_to_today(monkeypatch):
calls = _patch_request(monkeypatch, {"id": "today"})
asyncio.run(wellness.update_wellness(weight=80))
# URL date segment defaults to today's date (YYYY-MM-DD, 10 chars).
date_seg = calls[0]["url"].rsplit("/", 1)[1]
assert len(date_seg) == 10 and date_seg.count("-") == 2
def test_update_wellness_no_fields_returns_message(monkeypatch):
calls = _patch_request(monkeypatch, {})
out = asyncio.run(wellness.update_wellness(date="2025-05-24"))
assert "No wellness fields provided" in out
assert calls == [] # no request made
def test_update_wellness_clear_with_negative_one(monkeypatch):
calls = _patch_request(monkeypatch, {"id": "2025-05-24"})
asyncio.run(wellness.update_wellness(date="2025-05-24", weight=-1, sleep_hours=-1))
payload = calls[0]["data"]
assert payload["weight"] == -1
# -1 is the clear sentinel and must NOT be scaled to -3600.
assert payload["sleepSecs"] == -1
def test_update_wellness_locked_false_is_sent(monkeypatch):
calls = _patch_request(monkeypatch, {"id": "2025-05-24"})
asyncio.run(wellness.update_wellness(date="2025-05-24", locked=False))
assert calls[0]["data"]["locked"] is False
def test_update_wellness_error_path(monkeypatch):
_patch_request(monkeypatch, {"error": True, "message": "boom"})
out = asyncio.run(wellness.update_wellness(date="2025-05-24", weight=80))
assert "Error updating wellness data: boom" in out
def test_update_wellness_invalid_date(monkeypatch):
calls = _patch_request(monkeypatch, {})
out = asyncio.run(wellness.update_wellness(date="not-a-date", weight=80))
assert out.startswith("Error:")
assert calls == []
def test_update_wellness_credential_error(monkeypatch):
async def _deny():
raise CredentialError("not approved")
monkeypatch.setattr(credentials, "resolve_caller_credentials", _deny)
out = asyncio.run(wellness.update_wellness(date="2025-05-24", weight=80))
assert "not approved" in out
def test_update_wellness_non_dict_result(monkeypatch):
# If the API returns a list (unexpected), we still confirm the write.
_patch_request(monkeypatch, [])
out = asyncio.run(wellness.update_wellness(date="2025-05-24", weight=80))
assert out == "Updated wellness for 2025-05-24."
def test_update_wellness_echo_without_date_shows_written_date(monkeypatch):
# If the API echo omits id/date, the confirmation body must not read "Date: N/A".
_patch_request(monkeypatch, {"weight": 80})
out = asyncio.run(wellness.update_wellness(date="2025-05-24", weight=80))
assert "Date: 2025-05-24" in out
assert "Date: N/A" not in out