From 6273138a6553c41616c5826aab763c22556ae67b Mon Sep 17 00:00:00 2001 From: sstent Date: Fri, 22 Aug 2025 18:27:12 -0700 Subject: [PATCH] checkpoint 1 --- entrypoint.sh | 4 +- .../__pycache__/__init__.cpython-310.pyc | Bin 0 -> 120 bytes garminsync/__pycache__/cli.cpython-310.pyc | Bin 0 -> 5054 bytes garminsync/__pycache__/config.cpython-310.pyc | Bin 0 -> 795 bytes garminsync/__pycache__/daemon.cpython-310.pyc | Bin 0 -> 6865 bytes .../__pycache__/database.cpython-310.pyc | Bin 0 -> 5138 bytes garminsync/__pycache__/garmin.cpython-310.pyc | Bin 0 -> 5970 bytes garminsync/database.py | 6 + garminsync/web/routes.py | 1 + garminsync/web/static/activities.js | 4 +- garminsync/web/static/utils.js | 10 +- garminsync/web/templates/activities.html | 2 + mandates.md | 9 ++ ...40822165438_add_hr_and_calories_columns.py | 23 ++++ .../versions/__pycache__/env.cpython-310.pyc | Bin 0 -> 1696 bytes patches/garth_data_weight.py | 80 ++++++++++++ .../test_sync.cpython-310-pytest-8.1.1.pyc | Bin 0 -> 3491 bytes tests/activity_table_validation.sh | 114 ++++++++++++++++++ tests/test_sync.py | 102 ++++++++++++++++ 19 files changed, 350 insertions(+), 5 deletions(-) create mode 100644 garminsync/__pycache__/__init__.cpython-310.pyc create mode 100644 garminsync/__pycache__/cli.cpython-310.pyc create mode 100644 garminsync/__pycache__/config.cpython-310.pyc create mode 100644 garminsync/__pycache__/daemon.cpython-310.pyc create mode 100644 garminsync/__pycache__/database.cpython-310.pyc create mode 100644 garminsync/__pycache__/garmin.cpython-310.pyc create mode 100644 mandates.md create mode 100644 migrations/versions/20240822165438_add_hr_and_calories_columns.py create mode 100644 migrations/versions/__pycache__/env.cpython-310.pyc create mode 100644 patches/garth_data_weight.py create mode 100644 tests/__pycache__/test_sync.cpython-310-pytest-8.1.1.pyc create mode 100755 tests/activity_table_validation.sh create mode 100644 tests/test_sync.py diff --git a/entrypoint.sh b/entrypoint.sh index 336e491..a61423c 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -2,8 +2,8 @@ # Run database migrations echo "Running database migrations..." -export ALEMBIC_CONFIG=./migrations/alembic.ini -export ALEMBIC_SCRIPT_LOCATION=./migrations/versions +export ALEMBIC_CONFIG=${ALEMBIC_CONFIG:-./migrations/alembic.ini} +export ALEMBIC_SCRIPT_LOCATION=${ALEMBIC_SCRIPT_LOCATION:-./migrations/versions} alembic upgrade head if [ $? -ne 0 ]; then echo "Migration failed!" >&2 diff --git a/garminsync/__pycache__/__init__.cpython-310.pyc b/garminsync/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..28741c6a2e0da5d7817037447653cd5788c52187 GIT binary patch literal 120 zcmd1j<>g`k0xs@p86f&Gh(HF6K#l_t7qb9~6oz01O-8?!3`HPe1o2B+Ke3=dKRvN1 pH#4ueGA~&_K0Y%qvm`!Vub}c4hfQvNN@-529Y|9#6OdqG000Bp7Bm0= literal 0 HcmV?d00001 diff --git a/garminsync/__pycache__/cli.cpython-310.pyc b/garminsync/__pycache__/cli.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..37af08ff78f06cd6ec4b29347c6fdecc3c9da352 GIT binary patch literal 5054 zcmai2O>Eo973Po>MNyP2*^d8{(AmGpiJbidS{QB7AYO0N?s~UPitG|t5VVGh=}@FH zq}CBZbrP6fC$s?jRd55=yy zD9~C#t<$N3+9;?^I$cm_3hFF9H>P$~I!EU}D;*HC%6DDBUFqvjNnR|tMY=>6IJeHZ zmkS+N=o|EWvBL#=alD2a1$C8PDyT0M)EDVX1@&d1*7zx3p_j*0(6L3=_^x%n>y=|d zuhRBs0%$!&*Xgy-1he5Z>8tld_B*G6_N3$%e_C&Tfgaj!k+gE%_k+le9Qp;gM*vFI z3v6n20>A6tjjz3Jv!3hU8u}fhZEObnKF%5$)wmJ(zSD^eyA!z|yOHaJEisjM9j|XT znA_R4dN#W^=v%#jI;rfrVYHRjZO^mL7R04?8z}M2J1}cNZ&DaxCD17?_uMda{kw6c z-5z)6Ie$KySd(frSJI)e-fmBfmiUr0!sM!Q(CvDz@8A~W?#i8j-81r$vF}E^!5}h1 zFa_w+k)k9BEWJCP1(3RUs_Ohvdg3{&oCmQaKPtS^~i_swPtSEzYSTXQC$B z0lfWsQifTb&r}kfYKy|>0q$s7`B#K=su`WDcj) zGfGlBsvirZdQuNR;WDRC&K>xpjcDpX_*h`0qje+zn~s`E{eZyUGx^?f zQl}7PhvKI2(c)+-ncACXzo2tIq4T2#U$X$T8CbCh?^$>+C5_L8N8A$kAKnrC6Jto1RC8yQ9PGp4+T;0G=)$5s@VcJA; zeygkyLN82fc&RLEM>cK&a`Y^~Zg{TaNAdUHVk}@A#`~TF)A{a0!;TCjM4*wAD`Euw z2zE0r^PswF6!C)Hvzy;SRXY%rCoPO@xXu%?V02l~dnUry*Vp5(V>lqI>lZGBv|%t{ zMu)jzUe`{`xgED-WsCF0`o!9ecp826^f?|AQ>ISXF}2MC5zO+tIg}`EOb!;%Pu?Eg6cR5Nj#DP?@{b5>)hJA;n zveVfOQu(b1Zj_ep4;(g3E7_H?c80*|d8y(B9oq}jQpXEICzU+M=k`gfIYp1AvbI|<=R%D<%1)vL0B1X-lY1*L8WAT7MlF;^M zpw5}5Gly^nE@d{Fg#?0-d@hG%l2owL;gH-LRpYl4<>*{e-J63Hp~HLrXn`)!#bXiP zOLX~I9M$Lw&V!8Qqz3KzMT5#y+kH3<{(T5fw46*Gkfds@&}nGT!?`YCGPxt%{{-Ov;_x3i+%NDmT+DYjU|k0L zU^Q+(BU|+$SOa1I{wLWR;h(Qu&2;h4qtu6F*eKEn`tMR9FH0j6o;;HSNiVnmL&7dR z>w$^6E8{tyN?wT9i%WSHUjHwhFua^lkIC=b(QcMg#EF1z!fgDt9Hwpz7=ujV4Ivjm zQ^3A44vWyR8Dx$@zaKED_1FxoX6N_@9KyEPJjz=JNd9Wr7+r*1juqRcx_SNfb!+qH z4^kO1@itU^UlnV+~g*97DbjaUQ}zc`j@bb?;rbKtaa?oJ2taChK&L$-vcUgh{` zg67e#=kBmKfykDzIm7oA93o1v3)o!5#=r(KpQ9zaguNHAc@dkJph?9bgoFV5`X8sI z-aYCv_A=1Tb7NnO(Q#qeGpFy_9jKIpzT>B*eV+W*a7u-w;rMpX;Q((c zaI_w*ZJ}J11)jHKckV$V;5h&jR(7DXv+!eF!3jkC>;zZ&YILH7O1AkBRN{q*S;1Tb zu;1%5M9549G*?Qx>>3KeJdic7VebuSw%`eOq0vh^X&};1;JqdRdXu>^y1#_z-uP=B zC=DVKU7V9sJY0@6A9rwX;5Uf_T65ZseuB4bfp zZnv|JehbG?kswPdhf&av6`X{3^%k`G6k7+CW0fz1G#5|b+70&eIRivt>XR{Ya5aBd z?>IZg4{sVccWJQ`SAh;%fPJ8TWEpMghmwr7vWEl>Y^&b5AYJ|M9_!eEJ`4wmL5W literal 0 HcmV?d00001 diff --git a/garminsync/__pycache__/config.cpython-310.pyc b/garminsync/__pycache__/config.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..146b6f370f6c6c4a867a675287dfffaad4f1103c GIT binary patch literal 795 zcmZuvO>fgc5ZzsW#C21OIKj(pHClVat`n&dC(_@* z8IHa3bA08*U*G~Wb^?}Qq@A7d_|3e1yZY!TBw*LiKHq$!gnY-OZw@4{VA&BkK?GH# zq$#BsGsRLS9O2%P)Hx@@6aF0${+6Y#U^5aAegKBt)8CKlTmAk`q`K znbc%Upg9FN+r*cEivUy@U6rz`qct}Lf2X94=0=y%2x`&1P%<$L!1u(su(j^zlQCZ| z#|v)CqO!MDKF)PDFBYTa?T$~EBfug!WX6Z>A9{ZjcnVcXQYuKwr1QlU^eauhHewL@ za?ok9rD-5MIDp^yC+*B;cs{wBUL@JsbaHXo9PhF>liBR;>#Nfo71$fbNT*fgF$5pU zdt!X>)P^+^bW1-01=J_mJf9ZU7S$r^_m8yM=lcm!QT1hv9TPxja{#Uhuen;uGh?)g zY2)O|{uR-9_(!njst~-EL&z+OG-LsFe{GJth?fRwQEv;E;U#S^^T%kTUjww%=nf^}APQ f@Yc*jbjY`@{;ypVyy!T-MBObm9+H3t?2-E$fxxX; literal 0 HcmV?d00001 diff --git a/garminsync/__pycache__/daemon.cpython-310.pyc b/garminsync/__pycache__/daemon.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..5b145b925b5dddfe20e5bf64896008b2e744df9e GIT binary patch literal 6865 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 Zt?w0<0}p~~WL#2>eh3`#Tb22O`u{r1!y*6x literal 0 HcmV?d00001 diff --git a/garminsync/__pycache__/database.cpython-310.pyc b/garminsync/__pycache__/database.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..683c69169ac0e204ba57e2e3cd718ff91027ddb0 GIT binary patch literal 5138 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 YT*F6|i)|RI#_G3LyKc|zxL$YrKOFuKw*UYD literal 0 HcmV?d00001 diff --git a/garminsync/__pycache__/garmin.cpython-310.pyc b/garminsync/__pycache__/garmin.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d1c7df3904429129f45b3c4e9731f19f0dc4a48c GIT binary patch literal 5970 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 literal 0 HcmV?d00001 diff --git a/garminsync/database.py b/garminsync/database.py index 1b43faf..2479684 100644 --- a/garminsync/database.py +++ b/garminsync/database.py @@ -21,6 +21,7 @@ class Activity(Base): duration = Column(Integer, nullable=True) distance = Column(Float, nullable=True) max_heart_rate = Column(Integer, nullable=True) + avg_heart_rate = Column(Integer, nullable=True) avg_power = Column(Float, nullable=True) calories = Column(Integer, nullable=True) filename = Column(String, unique=True, nullable=True) @@ -63,6 +64,7 @@ class Activity(Base): "start_time": self.start_time, "activity_type": self.activity_type, "max_heart_rate": self.max_heart_rate, + "avg_heart_rate": self.avg_heart_rate, "avg_power": self.avg_power, "calories": self.calories, } @@ -141,6 +143,8 @@ def sync_database(garmin_client): # Safely access dictionary keys activity_id = activity.get("activityId") start_time = activity.get("startTimeLocal") + avg_heart_rate = activity.get("averageHR", None) + calories = activity.get("calories", None) if not activity_id or not start_time: print(f"Missing required fields in activity: {activity}") @@ -153,6 +157,8 @@ def sync_database(garmin_client): new_activity = Activity( activity_id=activity_id, start_time=start_time, + avg_heart_rate=avg_heart_rate, + calories=calories, downloaded=False, created_at=datetime.now().isoformat(), last_sync=datetime.now().isoformat(), diff --git a/garminsync/web/routes.py b/garminsync/web/routes.py index 0a3c236..24a378e 100644 --- a/garminsync/web/routes.py +++ b/garminsync/web/routes.py @@ -304,6 +304,7 @@ async def get_activities( "duration": activity.duration, "distance": activity.distance, "max_heart_rate": activity.max_heart_rate, + "avg_heart_rate": activity.avg_heart_rate, "avg_power": activity.avg_power, "calories": activity.calories, "filename": activity.filename, diff --git a/garminsync/web/static/activities.js b/garminsync/web/static/activities.js index dd378f9..6dc16eb 100644 --- a/garminsync/web/static/activities.js +++ b/garminsync/web/static/activities.js @@ -65,8 +65,10 @@ class ActivitiesPage { ${activity.activity_type || '-'} ${Utils.formatDuration(activity.duration)} ${Utils.formatDistance(activity.distance)} - ${activity.max_heart_rate || '-'} + ${Utils.formatHeartRate(activity.max_heart_rate)} + ${Utils.formatHeartRate(activity.avg_heart_rate)} ${Utils.formatPower(activity.avg_power)} + ${activity.calories ? activity.calories.toLocaleString() : '-'} `; return row; diff --git a/garminsync/web/static/utils.js b/garminsync/web/static/utils.js index 1a1e74c..294918f 100644 --- a/garminsync/web/static/utils.js +++ b/garminsync/web/static/utils.js @@ -7,12 +7,13 @@ class Utils { return new Date(dateStr).toLocaleDateString(); } - // Format duration from seconds to HH:MM + // Format duration from seconds to HH:MM:SS static formatDuration(seconds) { if (!seconds) return '-'; const hours = Math.floor(seconds / 3600); const minutes = Math.floor((seconds % 3600) / 60); - return `${hours}:${minutes.toString().padStart(2, '0')}`; + const secondsLeft = seconds % 60; + return `${hours}:${minutes.toString().padStart(2, '0')}:${secondsLeft.toString().padStart(2, '0')}`; } // Format distance from meters to kilometers @@ -26,6 +27,11 @@ class Utils { return watts ? `${Math.round(watts)}W` : '-'; } + // Format heart rate (adds 'bpm') + static formatHeartRate(hr) { + return hr ? `${hr} bpm` : '-'; + } + // Show error message static showError(message) { console.error(message); diff --git a/garminsync/web/templates/activities.html b/garminsync/web/templates/activities.html index 48d039f..2303f25 100644 --- a/garminsync/web/templates/activities.html +++ b/garminsync/web/templates/activities.html @@ -18,7 +18,9 @@ Duration Distance Max HR + Avg HR Power + Calories diff --git a/mandates.md b/mandates.md new file mode 100644 index 0000000..6680ee0 --- /dev/null +++ b/mandates.md @@ -0,0 +1,9 @@ + +- use the just_run_* tools via the MCP server +- all installs should be done in the docker container. +- NO installs on the host +- database upgrades should be handled during container server start up +- always rebuild the container before running tests +- if you need clarification return to PLAN mode +- force rereading of the mandates on each cycle + \ No newline at end of file diff --git a/migrations/versions/20240822165438_add_hr_and_calories_columns.py b/migrations/versions/20240822165438_add_hr_and_calories_columns.py new file mode 100644 index 0000000..d505977 --- /dev/null +++ b/migrations/versions/20240822165438_add_hr_and_calories_columns.py @@ -0,0 +1,23 @@ +"""Add avg_heart_rate and calories columns to activities table + +Revision ID: 20240822165438 +Revises: 20240821150000 +Create Date: 2024-08-22 16:54:38.123456 + +""" +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = '20240822165438' +down_revision = '20240821150000' +branch_labels = None +depends_on = None + +def upgrade(): + op.add_column('activities', sa.Column('avg_heart_rate', sa.Integer(), nullable=True)) + op.add_column('activities', sa.Column('calories', sa.Integer(), nullable=True)) + +def downgrade(): + op.drop_column('activities', 'avg_heart_rate') + op.drop_column('activities', 'calories') diff --git a/migrations/versions/__pycache__/env.cpython-310.pyc b/migrations/versions/__pycache__/env.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..7d0ef020e5b52d3278613256b3dd87a471007712 GIT binary patch literal 1696 zcmZvc&5ztP6u|9FCYj01espOINT`?!X&I@SEfPnBpj(hCp;gru5OayVv1i7E;{@B; zk81a{TsW-u#(DOH_*3{7a^=LoKtap1GqXD_B#L7{Kfe$A_gt*4B?z92-+!In?jZDs z8>~J73_gd?XlNK>SfU({Fn0Yw26=1L%EM9U$1T~;qfvwr$Fq18^LW-_VTJVvJaYXr zw<;WUS(`;ap;5wO)`5SI<9YuQ=}&lahMdN8#Cyx$p!+BIMFU*NS)nXHvV)-B;%Xuk zPsh5*QxK2Eqz=oXkf4@~h2*?gcA@IDI3PYLil*}q)2_@e=``YVM z#fU0EqR5wi4<5jhqMA%^Bm^wdEaRo6`;z-rQ?5Coh8%J!hlD&jCgulGmO@Ru!^RTH zH22Z^C5N1_LTy{Z54a+Va|Tg7e)PrDC&U(HAIu$4A)SXsCc9Khh`(5dKjcR|t1SO$ zK)idN3+ug2gtKU@_U;QO^Bzs6MQKS<*|M?=qc7LZkCMu4%Jbu4rR9q{DXFHpvB#3v zZAEj=24UTTsd~dwJ>fRZxuuL+TCYiAxu!DR7mAs>&jgh`vuROUvp0zAXhF_~Uqyv- zGjLkkAzyc|Pepg#_|`rL=2EMvGBk5~^}4Q9dUMDFtQ}v!A$<#ef4#p$%W~&tdgp*^ z<6zj~>R?zN*LPoEo-X)!7Z#h_&?Nm32mXz4^mh^``2LlC3npH}+6C(Z^jQA_9g8fU z2Nus-0$m0S+i=!q!FlTxouD~jeDUxUTd2kpY`d&=9su6)JOO#%_j(J^!zJiy6EJ_U ziaC_Q^{lJbH^-p=cvau&4B9V${=b?~|0#(l3XlMXO~w_~q5x_e{v2BlQDARL|8-4J zV~R}pIR~JEbB3*MN@%5~mRR*xu!8#}Dm5RAqq^(vkW7Lx5GFTvJOO)yzHY((>OM4e z&p~4vH}zJ-N-C6C%5>guma!(r(i)EmMqNlx8Gw~~X;#wXJG i6s9W4KXaF|c?X(4+QfYvg)ZVS>cpM5J9iSC1pfk6sm2ii literal 0 HcmV?d00001 diff --git a/patches/garth_data_weight.py b/patches/garth_data_weight.py new file mode 100644 index 0000000..d8d82d0 --- /dev/null +++ b/patches/garth_data_weight.py @@ -0,0 +1,80 @@ +from datetime import date, datetime, timedelta +from itertools import chain + +from pydantic import Field, ValidationInfo, field_validator +from pydantic.dataclasses import dataclass +from typing_extensions import Self + +from .. import http +from ..utils import ( + camel_to_snake_dict, + format_end_date, + get_localized_datetime, +) +from ._base import MAX_WORKERS, Data + + +@dataclass +class WeightData(Data): + sample_pk: int + calendar_date: date + weight: int + source_type: str + weight_delta: float + datetime_utc: datetime = Field(..., alias="timestamp_gmt") + datetime_local: datetime = Field(..., alias="date") + bmi: float | None = None + body_fat: float | None = None + body_water: float | None = None + bone_mass: int | None = None + muscle_mass: int | None = None + physique_rating: float | None = None + visceral_fat: float | None = None + metabolic_age: int | None = None + + @field_validator("datetime_local", mode="before") + @classmethod + def to_localized_datetime(cls, v: int, info: ValidationInfo) -> datetime: + return get_localized_datetime(info.data["datetime_utc"].timestamp() * 1000, v) + + @classmethod + def get( + cls, day: date | str, *, client: http.Client | None = None + ) -> Self | None: + client = client or http.client + path = f"/weight-service/weight/dayview/{day}" + data = client.connectapi(path) + day_weight_list = data["dateWeightList"] if data else [] + + if not day_weight_list: + return None + + # Get first (most recent) weight entry for the day + weight_data = camel_to_snake_dict(day_weight_list[0]) + return cls(**weight_data) + + @classmethod + def list( + cls, + end: date | str | None = None, + days: int = 1, + *, + client: http.Client | None = None, + max_workers: int = MAX_WORKERS, + ) -> list[Self]: + client = client or http.client + end = format_end_date(end) + start = end - timedelta(days=days - 1) + + data = client.connectapi( + f"/weight-service/weight/range/{start}/{end}?includeAll=true" + ) + weight_summaries = data["dailyWeightSummaries"] if data else [] + weight_metrics = chain.from_iterable( + summary["allWeightMetrics"] for summary in weight_summaries + ) + weight_data_list = ( + cls(**camel_to_snake_dict(weight_data)) + for weight_data in weight_metrics + ) + return sorted(weight_data_list, key=lambda d: d.datetime_utc) diff --git a/tests/__pycache__/test_sync.cpython-310-pytest-8.1.1.pyc b/tests/__pycache__/test_sync.cpython-310-pytest-8.1.1.pyc new file mode 100644 index 0000000000000000000000000000000000000000..bcebf2059ccf4a6610a3d0072e3a1d87f2e33d36 GIT binary patch literal 3491 zcmb7HTaO#X67HUx$K%W1vI%fFFa!ZHkd3|Jl0*a%1%wc=LShBNFQbWjc6XM!nC@|c zjjbb{g$JaA;0N%q`^LVUAJnhBuzvu-N?fXX#@=yQBjIdUS6B7SR9AoXbti2!90I@e zs~`6MvqZ>`I4J)N7`y>Z{0{^pj7FqORjPMQIZA6W@*=;J_jF#f%?lR~Wt9(hiE6iq9=vSG;YS6E-I%`0Gfi>9@ z^y`d%NLtG$5E0pGY0`KM#eS2-)~Xb5W}SBF+cO~`62*A{cWGeVRAPL zgCy(Wv(ujjiVf{9H1QpXjAWG2Z|DS8!E|PPuQ3y1gML>VlYPz8GlNwQ=(nv2W%iiv z>-#1&3t9zy_Lv+{N^s_3l~tL8YgV(I!`cz#OzHJp|Clh_AO<I!X^4K%JGt2=jFxeED&$I0?4&F<9esr=~rRC=~w>zEuxzi_iRSq$X zjcIB(c7x0lfe>MuY;g+dJNZc`q}AY8OAS&IP~ zUQcbgtgAs0=`rE|h8Le+LbzPZAh|+GPpz##lvmKEMl%qpFJjCgD=nrDuMrnPIGU~5 z)c)NuSE;Qv^}61qmw)C@f@?9{67ZJcHiMTA@Ny?KGfQ9s2r_d9rplo`15-8E0ZiHq zOqv2ydk#!sYgwa>MPS-WlVFA;cZa7jB8yuPix(UaBqQ zm=b;ogj^}MCrPIp$R9=LV<>PJa`6PteQr_O69}!8^`~I6z&If56kCz+UpRW^@?8Z3 zw!6^86#!^C-%JlFW)&om53`}f_1PX&VGjh9xU;atq4X!(Slh2o^s&D0Obk|=m}6sX zeyUBZu_bthv{xTnJCrrhzM~x(teG_qn@8k65$}S3X-tnO{{XaQ)DjAQ)U_k3dcEk4 zqBoByTl(I-NG27w%vQ#gkb-V!D=>R?Y#-4B`d^6IAQQmCM2BWTvmh=csr|J{yEQVD3&BL`*2u@ERnYBe`;{6W&D8 z05Lk_rR&?B&75C2o<$00TPH{)$MjUYefyY>;%Bb(hp)7R^F^V!w_Vg;nwmfO3CwIj~vZ=Ygz) z+q*Xaehd>Ktq8eV$VWV}mkuJPrh2;(pMS*jFb1dZC|0Co6%5Q*wjsfNl_%V3r89*KEbXN5y zRQ=PyyyPH1sLwmvv68BX&WwMd%8RP9W>saWs(N*L-kgnVO8+}Ht`}_FV9f<=j4b^3Yz#9BHr5qxzMjJk*jh`Y>*wG` zT>`@-zXnb(buM1c+-%qnavC13pF3kKXXbe*@ym#Xr%^nE0$G4Ri{g0{FQ9l4#mguj zI=-(if=luJDO!f=QuPtMp(tcc)Bak>x4_@JDD5ff@oQj^k9s*xZse>4)CKJRfQK1S z-pnK`g^gduXRQ1Ysf(fLHU>$UVW1m$Bg{!#$5{sa7h+FZK(BDu?5>@2*;UNlT`75T zRb6w{RZrbN6a^{Fb6i6yODbRLrenVrr)&@fuj5IlvT&K$bQv!)TAkXuy>2htYxUJX E0ioS(&;S4c literal 0 HcmV?d00001 diff --git a/tests/activity_table_validation.sh b/tests/activity_table_validation.sh new file mode 100755 index 0000000..b81a2bd --- /dev/null +++ b/tests/activity_table_validation.sh @@ -0,0 +1,114 @@ +#!/bin/bash + +# Activity Table Validation Script +# This script tests the activity table implementation + +# Configuration +API_URL="http://localhost:8888/api/api/activities" # Changed port to 8888 to match container +TIMEOUT=10 + +# Function to display test results +display_result() { + local test_name=$1 + local result=$2 + local message=$3 + + if [ "$result" = "PASS" ]; then + echo "✅ $test_name: $message" + else + echo "❌ $test_name: $message" + fi +} + +# Function to wait for API to be ready +wait_for_api() { + echo "Waiting for API to start..." + attempts=0 + max_attempts=60 # Increased timeout to 60 seconds + + while true; do + # Check for startup messages + if curl -s -m 1 "http://localhost:8888" | grep -q "Uvicorn running on" || \ + curl -s -m 1 "http://localhost:8888" | grep -q "Application startup complete" || \ + curl -s -m 1 "http://localhost:8888" | grep -q "Server is ready"; then + echo "API started successfully" + break + fi + + attempts=$((attempts+1)) + if [ $attempts -ge $max_attempts ]; then + echo "API failed to start within $max_attempts seconds" + exit 1 + fi + + sleep 1 + done +} + +# Wait for API to be ready +wait_for_api + +# Test 1: Basic API response +echo "Running basic API response test..." +response=$(curl -s -m $TIMEOUT "$API_URL" | jq '.') +if [ $? -eq 0 ]; then + if [[ "$response" == *"activities"* ]] && [[ "$response" == *"total_pages"* ]] && [[ "$response" == *"status"* ]]; then + display_result "Basic API Response" PASS "API returns expected structure" + else + display_result "Basic API Response" FAIL "API response doesn't contain expected fields" + fi +else + display_result "Basic API Response" FAIL "API request failed" +fi + +# Test 2: Pagination test +echo "Running pagination test..." +page1=$(curl -s -m $TIMEOUT "$API_URL?page=1" | jq '.') +page2=$(curl -s -m $TIMEOUT "$API_URL?page=2" | jq '.') + +if [ $? -eq 0 ]; then + page1_count=$(echo "$page1" | jq '.activities | length') + page2_count=$(echo "$page2" | jq '.activities | length') + + if [ "$page1_count" -gt 0 ] && [ "$page2_count" -gt 0 ]; then + display_result "Pagination Test" PASS "Both pages contain activities" + else + display_result "Pagination Test" FAIL "One or more pages are empty" + fi +else + display_result "Pagination Test" FAIL "API request failed" +fi + +# Test 3: Data consistency test +echo "Running data consistency test..." +activity_id=$(echo "$page1" | jq -r '.activities[0].id') +activity_name=$(echo "$page1" | jq -r '.activities[0].name') + +details_response=$(curl -s -m $TIMEOUT "$API_URL/$activity_id" | jq '.') +if [ $? -eq 0 ]; then + details_id=$(echo "$details_response" | jq -r '.id') + details_name=$(echo "$details_response" | jq -r '.name') + + if [ "$activity_id" = "$details_id" ] && [ "$activity_name" = "$details_name" ]; then + display_result "Data Consistency Test" PASS "Activity details match API response" + else + display_result "Data Consistency Test" FAIL "Activity details don't match API response" + fi +else + display_result "Data Consistency Test" FAIL "API request failed" +fi + +# Test 4: Error handling test +echo "Running error handling test..." +error_response=$(curl -s -m $TIMEOUT "$API_URL/999999999" | jq '.') +if [ $? -eq 0 ]; then + if [[ "$error_response" == *"detail"* ]] && [[ "$error_response" == *"not found"* ]]; then + display_result "Error Handling Test" PASS "API returns expected error for non-existent activity" + else + display_result "Error Handling Test" FAIL "API doesn't return expected error for non-existent activity" + fi +else + display_result "Error Handling Test" FAIL "API request failed" +fi + +echo "All tests completed." diff --git a/tests/test_sync.py b/tests/test_sync.py new file mode 100644 index 0000000..2243039 --- /dev/null +++ b/tests/test_sync.py @@ -0,0 +1,102 @@ +import pytest +import sys +from unittest.mock import Mock, patch + +# Add the project root to the Python path +sys.path.insert(0, '/app') + +from garminsync.database import sync_database +from garminsync.garmin import GarminClient + + +def test_sync_database_with_valid_activities(): + """Test sync_database with valid API response""" + mock_client = Mock(spec=GarminClient) + mock_client.get_activities.return_value = [ + {"activityId": 12345, "startTimeLocal": "2023-01-01T10:00:00"}, + {"activityId": 67890, "startTimeLocal": "2023-01-02T11:00:00"} + ] + + with patch('garminsync.database.get_session') as mock_session: + mock_session.return_value.query.return_value.filter_by.return_value.first.return_value = None + + sync_database(mock_client) + + # Verify get_activities was called + mock_client.get_activities.assert_called_once_with(0, 1000) + + # Verify database operations + mock_session.return_value.add.assert_called() + mock_session.return_value.commit.assert_called() + + +def test_sync_database_with_none_activities(): + """Test sync_database with None response from API""" + mock_client = Mock(spec=GarminClient) + mock_client.get_activities.return_value = None + + with patch('garminsync.database.get_session') as mock_session: + sync_database(mock_client) + + # Verify get_activities was called + mock_client.get_activities.assert_called_once_with(0, 1000) + + # Verify no database operations + mock_session.return_value.add.assert_not_called() + mock_session.return_value.commit.assert_not_called() + + +def test_sync_database_with_missing_fields(): + """Test sync_database with activities missing required fields""" + mock_client = Mock(spec=GarminClient) + mock_client.get_activities.return_value = [ + {"activityId": 12345}, # Missing startTimeLocal + {"startTimeLocal": "2023-01-02T11:00:00"}, # Missing activityId + {"activityId": 67890, "startTimeLocal": "2023-01-03T12:00:00"} # Valid + ] + + with patch('garminsync.database.get_session') as mock_session: + mock_session.return_value.query.return_value.filter_by.return_value.first.return_value = None + + sync_database(mock_client) + + # Verify only one activity was added (the valid one) + assert mock_session.return_value.add.call_count == 1 + mock_session.return_value.commit.assert_called() + + +def test_sync_database_with_existing_activities(): + """Test sync_database doesn't duplicate existing activities""" + mock_client = Mock(spec=GarminClient) + mock_client.get_activities.return_value = [ + {"activityId": 12345, "startTimeLocal": "2023-01-01T10:00:00"} + ] + + with patch('garminsync.database.get_session') as mock_session: + # Mock existing activity + mock_session.return_value.query.return_value.filter_by.return_value.first.return_value = Mock() + + sync_database(mock_client) + + # Verify no new activities were added + mock_session.return_value.add.assert_not_called() + mock_session.return_value.commit.assert_called() + + +def test_sync_database_with_invalid_activity_data(): + """Test sync_database with invalid activity data types""" + mock_client = Mock(spec=GarminClient) + mock_client.get_activities.return_value = [ + "invalid activity data", # Not a dict + None, # None value + {"activityId": 12345, "startTimeLocal": "2023-01-01T10:00:00"} # Valid + ] + + with patch('garminsync.database.get_session') as mock_session: + mock_session.return_value.query.return_value.filter_by.return_value.first.return_value = None + + sync_database(mock_client) + + # Verify only one activity was added (the valid one) + assert mock_session.return_value.add.call_count == 1 + mock_session.return_value.commit.assert_called()