From 59118eea52626b8a94986fd0caf8d83f13c7065e Mon Sep 17 00:00:00 2001
From: Philipp Stadler <hello@phstadler.com>
Date: Mon, 15 Jul 2024 15:46:33 +0200
Subject: [PATCH] chore: add tests and CI

---
 .gitignore                                    |   1 +
 .gitlab-ci.yml                                |  19 +
 Dockerfile                                    |   1 -
 Makefile                                      |  21 ++
 export-apkgs-test-runner/Dockerfile           |  16 +
 test/fixtures/anki/.apkg-spec.yaml            |   8 +
 test/fixtures/anki/Test.apkg                  | Bin 0 -> 55648 bytes
 test/fixtures/csv/.apkg-spec.yaml             |  18 +
 test/fixtures/csv/content.csv                 |   1 +
 .../csv_updated_content/.apkg-spec.yaml       |  18 +
 test/fixtures/csv_updated_content/content.csv |   1 +
 .../templates/q_a/.template-spec.yaml         |  14 +
 test/fixtures/templates/q_a/q_a/back.html     |   1 +
 test/fixtures/templates/q_a/q_a/front.html    |   1 +
 .../templates_updated/q_a/.template-spec.yaml |  14 +
 .../templates_updated/q_a/q_a/back.html       |   1 +
 .../templates_updated/q_a/q_a/front.html      |   1 +
 test/test_export_apkgs.py                     | 337 ++++++++++++++++++
 18 files changed, 472 insertions(+), 1 deletion(-)
 create mode 100644 .gitlab-ci.yml
 create mode 100644 Makefile
 create mode 100644 export-apkgs-test-runner/Dockerfile
 create mode 100644 test/fixtures/anki/.apkg-spec.yaml
 create mode 100644 test/fixtures/anki/Test.apkg
 create mode 100644 test/fixtures/csv/.apkg-spec.yaml
 create mode 100644 test/fixtures/csv/content.csv
 create mode 100644 test/fixtures/csv_updated_content/.apkg-spec.yaml
 create mode 100644 test/fixtures/csv_updated_content/content.csv
 create mode 100644 test/fixtures/templates/q_a/.template-spec.yaml
 create mode 100644 test/fixtures/templates/q_a/q_a/back.html
 create mode 100644 test/fixtures/templates/q_a/q_a/front.html
 create mode 100644 test/fixtures/templates_updated/q_a/.template-spec.yaml
 create mode 100644 test/fixtures/templates_updated/q_a/q_a/back.html
 create mode 100644 test/fixtures/templates_updated/q_a/q_a/front.html
 create mode 100644 test/test_export_apkgs.py

diff --git a/.gitignore b/.gitignore
index e69de29..bee8a64 100644
--- a/.gitignore
+++ b/.gitignore
@@ -0,0 +1 @@
+__pycache__
diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
new file mode 100644
index 0000000..93634ff
--- /dev/null
+++ b/.gitlab-ci.yml
@@ -0,0 +1,19 @@
+image: durcheinander/export-apkgs-test-runner:1.0.0
+
+default:
+  cache:
+    paths:
+      - .venv
+
+stages:
+- test
+
+test:
+  stage: test
+  rules:
+    - if: $CI_PIPELINE_SOURCE == "schedule"
+      when: never
+    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
+    - if: $CI_COMMIT_TAG
+  script:
+    - make check
diff --git a/Dockerfile b/Dockerfile
index e6b3055..15c903f 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -11,7 +11,6 @@ python3 \
 python3-pip \
 zip \
 curl
