Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
128 changes: 128 additions & 0 deletions backend/tests/test_metadata.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import json
import sqlite3

import pytest

from app.database import metadata as metadata_db


@pytest.fixture()
def metadata_database(tmp_path, monkeypatch):
db_path = tmp_path / "metadata.sqlite3"
monkeypatch.setattr(metadata_db, "DATABASE_PATH", str(db_path))
return db_path


# Validates that metadata table creation inserts a default empty metadata row.
def test_create_metadata_table_initializes_empty_metadata(metadata_database):
metadata_db.db_create_metadata_table()

with sqlite3.connect(str(metadata_database)) as conn:
rows = conn.execute("SELECT metadata FROM metadata").fetchall()

assert rows == [("{}",)]
assert metadata_db.db_get_metadata() == {}


# Validates that creating the metadata table again does not overwrite existing metadata.
def test_create_metadata_table_preserves_existing_metadata(metadata_database):
metadata_db.db_create_metadata_table()
expected_metadata = {"user_preferences": {"YOLO_model_size": "medium"}}

assert metadata_db.db_update_metadata(expected_metadata) is True
metadata_db.db_create_metadata_table()

with sqlite3.connect(str(metadata_database)) as conn:
row_count = conn.execute("SELECT COUNT(*) FROM metadata").fetchone()[0]

assert row_count == 1
assert metadata_db.db_get_metadata() == expected_metadata


# Validates that metadata retrieval returns None when the table has no metadata row.
def test_get_metadata_returns_none_when_no_row_exists(metadata_database):
metadata_db.db_create_metadata_table()

with sqlite3.connect(str(metadata_database)) as conn:
conn.execute("DELETE FROM metadata")

assert metadata_db.db_get_metadata() is None


# Validates that blank metadata content is treated as missing metadata.
def test_get_metadata_returns_none_for_blank_metadata(metadata_database):
metadata_db.db_create_metadata_table()

with sqlite3.connect(str(metadata_database)) as conn:
conn.execute("UPDATE metadata SET metadata = ?", ("",))

assert metadata_db.db_get_metadata() is None


# Validates that invalid JSON in the metadata row is handled as missing metadata.
def test_get_metadata_returns_none_for_invalid_json(metadata_database):
metadata_db.db_create_metadata_table()

with sqlite3.connect(str(metadata_database)) as conn:
conn.execute("UPDATE metadata SET metadata = ?", ("{invalid-json",))

assert metadata_db.db_get_metadata() is None


# Validates that updating metadata stores nested JSON-compatible values.
def test_update_metadata_stores_nested_values(metadata_database):
metadata_db.db_create_metadata_table()
expected_metadata = {
"user_preferences": {"YOLO_model_size": "nano", "GPU_Acceleration": False},
"recent_folders": ["photos", "archives"],
"version": 2,
}

assert metadata_db.db_update_metadata(expected_metadata) is True

assert metadata_db.db_get_metadata() == expected_metadata


# Validates that updating metadata replaces the previous row instead of appending another row.
def test_update_metadata_replaces_existing_metadata_row(metadata_database):
metadata_db.db_create_metadata_table()
old_metadata = {"old": True}
new_metadata = {"new": True, "count": 3}

assert metadata_db.db_update_metadata(old_metadata) is True
assert metadata_db.db_update_metadata(new_metadata) is True

with sqlite3.connect(str(metadata_database)) as conn:
rows = conn.execute("SELECT metadata FROM metadata").fetchall()

assert len(rows) == 1
assert json.loads(rows[0][0]) == new_metadata


# Validates that metadata can be updated through an existing database cursor.
def test_update_metadata_with_existing_cursor(metadata_database):
metadata_db.db_create_metadata_table()
expected_metadata = {"bulk_update": True}

conn = sqlite3.connect(str(metadata_database))
try:
cursor = conn.cursor()
assert metadata_db.db_update_metadata(expected_metadata, cursor) is True
conn.commit()
finally:
conn.close()

assert metadata_db.db_get_metadata() == expected_metadata


