From 3c22eca5dca11c14bbe8573529dba8440a13f51f Mon Sep 17 00:00:00 2001
From: Jakub Fidler <31575114+RisingOrange@users.noreply.github.com>
Date: Mon, 25 Mar 2024 18:01:25 +0100
Subject: [PATCH] [BUILD-254] fix: Handle deck updates response with empty
 notes correctly (#927)

* fix: Handle deck updates response with empty notes correctly

* Add test

* Fix exception handling handling for client tests
---
 ankihub/ankihub_client/ankihub_client.py      |  3 ++
 ...ckUpdates.test_get_empty_deck_updates.yaml | 54 +++++++++++++++++++
 tests/client/test_client.py                   | 51 +++++++++++++-----
 3 files changed, 96 insertions(+), 12 deletions(-)
 create mode 100644 tests/client/cassettes/TestGetDeckUpdates.test_get_empty_deck_updates.yaml

diff --git a/ankihub/ankihub_client/ankihub_client.py b/ankihub/ankihub_client/ankihub_client.py
index b8ea979b2..92035e146 100644
--- a/ankihub/ankihub_client/ankihub_client.py
+++ b/ankihub/ankihub_client/ankihub_client.py
@@ -717,6 +717,9 @@ def get_deck_updates(
             if should_cancel and should_cancel():
                 return None
 
+            if not chunk.notes:
+                continue
+
             if chunk.from_csv:
                 # The CSV contains all notes, so we assign instead of extending
                 notes_data_from_csv = chunk.notes
diff --git a/tests/client/cassettes/TestGetDeckUpdates.test_get_empty_deck_updates.yaml b/tests/client/cassettes/TestGetDeckUpdates.test_get_empty_deck_updates.yaml
new file mode 100644
index 000000000..83497b38b
--- /dev/null
+++ b/tests/client/cassettes/TestGetDeckUpdates.test_get_empty_deck_updates.yaml
@@ -0,0 +1,54 @@
+interactions:
+- request:
+    body: '{"username": "test1", "password": "asdf"}'
+    headers:
+      Accept:
+      - application/json; version=18.0
+      Content-Length:
+      - '41'
+      Content-Type:
+      - application/json
+    method: POST
+    uri: http://localhost:8000/api/login/
+  response:
+    body:
+      string: '{"expiry":"2024-04-20T11:14:04.085095Z","token":"dcb750c2d67a26450bea083c7ca0e7cbb70f0aece0eda87cfd0993583e51d3bd"}'
+    headers:
+      Allow:
+      - POST, OPTIONS
+      Content-Language:
+      - en
+      Content-Length:
+      - '115'
+      Content-Type:
+      - application/json
+      Cross-Origin-Opener-Policy:
+      - same-origin
+      Date:
+      - Sat, 23 Mar 2024 11:14:04 GMT
+      Referrer-Policy:
+      - same-origin
+      Server:
+      - WSGIServer/0.2 CPython/3.8.17
+      Server-Timing:
+      - TimerPanel_utime;dur=299.5340000000226;desc="User CPU time", TimerPanel_stime;dur=2.427000000000845;desc="System
+        CPU time", TimerPanel_total;dur=301.96100000002343;desc="Total CPU time",
+        TimerPanel_total_time;dur=219.2665290003788;desc="Elapsed time", SQLPanel_sql_time;dur=8.263236000857432;desc="SQL
+        16 queries", CachePanel_total_time;dur=0;desc="Cache 0 Calls"
+      Set-Cookie:
+      - csrftoken=GEJ14GLNrCpTovniHl5vOIipFded4604; expires=Sat, 22 Mar 2025 11:14:04
+        GMT; HttpOnly; Max-Age=31449600; Path=/; SameSite=Lax
+      - sessionid=zxmvjngepkvsttkrziapyu09k2nl0ffi; expires=Sat, 30 Mar 2024 11:14:04
+        GMT; HttpOnly; Max-Age=604800; Path=/; SameSite=Lax
+      Vary:
+      - Accept, Cookie, Accept-Language, origin
+      X-Content-Type-Options:
+      - nosniff
+      X-Frame-Options:
+      - DENY
+      djdt-store-id:
+      - ff681edc37564104bbfe37c99a4d6410
+    status:
+      code: 200
+      message: OK
+version: 1
diff --git a/tests/client/test_client.py b/tests/client/test_client.py
index ca9859eeb..904c975bf 100644
--- a/tests/client/test_client.py
+++ b/tests/client/test_client.py
@@ -10,7 +10,7 @@
 from copy import deepcopy
 from datetime import datetime, timedelta, timezone
 from pathlib import Path
-from typing import Callable, Generator, List, cast
+from typing import Callable, Generator, List, Optional, cast
 from unittest.mock import Mock
 
 import pytest
@@ -253,7 +253,7 @@ def remove_db_dump() -> Generator:
     elif result.returncode == 1 and "No such container" in result.stderr:
         # Nothing to do
         pass
-    elif "Container" in result.stderr and "is not running" in result.stderr:
+    elif "container" in result.stderr.lower() and "is not running" in result.stderr:
         # Container is not running, nothing to do
         pass
     elif "docker: command not found" in result.stderr:
@@ -1008,21 +1008,44 @@ def test_get_deck_updates_with_external_notes_url(
         assert deck_updates.notes == [note1_from_json, note2_from_csv]
         assert deck_updates.latest_update == latest_update
 
+    @pytest.mark.vcr()
+    def test_get_empty_deck_updates(
+        self, authorized_client_for_user_test1: AnkiHubClient, mocker: MockerFixture
+    ):
+        client = authorized_client_for_user_test1
+
+        # Mock responses from deck updates endpoint
+        response = self._deck_updates_response_mock_with_json_notes(
+            notes=[],
+            latest_update=None,
+        )
+
+        mocker.patch(
+            "ankihub.ankihub_client.ankihub_client.AnkiHubClient._send_request",
+            return_value=response,
+        )
+
+        # Assert that the deck updates are as expected.
+        deck_updates = client.get_deck_updates(ID_OF_DECK_OF_USER_TEST1, since=None)
+        assert deck_updates.notes == []
+        assert deck_updates.latest_update is None
+
     def _deck_updates_response_mock_with_json_notes(
-        self, notes: List[NoteInfo], latest_update: datetime
+        self, notes: List[NoteInfo], latest_update: Optional[datetime]
     ) -> Mock:
         result = Mock()
         note_dicts = [note.to_dict() for note in notes]
         notes_encoded = gzip.compress(json.dumps(note_dicts).encode("utf-8"))
         notes_encoded = base64.b85encode(notes_encoded)
-        latest_update_str = datetime.strftime(
-            latest_update, ANKIHUB_DATETIME_FORMAT_STR
-        )
         result.json = lambda: {
             "external_notes_url": None,
             "next": None,
             "notes": notes_encoded,
-            "latest_update": latest_update_str,
+            "latest_update": datetime.strftime(
+                latest_update, ANKIHUB_DATETIME_FORMAT_STR
+            )
+            if latest_update
+            else None,
             "protected_fields": {},
             "protected_tags": [],
         }
@@ -1030,17 +1053,21 @@ def _deck_updates_response_mock_with_json_notes(
         return result
 
     def _deck_updates_response_mock_with_csv_notes(
-        self, notes: List[NoteInfo], latest_update: datetime, mocker: MockerFixture
+        self,
+        notes: List[NoteInfo],
+        latest_update: Optional[datetime],
+        mocker: MockerFixture,
     ) -> Mock:
         result = Mock()
-        latest_update_str = datetime.strftime(
-            latest_update, ANKIHUB_DATETIME_FORMAT_STR
-        )
         result.json = lambda: {
             "external_notes_url": "test_url",
             "next": None,
             "notes": None,
-            "latest_update": latest_update_str,
+            "latest_update": datetime.strftime(
+                latest_update, ANKIHUB_DATETIME_FORMAT_STR
+            )
+            if latest_update
+            else None,
             "protected_fields": {},
             "protected_tags": [],
         }