diff --git a/.env.template b/.env.template index a22bc7dd..7dba825f 100644 --- a/.env.template +++ b/.env.template @@ -1,30 +1,8 @@ -SERVER_URL=http://localhost:8000/ -DATABASE_PATH=/tmp/data/slackhealthbot.db - WITHINGS_CLIENT_ID=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef WITHINGS_CLIENT_SECRET=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef -WITHINGS_CALLBACK_URL=http://localhost:8000/ FITBIT_CLIENT_ID=01A2B3 FITBIT_CLIENT_SECRET=0123456789abcdef0123456789abcdef FITBIT_CLIENT_SUBSCRIBER_VERIFICATION_CODE=0123456789abcdef0123456789abcdef0123456789abcdef1023456789abcdef -FITBIT_POLL_ENABLED=true -FITBIT_POLL_INTERVAL_S=3600 - -# For FITBIT_ACTIVITY_TYPE_IDS: -# See https://dev.fitbit.com/build/reference/web-api/activity/get-all-activity-types/ -# for the list of all supported activity types and their ids. -# Some examples: -# 55001: Spinning -# 90013: Walk -# 90001: Bike -# 90019: Treadmill -# 1071: Outdoor Bike -FITBIT_REALTIME_ACTIVITY_TYPE_IDS=[55001, 90013] -FITBIT_DAILY_ACTIVITY_TYPE_IDS=[90019] -FITBIT_DAILY_ACTIVITY_POST_TIME=23:50 -FITBIT_ACTIVITY_RECORD_HISTORY_DAYS=180 - -SLACK_WEBHOOK_URL=https://hooks.slack.com/services/XXXXXXXXX/XXXXXXXXXXX/abcdefghijklmnopqrstuvwx -SQL_LOG_LEVEL=WARNING +SLACK_WEBHOOK_URL=https://hooks.slack.com/services/XXXXXXXXX/XXXXXXXXXXX/abcdefghijklmnopqrstuvwx \ No newline at end of file diff --git a/config/app.yaml b/config/app.yaml new file mode 100644 index 00000000..02960671 --- /dev/null +++ b/config/app.yaml @@ -0,0 +1,53 @@ +# Configuration of the slack-health-bot application +app: + server_url: "http://localhost:8000/" # The url to access the slack-health-bot server for login. + database_path: "/tmp/data/slackhealthbot.db" # The location to the database file. + logging: + sql_log_level: "WARNING" + +# Withings-specific configuration: +# Note that secrets like the client id and client secret are configured in the .env file. +withings: + callback_url: "http://localhost:8000/" # The url that withings will call at the end of SSO. + +# Fitbit-specific configuration: +# Note that secrets like the client id and client secret are configured in the .env file. +fitbit: + poll: + enabled: true # If your server can't receive webhook calls from fitbit, activate polling instead, to fetch data from fitbit. + interval_seconds: 3600 # How often to poll fitbit for data. + + activities: + history_days: 180 # how far to look back to report new records of best times/durations/calories/etc. + daily_report_time: "23:50" # Time of day (HH:mm)to post daily reports to slack. + + activity_types: + # Configuration specific to activity types. + # + # For fitbit activity type ids: + # See https://dev.fitbit.com/build/reference/web-api/activity/get-all-activity-types/ + # for the list of all supported activity types and their ids. + # Some examples: + # 55001: Spinning + # 90013: Walk + # 90001: Bike + # 90019: Treadmill + # 1071: Outdoor Bike + # + # supported attributes: + # report_daily: whether a daily summary report should be posted to slack for this activity type + # report_realtime: whether a report should be posted to slack for this activity type as soon as we receive it from fitbit + - name: Treadmill + id: 90019 + report_daily: true + report_realtime: false + + - name: Spinning + id: 55001 + report_daily: false + report_realtime: true + + - name: Walk + id: 90013 + report_daily: false + report_realtime: true diff --git a/requirements/prod.txt b/requirements/prod.txt index 63c85661..3df2cefc 100644 --- a/requirements/prod.txt +++ b/requirements/prod.txt @@ -6,7 +6,7 @@ fastapi==0.115.6 httpx==0.27.2 itsdangerous==2.2.0 Jinja2==3.1.4 -pydantic-settings==2.6.1 +pydantic-settings[yaml]==2.6.1 pydantic==2.10.3 python-dotenv==1.0.1 python-multipart==0.0.19 diff --git a/slackhealthbot/data/database/connection.py b/slackhealthbot/data/database/connection.py index 1b239d30..ebdbc90e 100644 --- a/slackhealthbot/data/database/connection.py +++ b/slackhealthbot/data/database/connection.py @@ -6,8 +6,8 @@ from slackhealthbot.settings import settings -connection_url = f"sqlite+aiosqlite:///{settings.database_path}" -Path(settings.database_path).parent.mkdir(parents=True, exist_ok=True) +connection_url = f"sqlite+aiosqlite:///{settings.app_settings.app.database_path}" +Path(settings.app_settings.app.database_path).parent.mkdir(parents=True, exist_ok=True) engine = create_async_engine( connection_url, connect_args={"check_same_thread": False}, @@ -16,7 +16,7 @@ autocommit=False, autoflush=False, bind=engine, future=True ) -if settings.sql_log_level.upper() == "DEBUG": +if settings.app_settings.app.logging.sql_log_level.upper() == "DEBUG": def before_cursor_execute(_conn, _cursor, statement, parameters, *args): logging.debug(f"{statement}; args={parameters}") diff --git a/slackhealthbot/domain/usecases/fitbit/usecase_process_daily_activity.py b/slackhealthbot/domain/usecases/fitbit/usecase_process_daily_activity.py index 1ca47575..172a26b1 100644 --- a/slackhealthbot/domain/usecases/fitbit/usecase_process_daily_activity.py +++ b/slackhealthbot/domain/usecases/fitbit/usecase_process_daily_activity.py @@ -52,7 +52,8 @@ async def do( await local_fitbit_repo.get_top_daily_activity_stats_by_user_and_activity_type( fitbit_userid=fitbit_userid, type_id=daily_activity.type_id, - since=now - dt.timedelta(days=settings.fitbit_activity_record_history_days), + since=now + - dt.timedelta(days=settings.app_settings.fitbit.activities.history_days), ) ) @@ -68,5 +69,5 @@ async def do( slack_alias=user_identity.slack_alias, activity_name=activity_names.get(daily_activity.type_id, "Unknown"), history=history, - record_history_days=settings.fitbit_activity_record_history_days, + record_history_days=settings.app_settings.fitbit.activities.history_days, ) diff --git a/slackhealthbot/domain/usecases/fitbit/usecase_process_new_activity.py b/slackhealthbot/domain/usecases/fitbit/usecase_process_new_activity.py index e7d930fe..e221ed4c 100644 --- a/slackhealthbot/domain/usecases/fitbit/usecase_process_new_activity.py +++ b/slackhealthbot/domain/usecases/fitbit/usecase_process_new_activity.py @@ -83,7 +83,9 @@ async def do( fitbit_userid=fitbit_userid, type_id=new_activity_data.type_id, since=datetime.datetime.now(datetime.timezone.utc) - - datetime.timedelta(days=settings.fitbit_activity_record_history_days), + - datetime.timedelta( + days=settings.app_settings.fitbit.activities.history_days + ), ) ) await usecase_post_activity.do( @@ -96,7 +98,7 @@ async def do( all_time_top_activity_data=all_time_top_activity_stats, recent_top_activity_data=recent_top_activity_stats, ), - record_history_days=settings.fitbit_activity_record_history_days, + record_history_days=settings.app_settings.fitbit.activities.history_days, ) return new_activity_data diff --git a/slackhealthbot/domain/usecases/slack/usecase_post_user_logged_out.py b/slackhealthbot/domain/usecases/slack/usecase_post_user_logged_out.py index e157e4bd..7a1dba25 100644 --- a/slackhealthbot/domain/usecases/slack/usecase_post_user_logged_out.py +++ b/slackhealthbot/domain/usecases/slack/usecase_post_user_logged_out.py @@ -12,6 +12,6 @@ async def do( message = f""" Oh no <@{slack_alias}>, looks like you were logged out of {service}! 😳. You'll need to log in again to get your reports: -{settings.server_url}v1/{service}-authorization/{slack_alias} +{settings.app_settings.app.server_url}v1/{service}-authorization/{slack_alias} """ await repo.post_message(message) diff --git a/slackhealthbot/main.py b/slackhealthbot/main.py index e74d2602..3b89d3e1 100644 --- a/slackhealthbot/main.py +++ b/slackhealthbot/main.py @@ -35,7 +35,7 @@ @asynccontextmanager async def lifespan(_app: FastAPI): - logger.configure_logging(settings.sql_log_level) + logger.configure_logging(settings.app_settings.app.logging.sql_log_level) oauth_withings.configure( WithingsUpdateTokenUseCase( request_context_withings_repository, @@ -49,7 +49,7 @@ async def lifespan(_app: FastAPI): ) ) schedule_task = None - if settings.fitbit_poll_enabled: + if settings.app_settings.fitbit.poll.enabled: schedule_task = await fitbitpoll.schedule_fitbit_poll( local_fitbit_repo_factory=fitbit_repository_factory(), remote_fitbit_repo=get_remote_fitbit_repository(), @@ -62,7 +62,7 @@ async def lifespan(_app: FastAPI): local_fitbit_repo_factory=fitbit_repository_factory(), activity_type_ids=set(settings.fitbit_daily_activity_type_ids), slack_repo=get_slack_repository(), - post_time=settings.fitbit_daily_activity_post_time, + post_time=settings.app_settings.fitbit.activities.daily_report_time, ) yield if schedule_task: diff --git a/slackhealthbot/remoteservices/api/slack/messageapi.py b/slackhealthbot/remoteservices/api/slack/messageapi.py index d0fd9b75..1f6af3e3 100644 --- a/slackhealthbot/remoteservices/api/slack/messageapi.py +++ b/slackhealthbot/remoteservices/api/slack/messageapi.py @@ -6,7 +6,7 @@ async def post_message(message: str): async with httpx.AsyncClient() as client: await client.post( - url=str(settings.slack_webhook_url), + url=str(settings.secret_settings.slack_webhook_url), json={ "text": message, }, diff --git a/slackhealthbot/settings.py b/slackhealthbot/settings.py index eecc4ea7..c637cc81 100644 --- a/slackhealthbot/settings.py +++ b/slackhealthbot/settings.py @@ -2,8 +2,13 @@ import datetime as dt from pathlib import Path -from pydantic import AnyHttpUrl, HttpUrl -from pydantic_settings import BaseSettings, SettingsConfigDict +from pydantic import AnyHttpUrl, BaseModel, HttpUrl +from pydantic_settings import ( + BaseSettings, + PydanticBaseSettingsSource, + SettingsConfigDict, + YamlConfigSettingsSource, +) @dataclasses.dataclass @@ -23,56 +28,112 @@ class FitbitOAuthSettings: subscriber_verification_code: str -class Settings(BaseSettings): - database_path: Path = "/tmp/data/slackhealthbot.db" +class Poll(BaseModel): + enabled: bool = True + interval_seconds: int = 3600 + + +class ActivityType(BaseModel): + name: str + id: int + report_daily: bool = False + report_realtime: bool = True + + +class Activities(BaseModel): + daily_report_time: dt.time = dt.time(hour=23, second=50) + history_days: int = 180 + activity_types: list[ActivityType] + + +class Fitbit(BaseModel): + poll: Poll + activities: Activities + base_url: str = "https://api.fitbit.com/" + oauth_scopes: list[str] = ["sleep", "activity"] + + +class Withings(BaseModel): + callback_url: AnyHttpUrl + base_url: str = "https://wbsapi.withings.net/" + oauth_scopes: list[str] = ["user.metrics", "user.activity"] + + +class Logging(BaseModel): + sql_log_level: str = "WARNING" + + +class App(BaseModel): server_url: AnyHttpUrl - withings_base_url: str = "https://wbsapi.withings.net/" - withings_oauth_scopes: list[str] = ["user.metrics", "user.activity"] + database_path: Path = "/tmp/data/slackhealthbot.db" + logging: Logging + + +class AppSettings(BaseSettings): + app: App + withings: Withings + fitbit: Fitbit + model_config = SettingsConfigDict(yaml_file="config/app.yaml") + + @classmethod + def settings_customise_sources( + cls, + settings_cls: BaseSettings, + *args, + **kwargs, + ) -> tuple[PydanticBaseSettingsSource, ...]: + yaml_settings_source = YamlConfigSettingsSource(settings_cls) + return (yaml_settings_source,) + + +class SecretSettings(BaseSettings): withings_client_secret: str withings_client_id: str - withings_callback_url: AnyHttpUrl - fitbit_base_url: str = "https://api.fitbit.com/" - fitbit_oauth_scopes: list[str] = ["sleep", "activity"] fitbit_client_id: str fitbit_client_secret: str fitbit_client_subscriber_verification_code: str - fitbit_poll_interval_s: int = 3600 - fitbit_poll_enabled: bool = True - fitbit_realtime_activity_type_ids: list[int] = [ - # See https://dev.fitbit.com/build/reference/web-api/activity/get-all-activity-types/ - # for the list of all supported activity types and their ids - 55001, # Spinning - 90013, # Walk - # 90001, # Bike - # 90019, # Treadmill - # 1071, # Outdoor Bike - ] - fitbit_daily_activity_type_ids: list[int] = [ - 90019, - ] - fitbit_daily_activity_post_time: dt.time = dt.time(hour=23, second=50) - fitbit_activity_record_history_days: int = 180 slack_webhook_url: HttpUrl - sql_log_level: str = "WARNING" model_config = SettingsConfigDict(env_file=".env") + +@dataclasses.dataclass +class Settings: + app_settings: AppSettings + secret_settings: SecretSettings + @property def withings_oauth_settings(self): return WithingsOAuthSettings( - base_url=self.withings_base_url, - oauth_scopes=self.withings_oauth_scopes, - callback_url=self.withings_callback_url, - redirect_uri=f"{self.withings_callback_url}withings-oauth-webhook/", + base_url=self.app_settings.withings.base_url, + oauth_scopes=self.app_settings.withings.oauth_scopes, + callback_url=self.app_settings.withings.callback_url, + redirect_uri=f"{self.app_settings.withings.callback_url}withings-oauth-webhook/", ) @property def fitbit_oauth_settings(self): return FitbitOAuthSettings( - base_url=self.fitbit_base_url, - oauth_scopes=self.fitbit_oauth_scopes, - subscriber_verification_code=self.fitbit_client_subscriber_verification_code, + base_url=self.app_settings.fitbit.base_url, + oauth_scopes=self.app_settings.fitbit.oauth_scopes, + subscriber_verification_code=self.secret_settings.fitbit_client_subscriber_verification_code, ) + @property + def fitbit_realtime_activity_type_ids(self) -> list[int]: + return [ + x.id + for x in self.app_settings.fitbit.activities.activity_types + if x.report_realtime + ] + + @property + def fitbit_daily_activity_type_ids(self) -> list[int]: + return [ + x.id + for x in self.app_settings.fitbit.activities.activity_types + if x.report_daily + ] + @property def fitbit_activity_type_ids(self) -> list[int]: return ( @@ -80,6 +141,9 @@ def fitbit_activity_type_ids(self) -> list[int]: ) -settings = Settings() +settings = Settings( + app_settings=AppSettings(), + secret_settings=SecretSettings(), +) withings_oauth_settings = settings.withings_oauth_settings fitbit_oauth_settings = settings.fitbit_oauth_settings diff --git a/slackhealthbot/tasks/fitbitpoll.py b/slackhealthbot/tasks/fitbitpoll.py index d294706a..7a270d5e 100644 --- a/slackhealthbot/tasks/fitbitpoll.py +++ b/slackhealthbot/tasks/fitbitpoll.py @@ -181,7 +181,7 @@ async def schedule_fitbit_poll( local_fitbit_repo_factory: Callable[[], AsyncContextManager[LocalFitbitRepository]], remote_fitbit_repo: RemoteFitbitRepository, slack_repo: RemoteSlackRepository, - initial_delay_s: int = settings.fitbit_poll_interval_s, + initial_delay_s: int = settings.app_settings.fitbit.poll.interval_seconds, cache: Cache = None, ): if cache is None: @@ -197,6 +197,6 @@ async def run_with_delay(): remote_fitbit_repo=remote_fitbit_repo, slack_repo=slack_repo, ) - await asyncio.sleep(settings.fitbit_poll_interval_s) + await asyncio.sleep(settings.app_settings.fitbit.poll.interval_seconds) return asyncio.create_task(run_with_delay()) diff --git a/tests/domain/usecases/fitbit/test_usecase_process_daily_activities.py b/tests/domain/usecases/fitbit/test_usecase_process_daily_activities.py index ab49abac..fe5ced5e 100644 --- a/tests/domain/usecases/fitbit/test_usecase_process_daily_activities.py +++ b/tests/domain/usecases/fitbit/test_usecase_process_daily_activities.py @@ -130,9 +130,9 @@ async def test_process_daily_activities( ) # Mock an empty ok response from the slack webhook - slack_request = respx_mock.post(f"{settings.slack_webhook_url}").mock( - return_value=Response(200) - ) + slack_request = respx_mock.post( + f"{settings.secret_settings.slack_webhook_url}" + ).mock(return_value=Response(200)) with monkeypatch.context() as mp: freeze_time( diff --git a/tests/routes/test_fitbit_oauth.py b/tests/routes/test_fitbit_oauth.py index 345e2b09..57846461 100644 --- a/tests/routes/test_fitbit_oauth.py +++ b/tests/routes/test_fitbit_oauth.py @@ -59,7 +59,7 @@ async def test_refresh_token_ok( # Mock fitbit oauth refresh token success oauth_token_refresh_request = respx_mock.post( - url=f"{settings.fitbit_base_url}oauth2/token", + url=f"{settings.fitbit_oauth_settings.base_url}oauth2/token", ).mock( Response( status_code=200, @@ -74,7 +74,7 @@ async def test_refresh_token_ok( # Mock fitbit endpoint to return some activity data fitbit_activity_request = respx_mock.get( - url=f"{settings.fitbit_base_url}1/user/-/activities/list.json", + url=f"{settings.fitbit_oauth_settings.base_url}1/user/-/activities/list.json", ).mock( side_effect=[ Response(status_code=200, json=scenario.input_mock_fitbit_response), @@ -82,9 +82,9 @@ async def test_refresh_token_ok( ) # Mock an empty ok response from the slack webhook - slack_request = respx_mock.post(f"{settings.slack_webhook_url}").mock( - return_value=Response(200) - ) + slack_request = respx_mock.post( + f"{settings.secret_settings.slack_webhook_url}" + ).mock(return_value=Response(200)) # When we receive the callback from fitbit that a new activity is available with client: @@ -167,13 +167,13 @@ async def test_refresh_token_fail( # Mock fitbit oauth refresh token failure oauth_token_refresh_request = respx_mock.post( - url=f"{settings.fitbit_base_url}oauth2/token", + url=f"{settings.fitbit_oauth_settings.base_url}oauth2/token", ).mock(Response(status_code=401)) # Mock an empty ok response from the slack webhook - slack_request = respx_mock.post(f"{settings.slack_webhook_url}").mock( - return_value=Response(200) - ) + slack_request = respx_mock.post( + f"{settings.secret_settings.slack_webhook_url}" + ).mock(return_value=Response(200)) # When we receive the callback from fitbit that a new activity is available with client: @@ -341,13 +341,13 @@ async def test_logged_out( # Mock fitbit endpoint to return an unauthorized error fitbit_activity_request = respx_mock.get( - url=f"{settings.fitbit_base_url}1/user/-/activities/list.json", + url=f"{settings.fitbit_oauth_settings.base_url}1/user/-/activities/list.json", ).mock(Response(status_code=401)) # Mock an empty ok response from the slack webhook - slack_request = respx_mock.post(f"{settings.slack_webhook_url}").mock( - return_value=Response(200) - ) + slack_request = respx_mock.post( + f"{settings.secret_settings.slack_webhook_url}" + ).mock(return_value=Response(200)) # When we receive the callback from fitbit that a new activity is available with client: diff --git a/tests/routes/test_fitbit_routes.py b/tests/routes/test_fitbit_routes.py index d14d7767..aeffa0ae 100644 --- a/tests/routes/test_fitbit_routes.py +++ b/tests/routes/test_fitbit_routes.py @@ -1,6 +1,7 @@ import datetime import json import re +from operator import attrgetter import pytest from fastapi import status @@ -60,13 +61,13 @@ async def test_sleep_notification( # Mock fitbit endpoint to return some sleep data respx_mock.get( - url=f"{settings.fitbit_base_url}1.2/user/-/sleep/date/2023-05-12.json", + url=f"{settings.fitbit_oauth_settings.base_url}1.2/user/-/sleep/date/2023-05-12.json", ).mock(Response(status_code=200, json=scenario.input_mock_fitbit_response)) # Mock an empty ok response from the slack webhook - slack_request = respx_mock.post(f"{settings.slack_webhook_url}").mock( - return_value=Response(200) - ) + slack_request = respx_mock.post( + f"{settings.secret_settings.slack_webhook_url}" + ).mock(return_value=Response(200)) # When we receive the callback from fitbit that a new sleep is available with client: @@ -142,17 +143,23 @@ async def test_activity_notification( # noqa PLR0913 # Mock fitbit endpoint to return some activity data respx_mock.get( - url=f"{settings.fitbit_base_url}1/user/-/activities/list.json", + url=f"{settings.fitbit_oauth_settings.base_url}1/user/-/activities/list.json", ).mock(Response(status_code=200, json=scenario.input_mock_fitbit_response)) # Mock an empty ok response from the slack webhook - slack_request = respx_mock.post(f"{settings.slack_webhook_url}").mock( - return_value=Response(200) - ) + slack_request = respx_mock.post( + f"{settings.secret_settings.slack_webhook_url}" + ).mock(return_value=Response(200)) if scenario.settings_override: for key, value in scenario.settings_override.items(): - monkeypatch.setattr(settings, key, value) + settings_attribute_tokens = key.split(".") + settings_attribute_to_patch = settings_attribute_tokens.pop() + settings_obj_path_to_patch = ".".join(settings_attribute_tokens) + settings_obj_to_patch = attrgetter(settings_obj_path_to_patch)(settings) + monkeypatch.setattr( + settings_obj_to_patch, settings_attribute_to_patch, value + ) # When we receive the callback from fitbit that a new activity is available with client: @@ -226,13 +233,13 @@ async def test_duplicate_activity_notification( # Mock fitbit endpoint to return some activity data activity_request = respx_mock.get( - url=f"{settings.fitbit_base_url}1/user/-/activities/list.json", + url=f"{settings.fitbit_oauth_settings.base_url}1/user/-/activities/list.json", ).mock(Response(status_code=200, json=scenario.input_mock_fitbit_response)) # Mock an empty ok response from the slack webhook - slack_request = respx_mock.post(f"{settings.slack_webhook_url}").mock( - return_value=Response(200) - ) + slack_request = respx_mock.post( + f"{settings.secret_settings.slack_webhook_url}" + ).mock(return_value=Response(200)) # When we receive the callback from fitbit that a new activity is available with client: @@ -317,13 +324,13 @@ async def test_duplicate_sleep_notification( # Mock fitbit endpoint to return some sleep data sleep_request = respx_mock.get( - url=f"{settings.fitbit_base_url}1.2/user/-/sleep/date/2023-05-12.json", + url=f"{settings.fitbit_oauth_settings.base_url}1.2/user/-/sleep/date/2023-05-12.json", ).mock(Response(status_code=200, json=scenario.input_mock_fitbit_response)) # Mock an empty ok response from the slack webhook - slack_request = respx_mock.post(f"{settings.slack_webhook_url}").mock( - return_value=Response(200) - ) + slack_request = respx_mock.post( + f"{settings.secret_settings.slack_webhook_url}" + ).mock(return_value=Response(200)) # When we receive the callback from fitbit that a new activity is available with client: diff --git a/tests/routes/test_fitbit_validate_routes.py b/tests/routes/test_fitbit_validate_routes.py index 5cd5677c..781acbe5 100644 --- a/tests/routes/test_fitbit_validate_routes.py +++ b/tests/routes/test_fitbit_validate_routes.py @@ -17,7 +17,7 @@ class Scenario: SCENARIOS = [ Scenario( name="valid verify code", - input_verify_value=settings.fitbit_client_subscriber_verification_code, + input_verify_value=settings.secret_settings.fitbit_client_subscriber_verification_code, expected_response_status_code=status.HTTP_204_NO_CONTENT, ), Scenario( diff --git a/tests/routes/test_withings_oauth.py b/tests/routes/test_withings_oauth.py index 6cc236f7..7dbdbb88 100644 --- a/tests/routes/test_withings_oauth.py +++ b/tests/routes/test_withings_oauth.py @@ -52,7 +52,7 @@ async def test_refresh_token_ok( # Mock withings oauth refresh token success oauth_token_refresh_request = respx_mock.post( - url=f"{settings.withings_base_url}v2/oauth2", + url=f"{settings.app_settings.withings.base_url}v2/oauth2", ).mock( Response( status_code=200, @@ -70,7 +70,7 @@ async def test_refresh_token_ok( # Mock withings endpoint to return some weight data withings_weight_request = respx_mock.post( - url=f"{settings.withings_base_url}measure", + url=f"{settings.app_settings.withings.base_url}measure", ).mock( Response( status_code=200, @@ -93,9 +93,9 @@ async def test_refresh_token_ok( ) # Mock an empty ok response from the slack webhook - slack_request = respx_mock.post(url=f"{settings.slack_webhook_url}").mock( - return_value=Response(status_code=200) - ) + slack_request = respx_mock.post( + url=f"{settings.secret_settings.slack_webhook_url}" + ).mock(return_value=Response(status_code=200)) # When we receive the callback from withings that a new weight is available with client as client_ctx: @@ -159,13 +159,13 @@ async def test_refresh_token_fail( # Mock withings oauth refresh token fail oauth_token_refresh_request = respx_mock.post( - url=f"{settings.withings_base_url}v2/oauth2", + url=f"{settings.app_settings.withings.base_url}v2/oauth2", ).mock(Response(status_code=200, json={"status": 401})) # Mock an empty ok response from the slack webhook - slack_request = respx_mock.post(url=f"{settings.slack_webhook_url}").mock( - return_value=Response(status_code=200) - ) + slack_request = respx_mock.post( + url=f"{settings.secret_settings.slack_webhook_url}" + ).mock(return_value=Response(status_code=200)) # When we receive the callback from withings that a new weight is available with client as client_ctx: @@ -325,13 +325,13 @@ async def test_logged_out( # Mock withings endpoint to return an unauthorized error withings_weight_request = respx_mock.post( - url=f"{settings.withings_base_url}measure", + url=f"{settings.app_settings.withings.base_url}measure", ).mock(return_value=Response(status_code=200, json={"status": 401})) # Mock an empty ok response from the slack webhook - slack_request = respx_mock.post(f"{settings.slack_webhook_url}").mock( - return_value=Response(200) - ) + slack_request = respx_mock.post( + f"{settings.secret_settings.slack_webhook_url}" + ).mock(return_value=Response(200)) # When we receive the callback from withings that a new weight is available with client as client_ctx: response = client_ctx.post( diff --git a/tests/routes/test_withings_routes.py b/tests/routes/test_withings_routes.py index de7dca6b..c184416e 100644 --- a/tests/routes/test_withings_routes.py +++ b/tests/routes/test_withings_routes.py @@ -68,7 +68,7 @@ async def test_weight_notification( # Mock withings endpoint to return some weight data respx_mock.post( - url=f"{settings.withings_base_url}measure", + url=f"{settings.app_settings.withings.base_url}measure", ).mock( return_value=Response( status_code=200, @@ -91,9 +91,9 @@ async def test_weight_notification( ) # Mock an empty ok response from the slack webhook - slack_request = respx_mock.post(url=f"{settings.slack_webhook_url}").mock( - return_value=Response(status_code=200) - ) + slack_request = respx_mock.post( + url=f"{settings.secret_settings.slack_webhook_url}" + ).mock(return_value=Response(status_code=200)) # When we receive the callback from withings that a new weight is available # Use the client as a context manager so the app can have its lfespan events triggered. @@ -152,7 +152,7 @@ async def test_duplicate_weight_notification( # Mock withings endpoint to return some weight data weight_request = respx_mock.post( - url=f"{settings.withings_base_url}measure", + url=f"{settings.app_settings.withings.base_url}measure", ).mock( return_value=Response( status_code=200, @@ -175,9 +175,9 @@ async def test_duplicate_weight_notification( ) # Mock an empty ok response from the slack webhook - slack_request = respx_mock.post(url=f"{settings.slack_webhook_url}").mock( - return_value=Response(status_code=200) - ) + slack_request = respx_mock.post( + url=f"{settings.secret_settings.slack_webhook_url}" + ).mock(return_value=Response(status_code=200)) # When we receive the callback from withings that a new weight is available # Use the client as a context manager so the app can have its lfespan events triggered. diff --git a/tests/tasks/test_fitbit_oauth.py b/tests/tasks/test_fitbit_oauth.py index a7aa8f80..d2540517 100644 --- a/tests/tasks/test_fitbit_oauth.py +++ b/tests/tasks/test_fitbit_oauth.py @@ -65,7 +65,7 @@ async def test_refresh_token_ok( # Mock fitbit oauth refresh token success oauth_token_refresh_request = respx_mock.post( - url=f"{settings.fitbit_base_url}oauth2/token", + url=f"{settings.fitbit_oauth_settings.base_url}oauth2/token", ).mock( Response( status_code=200, @@ -80,12 +80,12 @@ async def test_refresh_token_ok( # Mock fitbit endpoint to return no sleep data respx_mock.get( - url=f"{settings.fitbit_base_url}1.2/user/-/sleep/date/2023-01-23.json", + url=f"{settings.fitbit_oauth_settings.base_url}1.2/user/-/sleep/date/2023-01-23.json", ).mock(Response(status_code=200, json={"sleep": []})) # Mock fitbit endpoint to return some activity data fitbit_activity_request = respx_mock.get( - url=f"{settings.fitbit_base_url}1/user/-/activities/list.json", + url=f"{settings.fitbit_oauth_settings.base_url}1/user/-/activities/list.json", ).mock( side_effect=[ Response(status_code=200, json=scenario.input_mock_fitbit_response), @@ -93,9 +93,9 @@ async def test_refresh_token_ok( ) # Mock an empty ok response from the slack webhook - slack_request = respx_mock.post(f"{settings.slack_webhook_url}").mock( - return_value=Response(200) - ) + slack_request = respx_mock.post( + f"{settings.secret_settings.slack_webhook_url}" + ).mock(return_value=Response(200)) # When we poll for new activity data # Use the client as a context manager so that the app lifespan hook is called @@ -175,13 +175,13 @@ async def test_refresh_token_fail( # Mock fitbit oauth refresh token failure oauth_token_refresh_request = respx_mock.post( - url=f"{settings.fitbit_base_url}oauth2/token", + url=f"{settings.fitbit_oauth_settings.base_url}oauth2/token", ).mock(Response(status_code=401)) # Mock an empty ok response from the slack webhook - slack_request = respx_mock.post(f"{settings.slack_webhook_url}").mock( - return_value=Response(200) - ) + slack_request = respx_mock.post( + f"{settings.secret_settings.slack_webhook_url}" + ).mock(return_value=Response(200)) # When we poll for new activity data # Use the client as a context manager so that the app lifespan hook is called @@ -259,16 +259,16 @@ async def test_logged_out( # Mock fitbit endpoints to return an unauthorized error respx_mock.get( - url=f"{settings.fitbit_base_url}1.2/user/-/sleep/date/2023-01-23.json", + url=f"{settings.fitbit_oauth_settings.base_url}1.2/user/-/sleep/date/2023-01-23.json", ).mock(Response(status_code=401)) fitbit_activity_request = respx_mock.get( - url=f"{settings.fitbit_base_url}1/user/-/activities/list.json", + url=f"{settings.fitbit_oauth_settings.base_url}1/user/-/activities/list.json", ).mock(Response(status_code=401)) # Mock an empty ok response from the slack webhook - slack_request = respx_mock.post(f"{settings.slack_webhook_url}").mock( - return_value=Response(200) - ) + slack_request = respx_mock.post( + f"{settings.secret_settings.slack_webhook_url}" + ).mock(return_value=Response(200)) # When we poll for new activity data # Use the client as a context manager so that the app lifespan hook is called diff --git a/tests/tasks/test_fitbit_poll.py b/tests/tasks/test_fitbit_poll.py index 5f570e62..77360581 100644 --- a/tests/tasks/test_fitbit_poll.py +++ b/tests/tasks/test_fitbit_poll.py @@ -2,6 +2,7 @@ import datetime import json import re +from operator import attrgetter import pytest from fastapi.testclient import TestClient @@ -76,18 +77,18 @@ async def test_fitbit_poll_sleep( # Mock fitbit endpoint to return no activity data respx_mock.get( - url=f"{settings.fitbit_base_url}1/user/-/activities/list.json", + url=f"{settings.fitbit_oauth_settings.base_url}1/user/-/activities/list.json", ).mock(Response(status_code=200, json={"activities": []})) # Mock fitbit endpoint to return some sleep data respx_mock.get( - url=f"{settings.fitbit_base_url}1.2/user/-/sleep/date/2023-01-23.json", + url=f"{settings.fitbit_oauth_settings.base_url}1.2/user/-/sleep/date/2023-01-23.json", ).mock(Response(status_code=200, json=scenario.input_mock_fitbit_response)) # Mock an empty ok response from the slack webhook - slack_request = respx_mock.post(f"{settings.slack_webhook_url}").mock( - return_value=Response(200) - ) + slack_request = respx_mock.post( + f"{settings.secret_settings.slack_webhook_url}" + ).mock(return_value=Response(200)) # When we poll for new sleep data # Use the client as a context manager so that the app lifespan hook is called @@ -158,22 +159,28 @@ async def test_fitbit_poll_activity( # noqa PLR0913 # Mock fitbit endpoint to return no sleep data respx_mock.get( - url=f"{settings.fitbit_base_url}1.2/user/-/sleep/date/2023-01-23.json", + url=f"{settings.fitbit_oauth_settings.base_url}1.2/user/-/sleep/date/2023-01-23.json", ).mock(Response(status_code=200, json={"sleep": []})) # Mock fitbit endpoint to return some activity data respx_mock.get( - url=f"{settings.fitbit_base_url}1/user/-/activities/list.json", + url=f"{settings.fitbit_oauth_settings.base_url}1/user/-/activities/list.json", ).mock(Response(status_code=200, json=scenario.input_mock_fitbit_response)) # Mock an empty ok response from the slack webhook - slack_request = respx_mock.post(f"{settings.slack_webhook_url}").mock( - return_value=Response(200) - ) + slack_request = respx_mock.post( + f"{settings.secret_settings.slack_webhook_url}" + ).mock(return_value=Response(200)) if scenario.settings_override: for key, value in scenario.settings_override.items(): - monkeypatch.setattr(settings, key, value) + settings_attribute_tokens = key.split(".") + settings_attribute_to_patch = settings_attribute_tokens.pop() + settings_obj_path_to_patch = ".".join(settings_attribute_tokens) + settings_obj_to_patch = attrgetter(settings_obj_path_to_patch)(settings) + monkeypatch.setattr( + settings_obj_to_patch, settings_attribute_to_patch, value + ) # When we poll for new sleep data # Use the client as a context manager so that the app lifespan hook is called @@ -219,8 +226,8 @@ async def test_schedule_fitbit_poll( fitbit_factories: tuple[UserFactory, FitbitUserFactory, FitbitActivityFactory], monkeypatch: pytest.MonkeyPatch, ): - monkeypatch.setattr(settings, "fitbit_poll_enabled", False) - monkeypatch.setattr(settings, "fitbit_poll_interval_s", 3) + monkeypatch.setattr(settings.app_settings.fitbit.poll, "enabled", False) + monkeypatch.setattr(settings.app_settings.fitbit.poll, "interval_seconds", 3) sleep_scenario: FitbitSleepScenario = sleep_scenarios["No previous sleep data"] activity_scenario: FitbitActivityScenario = activity_scenarios[ "No previous activity data, new Spinning activity" @@ -238,16 +245,18 @@ async def test_schedule_fitbit_poll( # Mock fitbit endpoint to return some sleep and activity data respx_mock.get( - url=re.compile(f"{settings.fitbit_base_url}1.2/user/-/sleep/date/[0-9-]*.json"), + url=re.compile( + f"{settings.fitbit_oauth_settings.base_url}1.2/user/-/sleep/date/[0-9-]*.json" + ), ).mock(Response(status_code=200, json=sleep_scenario.input_mock_fitbit_response)) respx_mock.get( - url=f"{settings.fitbit_base_url}1/user/-/activities/list.json", + url=f"{settings.fitbit_oauth_settings.base_url}1/user/-/activities/list.json", ).mock(Response(status_code=200, json=activity_scenario.input_mock_fitbit_response)) # Mock an empty ok response from the slack webhook - slack_request = respx_mock.post(f"{settings.slack_webhook_url}").mock( - return_value=Response(200) - ) + slack_request = respx_mock.post( + f"{settings.secret_settings.slack_webhook_url}" + ).mock(return_value=Response(200)) fitbitconfig.configure( UpdateTokenUseCase( diff --git a/tests/tasks/test_post_daily_activities.py b/tests/tasks/test_post_daily_activities.py index 8d784e08..f14338b0 100644 --- a/tests/tasks/test_post_daily_activities.py +++ b/tests/tasks/test_post_daily_activities.py @@ -103,9 +103,9 @@ async def test_post_daily_activities( ) # Mock an empty ok response from the slack webhook - slack_request = respx_mock.post(f"{settings.slack_webhook_url}").mock( - return_value=Response(200) - ) + slack_request = respx_mock.post( + f"{settings.secret_settings.slack_webhook_url}" + ).mock(return_value=Response(200)) # Freeze time to just before the scheduled post time. freeze_time( @@ -117,7 +117,7 @@ async def test_post_daily_activities( local_fitbit_repo_factory=fitbit_repository_factory(mocked_async_session), activity_type_ids=set(settings.fitbit_daily_activity_type_ids), slack_repo=WebhookSlackRepository(), - post_time=settings.fitbit_daily_activity_post_time, + post_time=settings.app_settings.fitbit.activities.daily_report_time, ) # Wait for one iteration of the scheduled task: diff --git a/tests/testsupport/fixtures/fitbit_scenarios.py b/tests/testsupport/fixtures/fitbit_scenarios.py index ded19240..7b0efeb6 100644 --- a/tests/testsupport/fixtures/fitbit_scenarios.py +++ b/tests/testsupport/fixtures/fitbit_scenarios.py @@ -3,6 +3,7 @@ from typing import Any from slackhealthbot.domain.models.sleep import SleepData +from slackhealthbot.settings import ActivityType @dataclasses.dataclass @@ -611,8 +612,14 @@ class FitbitActivityScenario: expected_new_activity_created=True, expected_message_pattern=None, settings_override={ - "fitbit_realtime_activity_type_ids": [], - "fitbit_daily_activity_type_ids": [55001], + "app_settings.fitbit.activities.activity_types": [ + ActivityType( + name="Spinning", + id=55001, + report_realtime=False, + report_daily=True, + ) + ], }, ), "Invalid json response": FitbitActivityScenario(