krz/crumb

A local alternative to your browser's history.

clone: git clone https://gitbay.org/krz/crumb.git

254cfed17728ab9b6011a298c6dd58a3730910ef

verified · cmc

author: Christian Cleberg <hello@cleberg.net> · 2026-08-23T02:16:10Z

Fix the tests, and run them

The suite arrived in one commit and had never passed. All four tests failed:

- test_server posted with data= and content_type json, which Flask sends
  form-encoded, so request.json was empty and the handler returned 400 where the
  test wanted 204. It also referenced an undefined client in the OPTIONS test.
- test_search patched LOG_PATH with a value, then declared a mock parameter that
  @patch never passes, so both tests raised TypeError before running. Underneath
  that they asserted the wrong thing entirely: search_log prints matches and does
  not touch the file, but the tests read the file back — so the no-match case
  could not pass under any implementation.

Both are rewritten against what the code does. search_log's output is captured
and asserted; the server tests patch LOG_PATH at a temp file, which the old ones
did not — running them appended to the developer's own ~/.crumb/history.org.

Nine tests now, covering CORS headers, optional-field omission, case-insensitive
search and the missing-log path. requirements-dev.txt pins flask and pytest. CI
also checks the extension manifest parses, since nothing else looks at it.
 .github/workflows/test.yml                         |  32 +++++++++
 __pycache__/search.cpython-314.pyc                 | Bin 0 -> 1701 bytes
 __pycache__/server.cpython-314.pyc                 | Bin 0 -> 3686 bytes
 requirements-dev.in                                |   2 +
 requirements-dev.txt                               |  29 ++++++++
 .../test_search.cpython-314-pytest-9.1.1.pyc       | Bin 0 -> 4560 bytes
 .../test_server.cpython-314-pytest-9.1.1.pyc       | Bin 0 -> 4751 bytes
 tests/test_search.py                               |  78 +++++++++++++--------
 tests/test_server.py                               |  75 ++++++++++++++------
 9 files changed, 166 insertions(+), 50 deletions(-)

diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
new file mode 100644
index 0000000..1850bcd
--- /dev/null
+++ b/.github/workflows/test.yml
@@ -0,0 +1,32 @@
+name: Test
+
+# crumb had a test suite from one commit that had never passed: four tests, all
+# failing. They are fixed and this runs them, so the next break is visible.
+on:
+  pull_request:
+  push:
+    branches: [main]
+
+permissions:
+  contents: read
+
+jobs:
+  test:
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v7
+
+      - uses: actions/setup-python@v7
+        with:
+          python-version: '3.11'
+
+      - name: Install
+        run: pip install -r requirements-dev.txt
+
+      - name: Tests
+        run: python -m pytest -q
+
+      # The webextension half of crumb is not covered by the Python tests, and a
+      # malformed manifest is silent until a browser refuses to load it.
+      - name: Extension manifest is valid JSON
+        run: python -c "import json; json.load(open('crumb_extension/manifest.json')); print('manifest.json parses')"
diff --git a/__pycache__/search.cpython-314.pyc b/__pycache__/search.cpython-314.pyc
new file mode 100644
index 0000000..e384b2b
Binary files /dev/null and b/__pycache__/search.cpython-314.pyc differ
diff --git a/__pycache__/server.cpython-314.pyc b/__pycache__/server.cpython-314.pyc
new file mode 100644
index 0000000..b728486
Binary files /dev/null and b/__pycache__/server.cpython-314.pyc differ
diff --git a/requirements-dev.in b/requirements-dev.in
new file mode 100644
index 0000000..4649a3c
--- /dev/null
+++ b/requirements-dev.in
@@ -0,0 +1,2 @@
+flask
+pytest
diff --git a/requirements-dev.txt b/requirements-dev.txt
new file mode 100644
index 0000000..454d868
--- /dev/null
+++ b/requirements-dev.txt
@@ -0,0 +1,29 @@
+# This file was autogenerated by uv via the following command:
+#    uv pip compile requirements-dev.in -o requirements-dev.txt --python-version 3.11
+blinker==1.9.0
+    # via flask
+click==8.4.2
+    # via flask
+flask==3.1.3
+    # via -r requirements-dev.in
+iniconfig==2.3.0
+    # via pytest
+itsdangerous==2.2.0
+    # via flask
+jinja2==3.1.6
+    # via flask
+markupsafe==3.0.3
+    # via
+    #   flask
+    #   jinja2
+    #   werkzeug
+packaging==26.3
+    # via pytest
+pluggy==1.6.0
+    # via pytest
+pygments==2.21.0
+    # via pytest
+pytest==9.1.1
+    # via -r requirements-dev.in
+werkzeug==3.1.8
+    # via flask
diff --git a/tests/__pycache__/test_search.cpython-314-pytest-9.1.1.pyc b/tests/__pycache__/test_search.cpython-314-pytest-9.1.1.pyc
new file mode 100644
index 0000000..a65a65c
Binary files /dev/null and b/tests/__pycache__/test_search.cpython-314-pytest-9.1.1.pyc differ
diff --git a/tests/__pycache__/test_server.cpython-314-pytest-9.1.1.pyc b/tests/__pycache__/test_server.cpython-314-pytest-9.1.1.pyc
new file mode 100644
index 0000000..b346c63
Binary files /dev/null and b/tests/__pycache__/test_server.cpython-314-pytest-9.1.1.pyc differ
diff --git a/tests/test_search.py b/tests/test_search.py
index f2c003c..5a3d374 100644
--- a/tests/test_search.py
+++ b/tests/test_search.py
@@ -1,34 +1,56 @@
+import io
+import os
+import tempfile
 import unittest
