diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml new file mode 100644 index 00000000..3c9b5efd --- /dev/null +++ b/.github/workflows/pr.yml @@ -0,0 +1,37 @@ +name: PR check +on: + pull_request: + push: + branches: + - main +jobs: + PR: + runs-on: ubuntu-latest + permissions: + checks: write + steps: + - uses: actions/checkout@v3 + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: '3.11' + - name: Install dependencies + run: | + python -m pip install -r requirements/dev.txt --upgrade pip + - name: Create fake .env file + run: cp .env.template .env + - name: Run codecheck + run: bash scripts/codecheck.sh + - name: Run tests + run: bash -x scripts/run_tests.sh + - name: Publish Test Report + uses: mikepenz/action-junit-report@v3 + if: always() # always run even if the previous step fails + with: + report_paths: 'reports/junit.xml' + - name: Archive reports + if: always() + uses: actions/upload-artifact@v3 + with: + name: reports + path: reports diff --git a/.gitignore b/.gitignore index 2e6d3c0a..295eb22b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ .vscode .env +.coverage +reports/ __pycache__/ diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 00000000..395500b3 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,2 @@ +[pytest] +log_cli=true diff --git a/requirements/dev.txt b/requirements/dev.txt index a2da7680..95ca730e 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -1,3 +1,5 @@ -r prod.txt ruff==0.0.263 black==23.3.0 +pytest==7.3.1 +pytest-cov==4.0.0 diff --git a/scripts/codecheck.sh b/scripts/codecheck.sh index a2ed792e..dac50908 100644 --- a/scripts/codecheck.sh +++ b/scripts/codecheck.sh @@ -1,4 +1,4 @@ -for project in withingsslack alembic +for project in withingsslack alembic tests do black $project ruff check $project diff --git a/scripts/run_tests.sh b/scripts/run_tests.sh new file mode 100644 index 00000000..ed30adbb --- /dev/null +++ b/scripts/run_tests.sh @@ -0,0 +1,4 @@ +rm -rf reports +python -m pytest --cov=withingsslack --cov-report=xml --cov-report=html --junitxml="reports/junit.xml" tests +mkdir -p reports +mv coverage.xml htmlcov reports/. diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/data/fitbit_sleep_response_1_item.json b/tests/data/fitbit_sleep_response_1_item.json new file mode 100644 index 00000000..678a8a49 --- /dev/null +++ b/tests/data/fitbit_sleep_response_1_item.json @@ -0,0 +1,310 @@ +{ + "sleep": [ + { + "dateOfSleep": "2023-05-13", + "duration": 31620000, + "efficiency": 95, + "endTime": "2023-05-13T09:27:30.000", + "infoCode": 0, + "isMainSleep": true, + "levels": { + "data": [ + { + "dateTime": "2023-05-13T00:40:00.000", + "level": "light", + "seconds": 60 + }, + { + "dateTime": "2023-05-13T00:41:00.000", + "level": "wake", + "seconds": 210 + }, + { + "dateTime": "2023-05-13T00:44:30.000", + "level": "deep", + "seconds": 1230 + }, + { + "dateTime": "2023-05-13T01:05:00.000", + "level": "light", + "seconds": 960 + }, + { + "dateTime": "2023-05-13T01:21:00.000", + "level": "deep", + "seconds": 1770 + }, + { + "dateTime": "2023-05-13T01:50:30.000", + "level": "light", + "seconds": 6180 + }, + { + "dateTime": "2023-05-13T03:33:30.000", + "level": "rem", + "seconds": 270 + }, + { + "dateTime": "2023-05-13T03:38:00.000", + "level": "light", + "seconds": 60 + }, + { + "dateTime": "2023-05-13T03:39:00.000", + "level": "wake", + "seconds": 450 + }, + { + "dateTime": "2023-05-13T03:46:30.000", + "level": "light", + "seconds": 360 + }, + { + "dateTime": "2023-05-13T03:52:30.000", + "level": "deep", + "seconds": 2520 + }, + { + "dateTime": "2023-05-13T04:34:30.000", + "level": "light", + "seconds": 360 + }, + { + "dateTime": "2023-05-13T04:40:30.000", + "level": "rem", + "seconds": 1980 + }, + { + "dateTime": "2023-05-13T05:13:30.000", + "level": "light", + "seconds": 3390 + }, + { + "dateTime": "2023-05-13T06:10:00.000", + "level": "rem", + "seconds": 1560 + }, + { + "dateTime": "2023-05-13T06:36:00.000", + "level": "light", + "seconds": 360 + }, + { + "dateTime": "2023-05-13T06:42:00.000", + "level": "rem", + "seconds": 2730 + }, + { + "dateTime": "2023-05-13T07:27:30.000", + "level": "light", + "seconds": 3780 + }, + { + "dateTime": "2023-05-13T08:30:30.000", + "level": "rem", + "seconds": 1050 + }, + { + "dateTime": "2023-05-13T08:48:00.000", + "level": "light", + "seconds": 1020 + }, + { + "dateTime": "2023-05-13T09:05:00.000", + "level": "wake", + "seconds": 1350 + } + ], + "shortData": [ + { + "dateTime": "2023-05-13T01:48:00.000", + "level": "wake", + "seconds": 150 + }, + { + "dateTime": "2023-05-13T02:01:30.000", + "level": "wake", + "seconds": 120 + }, + { + "dateTime": "2023-05-13T02:13:30.000", + "level": "wake", + "seconds": 150 + }, + { + "dateTime": "2023-05-13T02:23:30.000", + "level": "wake", + "seconds": 30 + }, + { + "dateTime": "2023-05-13T02:50:30.000", + "level": "wake", + "seconds": 30 + }, + { + "dateTime": "2023-05-13T03:14:00.000", + "level": "wake", + "seconds": 60 + }, + { + "dateTime": "2023-05-13T03:32:30.000", + "level": "wake", + "seconds": 30 + }, + { + "dateTime": "2023-05-13T04:33:00.000", + "level": "wake", + "seconds": 90 + }, + { + "dateTime": "2023-05-13T04:38:00.000", + "level": "wake", + "seconds": 60 + }, + { + "dateTime": "2023-05-13T04:53:30.000", + "level": "wake", + "seconds": 60 + }, + { + "dateTime": "2023-05-13T05:01:00.000", + "level": "wake", + "seconds": 60 + }, + { + "dateTime": "2023-05-13T05:07:30.000", + "level": "wake", + "seconds": 30 + }, + { + "dateTime": "2023-05-13T05:15:00.000", + "level": "wake", + "seconds": 120 + }, + { + "dateTime": "2023-05-13T05:22:00.000", + "level": "wake", + "seconds": 30 + }, + { + "dateTime": "2023-05-13T06:13:00.000", + "level": "wake", + "seconds": 30 + }, + { + "dateTime": "2023-05-13T06:19:30.000", + "level": "wake", + "seconds": 30 + }, + { + "dateTime": "2023-05-13T06:29:30.000", + "level": "wake", + "seconds": 30 + }, + { + "dateTime": "2023-05-13T06:47:30.000", + "level": "wake", + "seconds": 30 + }, + { + "dateTime": "2023-05-13T07:01:00.000", + "level": "wake", + "seconds": 30 + }, + { + "dateTime": "2023-05-13T07:09:00.000", + "level": "wake", + "seconds": 30 + }, + { + "dateTime": "2023-05-13T07:15:30.000", + "level": "wake", + "seconds": 30 + }, + { + "dateTime": "2023-05-13T07:30:00.000", + "level": "wake", + "seconds": 120 + }, + { + "dateTime": "2023-05-13T07:43:00.000", + "level": "wake", + "seconds": 30 + }, + { + "dateTime": "2023-05-13T08:24:30.000", + "level": "wake", + "seconds": 30 + }, + { + "dateTime": "2023-05-13T08:28:30.000", + "level": "wake", + "seconds": 60 + }, + { + "dateTime": "2023-05-13T08:35:00.000", + "level": "wake", + "seconds": 30 + }, + { + "dateTime": "2023-05-13T08:46:00.000", + "level": "wake", + "seconds": 30 + }, + { + "dateTime": "2023-05-13T08:48:30.000", + "level": "wake", + "seconds": 180 + }, + { + "dateTime": "2023-05-13T08:58:00.000", + "level": "wake", + "seconds": 30 + } + ], + "summary": { + "deep": { + "count": 3, + "minutes": 88, + "thirtyDayAvgMinutes": 87 + }, + "light": { + "count": 25, + "minutes": 258, + "thirtyDayAvgMinutes": 268 + }, + "rem": { + "count": 17, + "minutes": 119, + "thirtyDayAvgMinutes": 96 + }, + "wake": { + "count": 32, + "minutes": 62, + "thirtyDayAvgMinutes": 57 + } + } + }, + "logId": 41314291715, + "logType": "auto_detected", + "minutesAfterWakeup": 0, + "minutesAsleep": 465, + "minutesAwake": 62, + "minutesToFallAsleep": 0, + "startTime": "2023-05-13T00:40:00.000", + "timeInBed": 527, + "type": "stages" + } + ], + "summary": { + "stages": { + "deep": 88, + "light": 258, + "rem": 119, + "wake": 62 + }, + "totalMinutesAsleep": 465, + "totalSleepRecords": 1, + "totalTimeInBed": 527 + } +} diff --git a/tests/data/fitbit_sleep_response_2_items.json b/tests/data/fitbit_sleep_response_2_items.json new file mode 100644 index 00000000..05cb2111 --- /dev/null +++ b/tests/data/fitbit_sleep_response_2_items.json @@ -0,0 +1,430 @@ +{ + "sleep": [ + { + "dateOfSleep": "2023-05-14", + "duration": 11040000, + "efficiency": 96, + "endTime": "2023-05-14T18:30:30.000", + "infoCode": 0, + "isMainSleep": false, + "levels": { + "data": [ + { + "dateTime": "2023-05-14T15:26:00.000", + "level": "wake", + "seconds": 540 + }, + { + "dateTime": "2023-05-14T15:35:00.000", + "level": "light", + "seconds": 4440 + }, + { + "dateTime": "2023-05-14T16:49:00.000", + "level": "rem", + "seconds": 450 + }, + { + "dateTime": "2023-05-14T16:56:30.000", + "level": "light", + "seconds": 3570 + }, + { + "dateTime": "2023-05-14T17:56:00.000", + "level": "deep", + "seconds": 300 + }, + { + "dateTime": "2023-05-14T18:01:00.000", + "level": "light", + "seconds": 600 + }, + { + "dateTime": "2023-05-14T18:11:00.000", + "level": "rem", + "seconds": 270 + }, + { + "dateTime": "2023-05-14T18:15:30.000", + "level": "wake", + "seconds": 900 + } + ], + "shortData": [ + { + "dateTime": "2023-05-14T15:37:00.000", + "level": "wake", + "seconds": 120 + }, + { + "dateTime": "2023-05-14T16:02:00.000", + "level": "wake", + "seconds": 30 + }, + { + "dateTime": "2023-05-14T16:25:30.000", + "level": "wake", + "seconds": 30 + }, + { + "dateTime": "2023-05-14T16:56:30.000", + "level": "wake", + "seconds": 30 + }, + { + "dateTime": "2023-05-14T17:28:30.000", + "level": "wake", + "seconds": 30 + }, + { + "dateTime": "2023-05-14T18:10:30.000", + "level": "wake", + "seconds": 30 + } + ], + "summary": { + "deep": { + "count": 1, + "minutes": 5, + "thirtyDayAvgMinutes": 88 + }, + "light": { + "count": 7, + "minutes": 139, + "thirtyDayAvgMinutes": 267 + }, + "rem": { + "count": 2, + "minutes": 12, + "thirtyDayAvgMinutes": 99 + }, + "wake": { + "count": 8, + "minutes": 28, + "thirtyDayAvgMinutes": 57 + } + } + }, + "logId": 41332621161, + "logType": "auto_detected", + "minutesAfterWakeup": 2, + "minutesAsleep": 156, + "minutesAwake": 28, + "minutesToFallAsleep": 0, + "startTime": "2023-05-14T15:26:00.000", + "timeInBed": 184, + "type": "stages" + }, + { + "dateOfSleep": "2023-05-14", + "duration": 27120000, + "efficiency": 98, + "endTime": "2023-05-14T09:23:30.000", + "infoCode": 0, + "isMainSleep": true, + "levels": { + "data": [ + { + "dateTime": "2023-05-14T01:51:00.000", + "level": "wake", + "seconds": 450 + }, + { + "dateTime": "2023-05-14T01:58:30.000", + "level": "light", + "seconds": 1110 + }, + { + "dateTime": "2023-05-14T02:17:00.000", + "level": "deep", + "seconds": 930 + }, + { + "dateTime": "2023-05-14T02:32:30.000", + "level": "light", + "seconds": 540 + }, + { + "dateTime": "2023-05-14T02:41:30.000", + "level": "rem", + "seconds": 1170 + }, + { + "dateTime": "2023-05-14T03:01:00.000", + "level": "light", + "seconds": 1170 + }, + { + "dateTime": "2023-05-14T03:20:30.000", + "level": "deep", + "seconds": 2430 + }, + { + "dateTime": "2023-05-14T04:01:00.000", + "level": "light", + "seconds": 1350 + }, + { + "dateTime": "2023-05-14T04:23:30.000", + "level": "rem", + "seconds": 1410 + }, + { + "dateTime": "2023-05-14T04:47:00.000", + "level": "light", + "seconds": 1470 + }, + { + "dateTime": "2023-05-14T05:11:30.000", + "level": "deep", + "seconds": 810 + }, + { + "dateTime": "2023-05-14T05:25:00.000", + "level": "light", + "seconds": 930 + }, + { + "dateTime": "2023-05-14T05:40:30.000", + "level": "rem", + "seconds": 2190 + }, + { + "dateTime": "2023-05-14T06:17:00.000", + "level": "wake", + "seconds": 300 + }, + { + "dateTime": "2023-05-14T06:22:00.000", + "level": "light", + "seconds": 1830 + }, + { + "dateTime": "2023-05-14T06:52:30.000", + "level": "rem", + "seconds": 3240 + }, + { + "dateTime": "2023-05-14T07:46:30.000", + "level": "light", + "seconds": 1290 + }, + { + "dateTime": "2023-05-14T08:08:00.000", + "level": "deep", + "seconds": 1620 + }, + { + "dateTime": "2023-05-14T08:35:00.000", + "level": "light", + "seconds": 240 + }, + { + "dateTime": "2023-05-14T08:39:00.000", + "level": "rem", + "seconds": 1080 + }, + { + "dateTime": "2023-05-14T08:57:00.000", + "level": "light", + "seconds": 1590 + } + ], + "shortData": [ + { + "dateTime": "2023-05-14T02:03:30.000", + "level": "wake", + "seconds": 120 + }, + { + "dateTime": "2023-05-14T02:09:00.000", + "level": "wake", + "seconds": 30 + }, + { + "dateTime": "2023-05-14T02:31:00.000", + "level": "wake", + "seconds": 90 + }, + { + "dateTime": "2023-05-14T02:36:00.000", + "level": "wake", + "seconds": 30 + }, + { + "dateTime": "2023-05-14T02:50:00.000", + "level": "wake", + "seconds": 30 + }, + { + "dateTime": "2023-05-14T02:53:30.000", + "level": "wake", + "seconds": 30 + }, + { + "dateTime": "2023-05-14T03:04:00.000", + "level": "wake", + "seconds": 30 + }, + { + "dateTime": "2023-05-14T03:59:30.000", + "level": "wake", + "seconds": 90 + }, + { + "dateTime": "2023-05-14T04:40:00.000", + "level": "wake", + "seconds": 30 + }, + { + "dateTime": "2023-05-14T04:47:30.000", + "level": "wake", + "seconds": 60 + }, + { + "dateTime": "2023-05-14T04:50:00.000", + "level": "wake", + "seconds": 30 + }, + { + "dateTime": "2023-05-14T04:52:00.000", + "level": "wake", + "seconds": 30 + }, + { + "dateTime": "2023-05-14T05:25:00.000", + "level": "wake", + "seconds": 60 + }, + { + "dateTime": "2023-05-14T05:44:00.000", + "level": "wake", + "seconds": 30 + }, + { + "dateTime": "2023-05-14T05:55:30.000", + "level": "wake", + "seconds": 30 + }, + { + "dateTime": "2023-05-14T06:03:00.000", + "level": "wake", + "seconds": 30 + }, + { + "dateTime": "2023-05-14T06:08:30.000", + "level": "wake", + "seconds": 30 + }, + { + "dateTime": "2023-05-14T06:15:00.000", + "level": "wake", + "seconds": 30 + }, + { + "dateTime": "2023-05-14T07:06:30.000", + "level": "wake", + "seconds": 30 + }, + { + "dateTime": "2023-05-14T07:17:30.000", + "level": "wake", + "seconds": 30 + }, + { + "dateTime": "2023-05-14T07:26:00.000", + "level": "wake", + "seconds": 60 + }, + { + "dateTime": "2023-05-14T07:36:00.000", + "level": "wake", + "seconds": 30 + }, + { + "dateTime": "2023-05-14T07:47:30.000", + "level": "wake", + "seconds": 30 + }, + { + "dateTime": "2023-05-14T08:33:30.000", + "level": "wake", + "seconds": 90 + }, + { + "dateTime": "2023-05-14T08:41:30.000", + "level": "wake", + "seconds": 30 + }, + { + "dateTime": "2023-05-14T08:48:00.000", + "level": "wake", + "seconds": 30 + }, + { + "dateTime": "2023-05-14T08:58:30.000", + "level": "wake", + "seconds": 30 + }, + { + "dateTime": "2023-05-14T09:05:30.000", + "level": "wake", + "seconds": 30 + }, + { + "dateTime": "2023-05-14T09:18:00.000", + "level": "wake", + "seconds": 60 + }, + { + "dateTime": "2023-05-14T09:22:00.000", + "level": "wake", + "seconds": 90 + } + ], + "summary": { + "deep": { + "count": 4, + "minutes": 92, + "thirtyDayAvgMinutes": 88 + }, + "light": { + "count": 21, + "minutes": 181, + "thirtyDayAvgMinutes": 267 + }, + "rem": { + "count": 19, + "minutes": 144, + "thirtyDayAvgMinutes": 99 + }, + "wake": { + "count": 32, + "minutes": 35, + "thirtyDayAvgMinutes": 57 + } + } + }, + "logId": 41325676437, + "logType": "auto_detected", + "minutesAfterWakeup": 0, + "minutesAsleep": 417, + "minutesAwake": 35, + "minutesToFallAsleep": 0, + "startTime": "2023-05-14T01:51:00.000", + "timeInBed": 452, + "type": "stages" + } + ], + "summary": { + "stages": { + "deep": 97, + "light": 321, + "rem": 156, + "wake": 63 + }, + "totalMinutesAsleep": 573, + "totalSleepRecords": 2, + "totalTimeInBed": 636 + } +} diff --git a/tests/data/fitbit_sleep_response_no_main_sleep_item.json b/tests/data/fitbit_sleep_response_no_main_sleep_item.json new file mode 100644 index 00000000..c70c43ca --- /dev/null +++ b/tests/data/fitbit_sleep_response_no_main_sleep_item.json @@ -0,0 +1,310 @@ +{ + "sleep": [ + { + "dateOfSleep": "2023-05-13", + "duration": 31620000, + "efficiency": 95, + "endTime": "2023-05-13T09:27:30.000", + "infoCode": 0, + "isMainSleep": false, + "levels": { + "data": [ + { + "dateTime": "2023-05-13T00:40:00.000", + "level": "light", + "seconds": 60 + }, + { + "dateTime": "2023-05-13T00:41:00.000", + "level": "wake", + "seconds": 210 + }, + { + "dateTime": "2023-05-13T00:44:30.000", + "level": "deep", + "seconds": 1230 + }, + { + "dateTime": "2023-05-13T01:05:00.000", + "level": "light", + "seconds": 960 + }, + { + "dateTime": "2023-05-13T01:21:00.000", + "level": "deep", + "seconds": 1770 + }, + { + "dateTime": "2023-05-13T01:50:30.000", + "level": "light", + "seconds": 6180 + }, + { + "dateTime": "2023-05-13T03:33:30.000", + "level": "rem", + "seconds": 270 + }, + { + "dateTime": "2023-05-13T03:38:00.000", + "level": "light", + "seconds": 60 + }, + { + "dateTime": "2023-05-13T03:39:00.000", + "level": "wake", + "seconds": 450 + }, + { + "dateTime": "2023-05-13T03:46:30.000", + "level": "light", + "seconds": 360 + }, + { + "dateTime": "2023-05-13T03:52:30.000", + "level": "deep", + "seconds": 2520 + }, + { + "dateTime": "2023-05-13T04:34:30.000", + "level": "light", + "seconds": 360 + }, + { + "dateTime": "2023-05-13T04:40:30.000", + "level": "rem", + "seconds": 1980 + }, + { + "dateTime": "2023-05-13T05:13:30.000", + "level": "light", + "seconds": 3390 + }, + { + "dateTime": "2023-05-13T06:10:00.000", + "level": "rem", + "seconds": 1560 + }, + { + "dateTime": "2023-05-13T06:36:00.000", + "level": "light", + "seconds": 360 + }, + { + "dateTime": "2023-05-13T06:42:00.000", + "level": "rem", + "seconds": 2730 + }, + { + "dateTime": "2023-05-13T07:27:30.000", + "level": "light", + "seconds": 3780 + }, + { + "dateTime": "2023-05-13T08:30:30.000", + "level": "rem", + "seconds": 1050 + }, + { + "dateTime": "2023-05-13T08:48:00.000", + "level": "light", + "seconds": 1020 + }, + { + "dateTime": "2023-05-13T09:05:00.000", + "level": "wake", + "seconds": 1350 + } + ], + "shortData": [ + { + "dateTime": "2023-05-13T01:48:00.000", + "level": "wake", + "seconds": 150 + }, + { + "dateTime": "2023-05-13T02:01:30.000", + "level": "wake", + "seconds": 120 + }, + { + "dateTime": "2023-05-13T02:13:30.000", + "level": "wake", + "seconds": 150 + }, + { + "dateTime": "2023-05-13T02:23:30.000", + "level": "wake", + "seconds": 30 + }, + { + "dateTime": "2023-05-13T02:50:30.000", + "level": "wake", + "seconds": 30 + }, + { + "dateTime": "2023-05-13T03:14:00.000", + "level": "wake", + "seconds": 60 + }, + { + "dateTime": "2023-05-13T03:32:30.000", + "level": "wake", + "seconds": 30 + }, + { + "dateTime": "2023-05-13T04:33:00.000", + "level": "wake", + "seconds": 90 + }, + { + "dateTime": "2023-05-13T04:38:00.000", + "level": "wake", + "seconds": 60 + }, + { + "dateTime": "2023-05-13T04:53:30.000", + "level": "wake", + "seconds": 60 + }, + { + "dateTime": "2023-05-13T05:01:00.000", + "level": "wake", + "seconds": 60 + }, + { + "dateTime": "2023-05-13T05:07:30.000", + "level": "wake", + "seconds": 30 + }, + { + "dateTime": "2023-05-13T05:15:00.000", + "level": "wake", + "seconds": 120 + }, + { + "dateTime": "2023-05-13T05:22:00.000", + "level": "wake", + "seconds": 30 + }, + { + "dateTime": "2023-05-13T06:13:00.000", + "level": "wake", + "seconds": 30 + }, + { + "dateTime": "2023-05-13T06:19:30.000", + "level": "wake", + "seconds": 30 + }, + { + "dateTime": "2023-05-13T06:29:30.000", + "level": "wake", + "seconds": 30 + }, + { + "dateTime": "2023-05-13T06:47:30.000", + "level": "wake", + "seconds": 30 + }, + { + "dateTime": "2023-05-13T07:01:00.000", + "level": "wake", + "seconds": 30 + }, + { + "dateTime": "2023-05-13T07:09:00.000", + "level": "wake", + "seconds": 30 + }, + { + "dateTime": "2023-05-13T07:15:30.000", + "level": "wake", + "seconds": 30 + }, + { + "dateTime": "2023-05-13T07:30:00.000", + "level": "wake", + "seconds": 120 + }, + { + "dateTime": "2023-05-13T07:43:00.000", + "level": "wake", + "seconds": 30 + }, + { + "dateTime": "2023-05-13T08:24:30.000", + "level": "wake", + "seconds": 30 + }, + { + "dateTime": "2023-05-13T08:28:30.000", + "level": "wake", + "seconds": 60 + }, + { + "dateTime": "2023-05-13T08:35:00.000", + "level": "wake", + "seconds": 30 + }, + { + "dateTime": "2023-05-13T08:46:00.000", + "level": "wake", + "seconds": 30 + }, + { + "dateTime": "2023-05-13T08:48:30.000", + "level": "wake", + "seconds": 180 + }, + { + "dateTime": "2023-05-13T08:58:00.000", + "level": "wake", + "seconds": 30 + } + ], + "summary": { + "deep": { + "count": 3, + "minutes": 88, + "thirtyDayAvgMinutes": 87 + }, + "light": { + "count": 25, + "minutes": 258, + "thirtyDayAvgMinutes": 268 + }, + "rem": { + "count": 17, + "minutes": 119, + "thirtyDayAvgMinutes": 96 + }, + "wake": { + "count": 32, + "minutes": 62, + "thirtyDayAvgMinutes": 57 + } + } + }, + "logId": 41314291715, + "logType": "auto_detected", + "minutesAfterWakeup": 0, + "minutesAsleep": 465, + "minutesAwake": 62, + "minutesToFallAsleep": 0, + "startTime": "2023-05-13T00:40:00.000", + "timeInBed": 527, + "type": "stages" + } + ], + "summary": { + "stages": { + "deep": 88, + "light": 258, + "rem": 119, + "wake": 62 + }, + "totalMinutesAsleep": 465, + "totalSleepRecords": 1, + "totalTimeInBed": 527 + } +} diff --git a/tests/services/__init__.py b/tests/services/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/services/fitbit/__init__.py b/tests/services/fitbit/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/services/fitbit/test_parser.py b/tests/services/fitbit/test_parser.py new file mode 100644 index 00000000..bfbfbb47 --- /dev/null +++ b/tests/services/fitbit/test_parser.py @@ -0,0 +1,44 @@ +import os +import datetime +from pathlib import Path + +import pytest +from withingsslack.services.fitbit.parser import parse_sleep +from withingsslack.services.models import SleepData + + +@pytest.mark.parametrize( + "input_filename,expected_sleep_data", + [ + ( + "fitbit_sleep_response_1_item.json", + SleepData( + start_time=datetime.datetime(2023, 5, 13, 0, 40, 0), + end_time=datetime.datetime(2023, 5, 13, 9, 27, 30), + sleep_minutes=465, + wake_minutes=62, + score=95, + slack_alias="somebody", + ), + ), + ( + "fitbit_sleep_response_2_items.json", + SleepData( + start_time=datetime.datetime(2023, 5, 14, 1, 51, 0), + end_time=datetime.datetime(2023, 5, 14, 9, 23, 30), + sleep_minutes=417, + wake_minutes=35, + score=98, + slack_alias="somebody", + ), + ), + ("fitbit_sleep_response_no_main_sleep_item.json", None), + ], +) +def test_parse_sleep(input_filename: str, expected_sleep_data: SleepData): + input_file = ( + Path(os.path.abspath(__file__)).parent.parent.parent / "data" / input_filename + ) + with open(input_file) as input: + actual_sleep_data = parse_sleep(input=input.read(), slack_alias="somebody") + assert actual_sleep_data == expected_sleep_data diff --git a/tests/services/slack/__init__.py b/tests/services/slack/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/services/slack/test_slack_message_format.py b/tests/services/slack/test_slack_message_format.py new file mode 100644 index 00000000..af9a8e88 --- /dev/null +++ b/tests/services/slack/test_slack_message_format.py @@ -0,0 +1,29 @@ +from withingsslack.services import slack +import pytest +import datetime + + +@pytest.mark.parametrize( + "input,expected_output", + [ + (55, "55m"), + (65, "1h 5m"), + (417, "6h 57m"), + (721, "12h 1m"), + ], +) +def test_format_minutes(input: int, expected_output: str): + actual_output = slack.format_minutes(input) + assert actual_output == expected_output + + +@pytest.mark.parametrize( + "input,expected_output", + [ + (datetime.datetime(2023, 5, 14, 1, 51, 33, 234), "1:51"), + (datetime.datetime(2023, 5, 14, 15, 51, 33, 234), "15:51"), + ], +) +def test_format_time(input: datetime.datetime, expected_output: str): + actual_output = slack.format_time(input) + assert actual_output == expected_output diff --git a/withingsslack/services/fitbit/api.py b/withingsslack/services/fitbit/api.py index dd3f9190..c0e1cc55 100644 --- a/withingsslack/services/fitbit/api.py +++ b/withingsslack/services/fitbit/api.py @@ -2,7 +2,7 @@ from typing import Optional from sqlalchemy.orm import Session import datetime -from withingsslack.services.fitbit import requests +from withingsslack.services.fitbit import requests, parser from sqlalchemy.exc import NoResultFound from withingsslack.database import crud from withingsslack.services import models as svc_models @@ -36,14 +36,4 @@ def get_sleep( user=user, url=f"{settings.fitbit_base_url}1.2/user/-/sleep/date/{when_str}.json", ) - summary = response.json()["summary"] - if "stages" not in summary: - return None - return svc_models.SleepData( - total_sleep_minutes=summary["totalMinutesAsleep"], - deep_minutes=summary["stages"]["deep"], - light_minutes=summary["stages"]["light"], - rem_minutes=summary["stages"]["rem"], - wake_minutes=summary["stages"]["wake"], - slack_alias=user.slack_alias, - ) + return parser.parse_sleep(response.content, slack_alias=user.slack_alias) diff --git a/withingsslack/services/fitbit/parser.py b/withingsslack/services/fitbit/parser.py new file mode 100644 index 00000000..8e0f588a --- /dev/null +++ b/withingsslack/services/fitbit/parser.py @@ -0,0 +1,58 @@ +import json +from typing import Optional, Self +from withingsslack.services import models as svc_models +from pydantic import BaseModel +import datetime + + +class FitbitSleepItemSummaryItem(BaseModel): + minutes: int + + +class FitbitSleepItemSummary(BaseModel): + wake: FitbitSleepItemSummaryItem + + +class FitbitSleepItemLevels(BaseModel): + summary: FitbitSleepItemSummary + + +class FitbitSleepItem(BaseModel): + duration: int + efficiency: int + endTime: str + isMainSleep: bool + startTime: str + levels: FitbitSleepItemLevels + + +class FitbitSleep(BaseModel): + sleep: list[FitbitSleepItem] + + @classmethod + def parse(cls, input: str) -> Self: + return cls(**json.loads(input)) + + +DATETIME_FORMAT = "%Y-%m-%dT%H:%M:%S.%f" + + +def parse_sleep(input: str, slack_alias: str) -> Optional[svc_models.SleepData]: + fitbit_sleep = FitbitSleep.parse(input) + main_sleep_item = next( + (item for item in fitbit_sleep.sleep if item.isMainSleep), None + ) + if not main_sleep_item: + return None + + return svc_models.SleepData( + start_time=datetime.datetime.strptime( + main_sleep_item.startTime, DATETIME_FORMAT + ), + end_time=datetime.datetime.strptime(main_sleep_item.endTime, DATETIME_FORMAT), + score=main_sleep_item.efficiency, + sleep_minutes=main_sleep_item.duration / 60000 + - main_sleep_item.levels.summary.wake.minutes, + wake_minutes=main_sleep_item.levels.summary.wake.minutes, + slack_alias=slack_alias, + ) diff --git a/withingsslack/services/models.py b/withingsslack/services/models.py index 3ac48cc3..e55a3930 100644 --- a/withingsslack/services/models.py +++ b/withingsslack/services/models.py @@ -1,4 +1,7 @@ import dataclasses +import datetime + +from pydantic import BaseModel, NonNegativeInt @dataclasses.dataclass @@ -7,11 +10,10 @@ class WeightData: slack_alias: str -@dataclasses.dataclass -class SleepData: - total_sleep_minutes: int - deep_minutes: int - light_minutes: int - rem_minutes: int - wake_minutes: int +class SleepData(BaseModel): + start_time: datetime.datetime + end_time: datetime.datetime + sleep_minutes: NonNegativeInt + wake_minutes: NonNegativeInt + score: NonNegativeInt slack_alias: str diff --git a/withingsslack/services/slack.py b/withingsslack/services/slack.py index 223720df..c13b6967 100644 --- a/withingsslack/services/slack.py +++ b/withingsslack/services/slack.py @@ -1,5 +1,6 @@ import requests +import datetime from withingsslack.services.models import WeightData, SleepData from withingsslack.settings import settings @@ -17,19 +18,23 @@ def post_weight(weight_data: WeightData): ) -def _format_minutes(total_minutes: int) -> str: +def format_minutes(total_minutes: int) -> str: hours, minutes_remainder = divmod(total_minutes, 60) - return f"{hours}h {minutes_remainder}m" + return f"{hours}h {minutes_remainder}m" if hours else f"{minutes_remainder}m" + + +def format_time(input: datetime.datetime) -> str: + return input.strftime("%-H:%M") def post_sleep(sleep_data: SleepData): message = f""" New sleep from <@{sleep_data.slack_alias}>: - • Total sleep: {_format_minutes(sleep_data.total_sleep_minutes)}. - • Rem sleep: {_format_minutes(sleep_data.rem_minutes)}. - • Light sleep: {_format_minutes(sleep_data.light_minutes)}. - • Deep sleep: {_format_minutes(sleep_data.deep_minutes)}. - • Awake: {_format_minutes(sleep_data.wake_minutes)}. + • Went to bed at {format_time(sleep_data.start_time)} + • Woke up at {format_time(sleep_data.end_time)} + • Total sleep: {format_minutes(sleep_data.sleep_minutes)} + • Awake: {format_minutes(sleep_data.wake_minutes)} + • Score: {sleep_data.score} """.strip() requests.post( url=settings.slack_webhook_url,