# Validates that update failures roll back without deleting previous metadata.
def test_update_metadata_rolls_back_when_json_serialization_fails(metadata_database):
metadata_db.db_create_metadata_table()
original_metadata = {"safe": True}

assert metadata_db.db_update_metadata(original_metadata) is True

with pytest.raises(TypeError):
metadata_db.db_update_metadata({"bad": object()})

assert metadata_db.db_get_metadata() == original_metadata
Comment on lines +118 to +128

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

This doesn't actually test rollback after a write begins.

backend/app/database/metadata.py serializes with json.dumps() before it runs DELETE/INSERT, so object() fails before any database state changes. That means this test only proves serialization failure leaves the old row untouched; it will not catch a regression in the transactional rollback path the PR summary calls out. Please force an error after the delete/insert sequence starts (for example by injecting a failing execute/commit) and then assert the original metadata is restored. As per path instructions, verify that all critical functionality is covered by tests.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/tests/test_metadata.py` around lines 118 - 128, The current test in
test_update_metadata_rolls_back_when_json_serialization_fails only exercises
json.dumps failure before any database writes, so it does not verify
transactional rollback. Update this test to trigger an exception after
db_update_metadata has started its DELETE/INSERT path in
metadata_db.db_update_metadata, such as by mocking a failing execute or commit
on the database connection, then assert db_get_metadata still returns the
original_metadata. Keep the test focused on the rollback behavior in
backend/app/database/metadata.py and cover the critical transactional path the
PR is meant to protect.

Source: Path instructions

75 changes: 75 additions & 0 deletions backend/tests/test_yolo_mapping.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import sqlite3

import pytest

from app.database import yolo_mapping as yolo_mapping_db


@pytest.fixture()
def yolo_database(tmp_path, monkeypatch):
db_path = tmp_path / "yolo_mapping.sqlite3"
monkeypatch.setattr(yolo_mapping_db, "DATABASE_PATH", str(db_path))
return db_path


def fetch_mappings(db_path):
with sqlite3.connect(str(db_path)) as conn:
return conn.execute(
"SELECT class_id, name FROM mappings ORDER BY class_id"
).fetchall()


# Validates that YOLO class table creation stores each class name with its index.
def test_create_yolo_classes_table_inserts_class_names(yolo_database, monkeypatch):
monkeypatch.setattr(yolo_mapping_db, "class_names", ["person", "bicycle", "car"])

yolo_mapping_db.db_create_YOLO_classes_table()

assert fetch_mappings(yolo_database) == [
(0, "person"),
(1, "bicycle"),
(2, "car"),
]


# Validates that rerunning table creation does not duplicate existing class mappings.
def test_create_yolo_classes_table_is_idempotent(yolo_database, monkeypatch):
monkeypatch.setattr(yolo_mapping_db, "class_names", ["person", "car"])

yolo_mapping_db.db_create_YOLO_classes_table()
yolo_mapping_db.db_create_YOLO_classes_table()

assert fetch_mappings(yolo_database) == [(0, "person"), (1, "car")]


# Validates that existing class IDs are replaced when class names change.
def test_create_yolo_classes_table_replaces_existing_class_names(
yolo_database, monkeypatch
):
monkeypatch.setattr(yolo_mapping_db, "class_names", ["old-person", "old-car"])
yolo_mapping_db.db_create_YOLO_classes_table()

monkeypatch.setattr(yolo_mapping_db, "class_names", ["person", "car"])
yolo_mapping_db.db_create_YOLO_classes_table()

assert fetch_mappings(yolo_database) == [(0, "person"), (1, "car")]


# Validates that table creation succeeds when no YOLO classes are configured.
def test_create_yolo_classes_table_handles_empty_class_list(yolo_database, monkeypatch):
monkeypatch.setattr(yolo_mapping_db, "class_names", [])

yolo_mapping_db.db_create_YOLO_classes_table()

assert fetch_mappings(yolo_database) == []
Comment on lines +45 to +64

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Add a shrink/clear regression case for stale mappings.

These tests only cover replacement when the new class_names list has the same length, plus an empty list on a fresh database. In backend/app/database/yolo_mapping.py, db_create_YOLO_classes_table() never deletes rows that disappear from class_names, so a populated table followed by ["person"] or [] will leave stale class IDs behind. Since the PR objective explicitly includes replacement behavior and empty class lists, please add a test that starts with existing rows and then shrinks or clears the list. As per path instructions, verify that all critical functionality is covered by tests.

Suggested regression test
+def test_create_yolo_classes_table_removes_stale_rows_when_class_list_shrinks(
+    yolo_database, monkeypatch
+):
+    monkeypatch.setattr(yolo_mapping_db, "class_names", ["person", "car"])
+    yolo_mapping_db.db_create_YOLO_classes_table()
+
+    monkeypatch.setattr(yolo_mapping_db, "class_names", ["person"])
+    yolo_mapping_db.db_create_YOLO_classes_table()
+
+    assert fetch_mappings(yolo_database) == [(0, "person")]
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
# Validates that existing class IDs are replaced when class names change.
def test_create_yolo_classes_table_replaces_existing_class_names(
yolo_database, monkeypatch
):
monkeypatch.setattr(yolo_mapping_db, "class_names", ["old-person", "old-car"])
yolo_mapping_db.db_create_YOLO_classes_table()
monkeypatch.setattr(yolo_mapping_db, "class_names", ["person", "car"])
yolo_mapping_db.db_create_YOLO_classes_table()
assert fetch_mappings(yolo_database) == [(0, "person"), (1, "car")]
# Validates that table creation succeeds when no YOLO classes are configured.
def test_create_yolo_classes_table_handles_empty_class_list(yolo_database, monkeypatch):
monkeypatch.setattr(yolo_mapping_db, "class_names", [])
yolo_mapping_db.db_create_YOLO_classes_table()
assert fetch_mappings(yolo_database) == []
# Validates that existing class IDs are replaced when class names change.
def test_create_yolo_classes_table_replaces_existing_class_names(
yolo_database, monkeypatch
):
monkeypatch.setattr(yolo_mapping_db, "class_names", ["old-person", "old-car"])
yolo_mapping_db.db_create_YOLO_classes_table()
monkeypatch.setattr(yolo_mapping_db, "class_names", ["person", "car"])
yolo_mapping_db.db_create_YOLO_classes_table()
assert fetch_mappings(yolo_database) == [(0, "person"), (1, "car")]
# Validates that table creation succeeds when no YOLO classes are configured.
def test_create_yolo_classes_table_handles_empty_class_list(yolo_database, monkeypatch):
monkeypatch.setattr(yolo_mapping_db, "class_names", [])
yolo_mapping_db.db_create_YOLO_classes_table()
assert fetch_mappings(yolo_database) == []
def test_create_yolo_classes_table_removes_stale_rows_when_class_list_shrinks(
yolo_database, monkeypatch
):
monkeypatch.setattr(yolo_mapping_db, "class_names", ["person", "car"])
yolo_mapping_db.db_create_YOLO_classes_table()
monkeypatch.setattr(yolo_mapping_db, "class_names", ["person"])
yolo_mapping_db.db_create_YOLO_classes_table()
assert fetch_mappings(yolo_database) == [(0, "person")]
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/tests/test_yolo_mapping.py` around lines 45 - 64, Add a regression
test for stale YOLO mappings in db_create_YOLO_classes_table by first seeding
the database with existing class rows, then shrinking
yolo_mapping_db.class_names to a shorter list or [] and calling the method
again. Use fetch_mappings on yolo_database to assert removed classes are
actually deleted, not just overwritten, so the test covers the replacement/clear
behavior that currently leaks stale class IDs.

Source: Path instructions



# Validates that duplicate class names can be stored under different class IDs.
def test_create_yolo_classes_table_allows_duplicate_names_with_distinct_ids(
yolo_database, monkeypatch
):
monkeypatch.setattr(yolo_mapping_db, "class_names", ["person", "person"])

yolo_mapping_db.db_create_YOLO_classes_table()

assert fetch_mappings(yolo_database) == [(0, "person"), (1, "person")]
Loading