-
 RUN npm install --global \
 yarn \
 mudslide
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..eeba479
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,21 @@
+ifneq ($(wildcard /bin/export_apkgs),)
+# no pipenv needed in container, deps are preinstalled globally
+PYTHON := $(shell python-libfaketime  | sed 's/export //') python3
+PYTHON_NEEDED :=
+else
+# lazy evaluation in case faketime dep is not installed yet
+PYTHON = PIPENV_VENV_IN_PROJECT=1 $(shell .venv/bin/python-libfaketime  | sed 's/export //') pipenv run python
+# when running outside the container, install python deps for tests if needed
+PYTHON_NEEDED := .venv/.project
+endif
+
+.PHONY: all
+all: check
+
+.PHONY: check
+check: $(PYTHON_NEEDED)
+	$(PYTHON) -m unittest discover test
+
+.venv/.project: Pipfile Pipfile.lock
+	PIPENV_VENV_IN_PROJECT=1 pipenv install
+	touch $@
diff --git a/export-apkgs-test-runner/Dockerfile b/export-apkgs-test-runner/Dockerfile
new file mode 100644
index 0000000..9b6c82a
--- /dev/null
+++ b/export-apkgs-test-runner/Dockerfile
@@ -0,0 +1,16 @@
+FROM debian:12.4
+
+RUN apt-get update && apt-get install -y \
+faketime \
+git \
+make \
+nodejs \
+npm \
+pipenv \
+python3 \
+python3-pip \
+zip \
+curl
+RUN npm install --global \
+yarn \
+mudslide
diff --git a/test/fixtures/anki/.apkg-spec.yaml b/test/fixtures/anki/.apkg-spec.yaml
new file mode 100644
index 0000000..92ef748
--- /dev/null
+++ b/test/fixtures/anki/.apkg-spec.yaml
@@ -0,0 +1,8 @@
+content_version: 0.0.1
+
+templates:
+- q_a
+
+content:
+- import_apkg:
+    note_type: Q/A Testnotetype
\ No newline at end of file
diff --git a/test/fixtures/anki/Test.apkg b/test/fixtures/anki/Test.apkg
new file mode 100644
index 0000000000000000000000000000000000000000..28a3614f2b228aa8ae1ba806c9914101a758cdcd
GIT binary patch
literal 55648
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

literal 0
HcmV?d00001