+from contextlib import redirect_stdout
 from unittest.mock import patch
+
+import search
 from search import search_log
 
+
 class TestSearchLog(unittest.TestCase):
+    """search_log prints matches; it does not modify the log. The previous tests
+    read the file back and asserted on its contents, which could only ever pass
+    for the match case and never for the no-match one."""
+
+    ENTRY = (
+        "* Example Entry\n"
+        ":PROPERTIES:\n"
+        ":URL:       http://example.com\n"
+        ":TIMESTAMP: 2023-10-27 10:00:00\n"
+        ":END:\n\n"
+    )
+
+    def setUp(self):
+        handle, self.log_path = tempfile.mkstemp(suffix=".org")
+        os.close(handle)
+        with open(self.log_path, "w") as f:
+            f.write(self.ENTRY)
+
+    def tearDown(self):
+        os.unlink(self.log_path)
+
+    def run_search(self, query):
+        out = io.StringIO()
+        with patch.object(search, "LOG_PATH", self.log_path), redirect_stdout(out):
+            search_log(query)
+        return out.getvalue()
+
+    def test_a_match_is_printed(self):
+        self.assertIn("Example Entry", self.run_search("example"))
+
+    def test_the_search_is_case_insensitive(self):
+        self.assertIn("Example Entry", self.run_search("EXAMPLE"))
+
+    def test_a_property_value_matches(self):
+        self.assertIn("Example Entry", self.run_search("example.com"))
+
+    def test_no_match_prints_no_entry(self):
+        self.assertNotIn("Example Entry", self.run_search("nonexistent"))
 