diff --git a/test/fixtures/csv/.apkg-spec.yaml b/test/fixtures/csv/.apkg-spec.yaml
new file mode 100644
index 0000000..274a5ed
--- /dev/null
+++ b/test/fixtures/csv/.apkg-spec.yaml
@@ -0,0 +1,18 @@
+content_version: 1.0.0
+
+templates:
+- q_a
+
+content:
+- import_csv:
+    content_version: 2024-01-19 19:00:00+00:00
+    note_type: Q/A Testnotetype
+    file_patterns:
+    - '*.csv'
+    # and to making one deck per card type
+    deck_name_pattern: '{{card_type}}'
+    fields_mapping:
+    - guid
+    - Question
+    - Answer
+    tags: []
\ No newline at end of file
diff --git a/test/fixtures/csv/content.csv b/test/fixtures/csv/content.csv
new file mode 100644
index 0000000..3c98112
--- /dev/null
+++ b/test/fixtures/csv/content.csv
@@ -0,0 +1 @@
+test-csv-note-1;What is the scientific name of the only tapir living in Asia?;Tapirus indicus
\ No newline at end of file
diff --git a/test/fixtures/csv_updated_content/.apkg-spec.yaml b/test/fixtures/csv_updated_content/.apkg-spec.yaml
new file mode 100644
index 0000000..c20f61a
--- /dev/null
+++ b/test/fixtures/csv_updated_content/.apkg-spec.yaml
@@ -0,0 +1,18 @@
+content_version: 1.0.0
+
+templates:
+- q_a
+
+content:
+- import_csv:
+    content_version: 2024-01-19 20:00:00+00:00
+    note_type: Q/A Testnotetype
+    file_patterns:
+    - '*.csv'
+    # and to making one deck per card type
+    deck_name_pattern: '{{card_type}}'
+    fields_mapping:
+    - guid
+    - Question
+    - Answer
+    tags: []
\ No newline at end of file
diff --git a/test/fixtures/csv_updated_content/content.csv b/test/fixtures/csv_updated_content/content.csv
new file mode 100644
index 0000000..d1f9267
--- /dev/null
+++ b/test/fixtures/csv_updated_content/content.csv
@@ -0,0 +1 @@
+test-csv-note-1;What is the scientific name of the only tapir living in Asia?;Tapirus indicus, Acrocodia indica being an outdated synonym.
\ No newline at end of file
diff --git a/test/fixtures/templates/q_a/.template-spec.yaml b/test/fixtures/templates/q_a/.template-spec.yaml
new file mode 100644
index 0000000..81ef829
--- /dev/null
+++ b/test/fixtures/templates/q_a/.template-spec.yaml
@@ -0,0 +1,14 @@
+template_version: 2024-01-20 18:00:00+00:00
+
+note_type:
+  id: 2024-01-20 01:00:00+00:00
+  name: Q/A Testnotetype
+  fields:
+  - Question
+  - Answer
+
+card_types:
+- name: Q/A Testcardtype
+  template: q_a
+
+resource_paths: []
\ No newline at end of file
diff --git a/test/fixtures/templates/q_a/q_a/back.html b/test/fixtures/templates/q_a/q_a/back.html
new file mode 100644
index 0000000..aaee1c4
--- /dev/null
+++ b/test/fixtures/templates/q_a/q_a/back.html
@@ -0,0 +1 @@
+{{Answer}}
\ No newline at end of file
diff --git a/test/fixtures/templates/q_a/q_a/front.html b/test/fixtures/templates/q_a/q_a/front.html
new file mode 100644
index 0000000..14cb9f2
--- /dev/null
+++ b/test/fixtures/templates/q_a/q_a/front.html
@@ -0,0 +1 @@
+{{Question}}
\ No newline at end of file
diff --git a/test/fixtures/templates_updated/q_a/.template-spec.yaml b/test/fixtures/templates_updated/q_a/.template-spec.yaml
new file mode 100644
index 0000000..02ef56a
--- /dev/null
+++ b/test/fixtures/templates_updated/q_a/.template-spec.yaml
@@ -0,0 +1,14 @@
+template_version: 2024-01-20 19:00:00+00:00
+
+note_type:
+  id: 2024-01-20 01:00:00+00:00
+  name: Q/A Testnotetype
+  fields:
+  - Question
+  - Answer
+
+card_types:
+- name: Q/A Testcardtype
+  template: q_a
+
+resource_paths: []
diff --git a/test/fixtures/templates_updated/q_a/q_a/back.html b/test/fixtures/templates_updated/q_a/q_a/back.html
new file mode 100644
index 0000000..5cc0404
--- /dev/null
+++ b/test/fixtures/templates_updated/q_a/q_a/back.html
@@ -0,0 +1 @@
+Back: {{Answer}}
\ No newline at end of file
diff --git a/test/fixtures/templates_updated/q_a/q_a/front.html b/test/fixtures/templates_updated/q_a/q_a/front.html
new file mode 100644
index 0000000..51c00f1
--- /dev/null
+++ b/test/fixtures/templates_updated/q_a/q_a/front.html
@@ -0,0 +1 @@
+Front: {{Question}}
\ No newline at end of file
diff --git a/test/test_export_apkgs.py b/test/test_export_apkgs.py
new file mode 100644
index 0000000..03259b0
--- /dev/null
+++ b/test/test_export_apkgs.py
@@ -0,0 +1,337 @@
+from anki.collection import Collection, ImportAnkiPackageRequest, ImportAnkiPackageOptions
+from anki.import_export_pb2 import ImportAnkiPackageUpdateCondition
+from export_apkgs import export_package_from_spec
+from dataclasses import dataclass
+from pathlib import Path
+from tempfile import TemporaryDirectory
+import unittest
+
+@dataclass
+class MockArgs:
+    content: str
+    templates_dir: str
+    output_dir: str
+    dry_run: bool
+
+CONTENT_PATH_ANKI = 'test/fixtures/anki'
+CONTENT_PATH_CSV = 'test/fixtures/csv'
+CONTENT_PATH_CSV_UPDATED_CONTENT = 'test/fixtures/csv_updated_content'
+TEMPLATES_PATH = 'test/fixtures/templates'
+TEMPLATES_PATH_UPDATED = 'test/fixtures/templates_updated'
+
+class TestExportApkgs(unittest.TestCase):
+    def test_generate_once_and_reimport(self):
+        """
+        Basic sanity check: if the exported package is imported twice,
+        everything is a duplicate and nothing should change.
+        """
+        with TemporaryDirectory() as temp_collection_dir:
+            col = Collection(str(Path(temp_collection_dir) / "test.anki2"))
+            with TemporaryDirectory() as first_export_dir:
+                args_first = MockArgs(
+                    content=CONTENT_PATH_CSV,
+                    templates_dir=TEMPLATES_PATH,
+                    output_dir=first_export_dir,
+                    dry_run=False)
+                package = export_package_from_spec(
+                    Path(CONTENT_PATH_CSV) / '.apkg-spec.yaml',
+                    args_first)
+
+                # import the first time, everything is new
+                result1 = col.import_anki_package(ImportAnkiPackageRequest(
+                    package_path=str(package),
+                    options=ImportAnkiPackageOptions(
+                        merge_notetypes=True,
+                        update_notes=ImportAnkiPackageUpdateCondition.IMPORT_ANKI_PACKAGE_UPDATE_CONDITION_IF_NEWER,
+                        update_notetypes=ImportAnkiPackageUpdateCondition.IMPORT_ANKI_PACKAGE_UPDATE_CONDITION_IF_NEWER,
+                        with_scheduling=False,
+                        with_deck_configs=False,
+                    )
+                ))
+                self.assertEqual(len(result1.log.new), 1)
+                self.assertEqual(len(result1.log.duplicate), 0)
+
+                # now import again, nothing should change
+                result2 = col.import_anki_package(ImportAnkiPackageRequest(
+                    package_path=str(package),
+                    options=ImportAnkiPackageOptions(
+                        merge_notetypes=True,
+                        update_notes=ImportAnkiPackageUpdateCondition.IMPORT_ANKI_PACKAGE_UPDATE_CONDITION_IF_NEWER,
+                        update_notetypes=ImportAnkiPackageUpdateCondition.IMPORT_ANKI_PACKAGE_UPDATE_CONDITION_IF_NEWER,
+                        with_scheduling=False,
+                        with_deck_configs=False,
+                    )
+                ))
+                self.assertEqual(len(result2.log.duplicate) == 1 and len(result2.log.new), 0,
+                    f'Expected the note to be recognized as duplicate.\nLog1:\n{result1.log}\nLog2:\n{result2.log}')
+
+    def test_generate_twice_and_reimport(self):
+        """
+        This checks that if we generate the package twice and import it twice,
+        then the second import will not change anything because the content is
+        supposed to be the same.
+
+        If we introduce errors that lead to different note and card IDs being
+        generated, then this test will fail.
+        """
+        with TemporaryDirectory() as temp_collection_dir:
+            col = Collection(str(Path(temp_collection_dir) / "test.anki2"))
+            with TemporaryDirectory() as first_export_dir:
+                args_first = MockArgs(
+                    content=CONTENT_PATH_CSV,
+                    templates_dir=TEMPLATES_PATH,
+                    output_dir=first_export_dir,
+                    dry_run=False)
+                with TemporaryDirectory() as second_export_dir:
+                    args_second = MockArgs(
+                        content=CONTENT_PATH_CSV,
+                        templates_dir=TEMPLATES_PATH,
+                        output_dir=second_export_dir,
+                        dry_run=False)
+                    spec_path = Path(CONTENT_PATH_CSV) / '.apkg-spec.yaml'
+                    first_package = export_package_from_spec(spec_path, args_first)
+                    second_package = export_package_from_spec(spec_path, args_second)
+                    
+                    # debug: uncomment to view for testing
+                    # shutil.copy(first_package, './test-export-first.apkg.zip')
+                    # shutil.copy(second_package, './test-export-second.apkg.zip')
+
+                    # import the first time, everything is new
+                    result1 = col.import_anki_package(ImportAnkiPackageRequest(
+                        package_path=str(first_package),
+                        options=ImportAnkiPackageOptions(
+                            merge_notetypes=True,
+                            update_notes=ImportAnkiPackageUpdateCondition.IMPORT_ANKI_PACKAGE_UPDATE_CONDITION_IF_NEWER,
+                            update_notetypes=ImportAnkiPackageUpdateCondition.IMPORT_ANKI_PACKAGE_UPDATE_CONDITION_IF_NEWER,
+                            with_scheduling=False,
+                            with_deck_configs=False,
+                        )
+                    ))
+                    self.assertEqual(len(result1.log.new), 1)
+                    self.assertEqual(len(result1.log.duplicate), 0)
+
+                    # now import again, nothing should change
+                    result2 = col.import_anki_package(ImportAnkiPackageRequest(
+                        package_path=str(second_package),
+                        options=ImportAnkiPackageOptions(
+                            merge_notetypes=True,
+                            update_notes=ImportAnkiPackageUpdateCondition.IMPORT_ANKI_PACKAGE_UPDATE_CONDITION_IF_NEWER,
+                            update_notetypes=ImportAnkiPackageUpdateCondition.IMPORT_ANKI_PACKAGE_UPDATE_CONDITION_IF_NEWER,
+                            with_scheduling=False,
+                            with_deck_configs=False,
+                        )
+                    ))
+                    self.assertTrue(
+                        len(result2.log.duplicate) == 1 and len(result2.log.new) == 0,
+                        f'Expected second import to be a duplicate, but something else happened\nfirst log: {result1.log}\nsecond log:\n{result2.log}')
+                
+    def test_content_update_overwrites_previous_note(self):
+        """
+        Tests that bumping the modification time will update notes from previous versions.
+        """
+        with TemporaryDirectory() as temp_collection_dir:
+            col = Collection(str(Path(temp_collection_dir) / "test.anki2"))
+            with TemporaryDirectory() as first_export_dir:
+                args_first = MockArgs(
+                    content=CONTENT_PATH_CSV,
+                    templates_dir=TEMPLATES_PATH,
+                    output_dir=first_export_dir,
+                    dry_run=False)
+                with TemporaryDirectory() as second_export_dir:
+                    args_second = MockArgs(
+                        content=CONTENT_PATH_CSV_UPDATED_CONTENT,
+                        templates_dir=TEMPLATES_PATH,
+                        output_dir=second_export_dir,
+                        dry_run=False)
+                    spec_path_old = Path(f'{CONTENT_PATH_CSV}/.apkg-spec.yaml')
+                    spec_path_new = Path(f'{CONTENT_PATH_CSV_UPDATED_CONTENT}/.apkg-spec.yaml')
+                    first_package = export_package_from_spec(spec_path_old, args_first)
+                    second_package = export_package_from_spec(spec_path_new, args_second)
+
+                    # import the old version version
+                    result1 = col.import_anki_package(ImportAnkiPackageRequest(
+                        package_path=str(first_package),
+                        options=ImportAnkiPackageOptions(
+                            merge_notetypes=True,
+                            update_notes=ImportAnkiPackageUpdateCondition.IMPORT_ANKI_PACKAGE_UPDATE_CONDITION_IF_NEWER,
+                            update_notetypes=ImportAnkiPackageUpdateCondition.IMPORT_ANKI_PACKAGE_UPDATE_CONDITION_IF_NEWER,
+                            with_scheduling=False,
+                            with_deck_configs=False,
+                        )
+                    ))
+                    self.assertEqual(len(result1.log.updated), 0)
+                    self.assertEqual(len(result1.log.new), 1)
+                    self.assertEqual(len(result1.log.duplicate), 0)
+
+                    # now the update
+                    result2 = col.import_anki_package(ImportAnkiPackageRequest(
+                        package_path=str(second_package),
+                        options=ImportAnkiPackageOptions(
+                            merge_notetypes=True,
+                            update_notes=ImportAnkiPackageUpdateCondition.IMPORT_ANKI_PACKAGE_UPDATE_CONDITION_IF_NEWER,
+                            update_notetypes=ImportAnkiPackageUpdateCondition.IMPORT_ANKI_PACKAGE_UPDATE_CONDITION_IF_NEWER,
+                            with_scheduling=False,
+                            with_deck_configs=False,
+                        )
+                    ))
+                    self.assertTrue(len(result2.log.updated) == 1 and len(result2.log.new) == 0 and len(result2.log.duplicate) == 0,
+                                    f'Expected the note to be recognized as update.\nLog1:\n{result1.log}\nLog2:\n{result2.log}')
+                    
+
+    def test_template_update_overwrites_previous_template_csv(self):
+        """
+        Tests that bumping the modification time will update notes from previous versions
+        of CSV content.
+        """
+        with TemporaryDirectory() as temp_collection_dir:
+            col = Collection(str(Path(temp_collection_dir) / "test.anki2"))
+            with TemporaryDirectory() as first_export_dir:
+                args_first = MockArgs(
+                    content=CONTENT_PATH_CSV,
+                    templates_dir=TEMPLATES_PATH,
+                    output_dir=first_export_dir,
+                    dry_run=False)
+                with TemporaryDirectory() as second_export_dir:
+                    args_second = MockArgs(
+                        content=CONTENT_PATH_CSV,
+                        templates_dir=TEMPLATES_PATH_UPDATED,
+                        output_dir=second_export_dir,
+                        dry_run=False)
+                    spec_path = Path(f'{CONTENT_PATH_CSV}/.apkg-spec.yaml')
+                    first_package = export_package_from_spec(spec_path, args_first)
+                    second_package = export_package_from_spec(spec_path, args_second)
+
+                    # debug: uncomment to view for testing
+                    #shutil.copy(first_package, './test-export-first.apkg.zip')
+                    #shutil.copy(second_package, './test-export-second.apkg.zip')
+
+                    # import the old version version
+                    col.import_anki_package(ImportAnkiPackageRequest(
+                        package_path=str(first_package),
+                        options=ImportAnkiPackageOptions(
+                            merge_notetypes=True,
+                            update_notes=ImportAnkiPackageUpdateCondition.IMPORT_ANKI_PACKAGE_UPDATE_CONDITION_IF_NEWER,
+                            update_notetypes=ImportAnkiPackageUpdateCondition.IMPORT_ANKI_PACKAGE_UPDATE_CONDITION_IF_NEWER,
+                            with_scheduling=False,
+                            with_deck_configs=False,
+                        )
+                    ))
+
+                    template = find_template(col, 'Q/A Testnotetype')
+                    self.assertIsNotNone(template, 'template not found in collection')
+                    self.assertEqual(
+                        template['qfmt'],
+                        '{{Question}}')
+                    self.assertEqual(
+                        template['afmt'],
+                        '{{Answer}}')
+
+                    # now the update
+                    result = col.import_anki_package(ImportAnkiPackageRequest(
+                        package_path=str(second_package),
+                        options=ImportAnkiPackageOptions(
+                            merge_notetypes=True,
+                            update_notes=ImportAnkiPackageUpdateCondition.IMPORT_ANKI_PACKAGE_UPDATE_CONDITION_IF_NEWER,
+                            update_notetypes=ImportAnkiPackageUpdateCondition.IMPORT_ANKI_PACKAGE_UPDATE_CONDITION_IF_NEWER,
+                            with_scheduling=False,
+                            with_deck_configs=False,
+                        )
+                    ))
+
+                    # for some reason the change is only visible if we reload the collection
+                    col.close()
+                    col = Collection(str(Path(temp_collection_dir) / "test.anki2"))
+
+                    template = find_template(col, 'Q/A Testnotetype')
+                    self.assertIsNotNone(template, 'template not found in collection')
+                    self.assertEqual(
+                        template['qfmt'],
+                        'Front: {{Question}}',
+                        f'Template not updated successfully, log:\n{result.log}')
+                    self.assertEqual(
+                        template['afmt'],
+                        'Back: {{Answer}}')
+
+    def test_template_update_overwrites_previous_template_anki(self):
+        """
+        Tests that bumping the modification time will update notes from previous versions
+        of an imported anki package.
+        """
+        with TemporaryDirectory() as temp_collection_dir:
+            col = Collection(str(Path(temp_collection_dir) / "test.anki2"))
+            with TemporaryDirectory() as first_export_dir:
+                args_first = MockArgs(
+                    content=CONTENT_PATH_ANKI,
+                    templates_dir=TEMPLATES_PATH,
+                    output_dir=first_export_dir,
+                    dry_run=False)
+                with TemporaryDirectory() as second_export_dir:
+                    args_second = MockArgs(
+                        content=CONTENT_PATH_ANKI,
+                        templates_dir=TEMPLATES_PATH_UPDATED,
+                        output_dir=second_export_dir,
+                        dry_run=False)
+                    spec_path = Path(f'{CONTENT_PATH_ANKI}/.apkg-spec.yaml')
+                    first_package = export_package_from_spec(spec_path, args_first)
+                    second_package = export_package_from_spec(spec_path, args_second)
+
+                    # debug: uncomment to view for testing
+                    #shutil.copy(first_package, './test-export-first.apkg.zip')
+                    #shutil.copy(second_package, './test-export-second.apkg.zip')
+
+                    # import the old version version
+                    col.import_anki_package(ImportAnkiPackageRequest(
+                        package_path=str(first_package),
+                        options=ImportAnkiPackageOptions(
+                            merge_notetypes=True,
+                            update_notes=ImportAnkiPackageUpdateCondition.IMPORT_ANKI_PACKAGE_UPDATE_CONDITION_IF_NEWER,
+                            update_notetypes=ImportAnkiPackageUpdateCondition.IMPORT_ANKI_PACKAGE_UPDATE_CONDITION_IF_NEWER,
+                            with_scheduling=False,
+                            with_deck_configs=False,
+                        )
+                    ))
+
+                    template = find_template(col, 'Q/A Testnotetype')
+                    self.assertIsNotNone(template, 'template not found in collection')
+                    self.assertEqual(
+                        template['qfmt'],
+                        '{{Question}}')
+                    self.assertEqual(
+                        template['afmt'],
+                        '{{Answer}}')
+
+                    # now the update
+                    result = col.import_anki_package(ImportAnkiPackageRequest(
+                        package_path=str(second_package),
+                        options=ImportAnkiPackageOptions(
+                            merge_notetypes=True,
+                            update_notes=ImportAnkiPackageUpdateCondition.IMPORT_ANKI_PACKAGE_UPDATE_CONDITION_IF_NEWER,
+                            update_notetypes=ImportAnkiPackageUpdateCondition.IMPORT_ANKI_PACKAGE_UPDATE_CONDITION_IF_NEWER,
+                            with_scheduling=False,
+                            with_deck_configs=False,
+                        )
+                    ))
+
+                    # for some reason the change is only visible if we reload the collection
+                    col.close()
+                    col = Collection(str(Path(temp_collection_dir) / "test.anki2"))
+
+                    template = find_template(col, 'Q/A Testnotetype')
+                    self.assertIsNotNone(template, 'template not found in collection')
+                    self.assertEqual(
+                        template['qfmt'],
+                        'Front: {{Question}}',
+                        f'Template not updated successfully, log:\n{result.log}')
+                    self.assertEqual(
+                        template['afmt'],
+                        'Back: {{Answer}}')
+
+def find_template(col: Collection, name: str):
+    template = None
+    for name_and_id in col.models.all_names_and_ids():
+        if name_and_id.name == name:
+            if template is None:
+                template = col.models.get(name_and_id.id)['tmpls'][0]
+            else:
+                raise Exception(f'Found more than one template with name {name}')
+    return template
\ No newline at end of file
-- 
GitLab