-    @patch('search.LOG_PATH', 'test_log_path')
-    def test_search_log_with_match(self, mock_log_path):
-        query = 'crumb'
-        # Create a dummy log file (for testing)
-        with open('test_log_path', 'w') as f:
-            f.write("* Example Entry\n")
-            f.write(":PROPERTIES:\n")
-            f.write(":URL:       http://example.com\n")
-            f.write(":TIMESTAMP: 2023-10-27 10:00:00\n")
-
-        search_log(query)
-        # Assert that the function prints the expected output
-        with open('test_log_path', 'r') as f:
-            output = f.read()
-        self.assertIn("* Example Entry\n", output)
-
-    @patch('search.LOG_PATH', 'test_log_path')
-    def test_search_log_no_match(self, mock_log_path):
-        query = 'nonexistent'
-        with open('test_log_path', 'w') as f:
-            f.write("* Example Entry\n")
-            f.write(":PROPERTIES:\n")
-            f.write(":URL:       http://example.com\n")
-            f.write(":TIMESTAMP: 2023-10-27 10:00:00\n")
-        search_log(query)
-        with open('test_log_path', 'r') as f:
-            output = f.read()
-        self.assertNotIn("* Example Entry\n", output)
+    def test_a_missing_log_is_reported_not_raised(self):
+        out = io.StringIO()
+        with patch.object(search, "LOG_PATH", self.log_path + ".absent"), redirect_stdout(out):
+            search_log("anything")
+        self.assertIn("No history file found.", out.getvalue())
diff --git a/tests/test_server.py b/tests/test_server.py
index 43584d8..b9b3638 100644
--- a/tests/test_server.py
+++ b/tests/test_server.py
@@ -1,35 +1,66 @@
+import json
+import os
+import tempfile
 import unittest
-from flask import Flask
+from unittest.mock import patch
+
 from server import app
 
+
 class TestServer(unittest.TestCase):
+    """The POST handler appends to LOG_PATH, which defaults to the developer's
+    own ~/.crumb/history.org. Every test here patches it at a temporary file:
+    without that, running the suite writes into real browsing history."""
+
+    def setUp(self):
+        self.client = app.test_client()
+        handle, self.log_path = tempfile.mkstemp(suffix=".org")
+        os.close(handle)
 
-    def test_log_visit_post(self):
-        # Create a test client
-        client = app.test_client()
+    def tearDown(self):
+        os.unlink(self.log_path)
 
-        # Sample JSON data
+    def test_log_visit_post_writes_an_entry(self):
         data = {
-            'title': 'Test Visit',
-            'url': 'https://test.com',
-            'hostname': 'test.com',
-            'path': '/',
-            'query': 'test',
-            'tabId': 123,
-            'windowId': 456,
-            'favIconUrl': 'https://example.com/favicon.ico'
+            "title": "Test Visit",
+            "url": "https://test.com",
+            "hostname": "test.com",
+            "path": "/",
+            "query": "test",
+            "tabId": 123,
+            "windowId": 456,
+            "favIconUrl": "https://example.com/favicon.ico",
         }
 
-        # Send a POST request
-        response = client.post('/', data=data, content_type='application/json')
+        # json=, not data=: with data= Flask sends it form-encoded regardless of
+        # content_type, request.json comes back empty and the handler 400s.
+        with patch("server.LOG_PATH", self.log_path):
+            response = self.client.post("/", json=data)
 
-        # Assertions
         self.assertEqual(response.status_code, 204)
-        # Optionally check the log file contents (for verification)
-        # This part would require additional logging setup.
-        # self.assertTrue('Test Visit' in self.get_log_content())
+
+        with open(self.log_path) as f:
+            written = f.read()
+        self.assertIn("* Test Visit", written)
+        self.assertIn(":URL:       https://test.com", written)
+        self.assertIn(":QUERY:     test", written)
+        self.assertIn(":END:", written)
+
+    def test_optional_fields_are_omitted_when_empty(self):
+        with patch("server.LOG_PATH", self.log_path):
+            response = self.client.post("/", json={"title": "Bare", "url": "https://x"})
+
+        self.assertEqual(response.status_code, 204)
+        with open(self.log_path) as f:
+            written = f.read()
+        self.assertNotIn(":QUERY:", written)
+        self.assertNotIn(":FAVICON:", written)
 
     def test_log_visit_options(self):
-        # Send an OPTIONS request
-        response = client.options( '/', content_type='application/json')
-        self.assertEqual(response.status_code, 204) #Verify 204 No Content for OPTIONS
+        response = self.client.options("/")
+        self.assertEqual(response.status_code, 204)
+
+    def test_cors_headers_are_present(self):
+        response = self.client.options("/")
+        self.assertEqual(response.headers["Access-Control-Allow-Origin"], "*")
+        self.assertEqual(response.headers["Access-Control-Allow-Headers"], "Content-Type")