From 7d47615f580f7fb4f99b0abb32632142ea735948 Mon Sep 17 00:00:00 2001 From: Mansi2007275 Date: Thu, 25 Jun 2026 17:52:50 +0530 Subject: [PATCH 1/2] add database tests for albums and faces (#1238) --- backend/tests/test_albums_db.py | 302 ++++++++++++++++++++++++++++++++ backend/tests/test_faces_db.py | 220 +++++++++++++++++++++++ 2 files changed, 522 insertions(+) create mode 100644 backend/tests/test_albums_db.py create mode 100644 backend/tests/test_faces_db.py diff --git a/backend/tests/test_albums_db.py b/backend/tests/test_albums_db.py new file mode 100644 index 000000000..b4f30c9b6 --- /dev/null +++ b/backend/tests/test_albums_db.py @@ -0,0 +1,302 @@ +import pytest +import sqlite3 +import uuid +from unittest.mock import patch, MagicMock +from app.database.albums import ( + db_create_albums_table, + db_create_album_images_table, + db_get_all_albums, + db_get_album_by_name, + db_get_album, + db_insert_album, + db_update_album, + db_delete_album, + db_get_album_images, + db_remove_images_from_album, + verify_album_password, +) + + +class TestDbCreateAlbumsTable: + # Tests that albums table is created without raising errors + def test_create_albums_table_success(self): + with patch("app.database.albums.sqlite3.connect") as mock_connect: + mock_conn = MagicMock() + mock_connect.return_value = mock_conn + db_create_albums_table() + mock_conn.cursor.return_value.execute.assert_called_once() + mock_conn.commit.assert_called_once() + + # Tests that connection is closed even if an error occurs + def test_create_albums_table_closes_connection_on_error(self): + with patch("app.database.albums.sqlite3.connect") as mock_connect: + mock_conn = MagicMock() + mock_conn.cursor.return_value.execute.side_effect = sqlite3.Error("fail") + mock_connect.return_value = mock_conn + with pytest.raises(sqlite3.Error): + db_create_albums_table() + mock_conn.close.assert_called_once() + + +class TestDbCreateAlbumImagesTable: + # Tests that album_images table is created without raising errors + def test_create_album_images_table_success(self): + with patch("app.database.albums.sqlite3.connect") as mock_connect: + mock_conn = MagicMock() + mock_connect.return_value = mock_conn + db_create_album_images_table() + mock_conn.cursor.return_value.execute.assert_called_once() + mock_conn.commit.assert_called_once() + + +class TestDbGetAllAlbums: + # Tests that all visible albums are returned when show_hidden is False + def test_get_all_albums_public_only(self): + fake_albums = [ + ("id-1", "Album One", "Desc 1", 0, None), + ("id-2", "Album Two", "Desc 2", 0, None), + ] + with patch("app.database.albums.sqlite3.connect") as mock_connect: + mock_conn = MagicMock() + mock_cursor = MagicMock() + mock_cursor.fetchall.return_value = fake_albums + mock_conn.cursor.return_value = mock_cursor + mock_connect.return_value = mock_conn + result = db_get_all_albums(show_hidden=False) + assert result == fake_albums + mock_cursor.execute.assert_called_once_with( + "SELECT * FROM albums WHERE is_hidden = 0" + ) + + # Tests that hidden albums are also returned when show_hidden is True + def test_get_all_albums_include_hidden(self): + fake_albums = [ + ("id-1", "Album One", "Desc 1", 0, None), + ("id-2", "Hidden Album", "Secret", 1, "hash"), + ] + with patch("app.database.albums.sqlite3.connect") as mock_connect: + mock_conn = MagicMock() + mock_cursor = MagicMock() + mock_cursor.fetchall.return_value = fake_albums + mock_conn.cursor.return_value = mock_cursor + mock_connect.return_value = mock_conn + result = db_get_all_albums(show_hidden=True) + assert result == fake_albums + mock_cursor.execute.assert_called_once_with("SELECT * FROM albums") + + # Tests that an empty list is returned when no albums exist + def test_get_all_albums_empty(self): + with patch("app.database.albums.sqlite3.connect") as mock_connect: + mock_conn = MagicMock() + mock_cursor = MagicMock() + mock_cursor.fetchall.return_value = [] + mock_conn.cursor.return_value = mock_cursor + mock_connect.return_value = mock_conn + result = db_get_all_albums() + assert result == [] + + +class TestDbGetAlbumByName: + # Tests that correct album is returned when name matches + def test_get_album_by_name_found(self): + fake_album = ("id-1", "Summer Trip", "Fun times", 0, None) + with patch("app.database.albums.sqlite3.connect") as mock_connect: + mock_conn = MagicMock() + mock_cursor = MagicMock() + mock_cursor.fetchone.return_value = fake_album + mock_conn.cursor.return_value = mock_cursor + mock_connect.return_value = mock_conn + result = db_get_album_by_name("Summer Trip") + assert result == fake_album + + # Tests that None is returned when album name does not exist + def test_get_album_by_name_not_found(self): + with patch("app.database.albums.sqlite3.connect") as mock_connect: + mock_conn = MagicMock() + mock_cursor = MagicMock() + mock_cursor.fetchone.return_value = None + mock_conn.cursor.return_value = mock_cursor + mock_connect.return_value = mock_conn + result = db_get_album_by_name("Nonexistent") + assert result is None + + +class TestDbGetAlbum: + # Tests that correct album is returned when album_id matches + def test_get_album_found(self): + album_id = str(uuid.uuid4()) + fake_album = (album_id, "My Album", "Description", 0, None) + with patch("app.database.albums.sqlite3.connect") as mock_connect: + mock_conn = MagicMock() + mock_cursor = MagicMock() + mock_cursor.fetchone.return_value = fake_album + mock_conn.cursor.return_value = mock_cursor + mock_connect.return_value = mock_conn + result = db_get_album(album_id) + assert result == fake_album + + # Tests that None is returned when album_id does not exist + def test_get_album_not_found(self): + with patch("app.database.albums.sqlite3.connect") as mock_connect: + mock_conn = MagicMock() + mock_cursor = MagicMock() + mock_cursor.fetchone.return_value = None + mock_conn.cursor.return_value = mock_cursor + mock_connect.return_value = mock_conn + result = db_get_album("nonexistent-id") + assert result is None + + +class TestDbInsertAlbum: + # Tests that a basic album without password is inserted correctly + def test_insert_album_without_password(self): + album_id = str(uuid.uuid4()) + with patch("app.database.albums.sqlite3.connect") as mock_connect: + mock_conn = MagicMock() + mock_cursor = MagicMock() + mock_conn.cursor.return_value = mock_cursor + mock_connect.return_value = mock_conn + db_insert_album(album_id, "Test Album", "A description", False, None) + mock_cursor.execute.assert_called_once() + mock_conn.commit.assert_called_once() + + # Tests that a password is hashed before being stored in the database + def test_insert_album_with_password_hashes_it(self): + album_id = str(uuid.uuid4()) + with patch("app.database.albums.sqlite3.connect") as mock_connect: + mock_conn = MagicMock() + mock_cursor = MagicMock() + mock_conn.cursor.return_value = mock_cursor + mock_connect.return_value = mock_conn + db_insert_album(album_id, "Secret Album", "Hidden", True, "mypassword") + call_args = mock_cursor.execute.call_args[0][1] + # password_hash should not be plain text + assert call_args[4] != "mypassword" + assert call_args[4] is not None + + +class TestDbUpdateAlbum: + # Tests that album fields are updated correctly without changing password + def test_update_album_without_password(self): + album_id = str(uuid.uuid4()) + with patch("app.database.albums.sqlite3.connect") as mock_connect: + mock_conn = MagicMock() + mock_cursor = MagicMock() + mock_conn.cursor.return_value = mock_cursor + mock_connect.return_value = mock_conn + db_update_album(album_id, "New Name", "New Desc", False, None) + mock_cursor.execute.assert_called_once() + mock_conn.commit.assert_called_once() + + # Tests that password is hashed when updating album with a new password + def test_update_album_with_new_password(self): + album_id = str(uuid.uuid4()) + with patch("app.database.albums.sqlite3.connect") as mock_connect: + mock_conn = MagicMock() + mock_cursor = MagicMock() + mock_conn.cursor.return_value = mock_cursor + mock_connect.return_value = mock_conn + db_update_album(album_id, "New Name", "New Desc", True, "newpassword") + call_args = mock_cursor.execute.call_args[0][1] + assert call_args[3] != "newpassword" + + +class TestDbDeleteAlbum: + # Tests that delete is called with the correct album_id + def test_delete_album_success(self): + album_id = str(uuid.uuid4()) + with patch("app.database.albums.get_db_connection") as mock_conn_ctx: + mock_conn = MagicMock() + mock_cursor = MagicMock() + mock_conn.cursor.return_value = mock_cursor + mock_conn_ctx.return_value.__enter__ = MagicMock(return_value=mock_conn) + mock_conn_ctx.return_value.__exit__ = MagicMock(return_value=False) + db_delete_album(album_id) + mock_cursor.execute.assert_called_once_with( + "DELETE FROM albums WHERE album_id = ?", (album_id,) + ) + + +class TestDbGetAlbumImages: + # Tests that list of image IDs is returned for a valid album + def test_get_album_images_returns_list(self): + album_id = str(uuid.uuid4()) + fake_images = [("img-1",), ("img-2",), ("img-3",)] + with patch("app.database.albums.sqlite3.connect") as mock_connect: + mock_conn = MagicMock() + mock_cursor = MagicMock() + mock_cursor.fetchall.return_value = fake_images + mock_conn.cursor.return_value = mock_cursor + mock_connect.return_value = mock_conn + result = db_get_album_images(album_id) + assert result == ["img-1", "img-2", "img-3"] + + # Tests that empty list is returned when album has no images + def test_get_album_images_empty(self): + album_id = str(uuid.uuid4()) + with patch("app.database.albums.sqlite3.connect") as mock_connect: + mock_conn = MagicMock() + mock_cursor = MagicMock() + mock_cursor.fetchall.return_value = [] + mock_conn.cursor.return_value = mock_cursor + mock_connect.return_value = mock_conn + result = db_get_album_images(album_id) + assert result == [] + + +class TestDbRemoveImagesFromAlbum: + # Tests that multiple images are removed from album in one call + def test_remove_images_from_album(self): + album_id = str(uuid.uuid4()) + image_ids = ["img-1", "img-2"] + with patch("app.database.albums.sqlite3.connect") as mock_connect: + mock_conn = MagicMock() + mock_cursor = MagicMock() + mock_conn.cursor.return_value = mock_cursor + mock_connect.return_value = mock_conn + db_remove_images_from_album(album_id, image_ids) + mock_cursor.executemany.assert_called_once() + mock_conn.commit.assert_called_once() + + +class TestVerifyAlbumPassword: + # Tests that correct password returns True + def test_verify_correct_password(self): + import bcrypt + album_id = str(uuid.uuid4()) + password = "securepass" + hashed = bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode() + with patch("app.database.albums.sqlite3.connect") as mock_connect: + mock_conn = MagicMock() + mock_cursor = MagicMock() + mock_cursor.fetchone.return_value = (hashed,) + mock_conn.cursor.return_value = mock_cursor + mock_connect.return_value = mock_conn + result = verify_album_password(album_id, password) + assert result is True + + # Tests that wrong password returns False + def test_verify_wrong_password(self): + import bcrypt + album_id = str(uuid.uuid4()) + hashed = bcrypt.hashpw("correct".encode(), bcrypt.gensalt()).decode() + with patch("app.database.albums.sqlite3.connect") as mock_connect: + mock_conn = MagicMock() + mock_cursor = MagicMock() + mock_cursor.fetchone.return_value = (hashed,) + mock_conn.cursor.return_value = mock_cursor + mock_connect.return_value = mock_conn + result = verify_album_password(album_id, "wrongpass") + assert result is False + + # Tests that missing album returns False without error + def test_verify_album_not_found_returns_false(self): + album_id = str(uuid.uuid4()) + with patch("app.database.albums.sqlite3.connect") as mock_connect: + mock_conn = MagicMock() + mock_cursor = MagicMock() + mock_cursor.fetchone.return_value = None + mock_conn.cursor.return_value = mock_cursor + mock_connect.return_value = mock_conn + result = verify_album_password(album_id, "anypassword") + assert result is False \ No newline at end of file diff --git a/backend/tests/test_faces_db.py b/backend/tests/test_faces_db.py new file mode 100644 index 000000000..9b73192da --- /dev/null +++ b/backend/tests/test_faces_db.py @@ -0,0 +1,220 @@ +import pytest +import sqlite3 +import json +import numpy as np +from unittest.mock import patch, MagicMock +from app.database.faces import ( + db_create_faces_table, + db_insert_face_embeddings, + db_get_faces_unassigned_clusters, + db_get_all_faces_with_cluster_names, + db_update_face_cluster_ids_batch, + db_get_cluster_mean_embeddings, +) + + +class TestDbCreateFacesTable: + # Tests that faces table is created successfully without errors + def test_create_faces_table_success(self): + with patch("app.database.faces.sqlite3.connect") as mock_connect: + mock_conn = MagicMock() + mock_connect.return_value = mock_conn + db_create_faces_table() + mock_conn.cursor.return_value.execute.assert_called_once() + mock_conn.commit.assert_called_once() + + # Tests that connection is closed even when table creation fails + def test_create_faces_table_closes_connection_on_error(self): + with patch("app.database.faces.sqlite3.connect") as mock_connect: + mock_conn = MagicMock() + mock_conn.cursor.return_value.execute.side_effect = sqlite3.Error("fail") + mock_connect.return_value = mock_conn + with pytest.raises(sqlite3.Error): + db_create_faces_table() + mock_conn.close.assert_called_once() + + +class TestDbInsertFaceEmbeddings: + # Tests that face embedding is inserted and returns a valid face_id + def test_insert_face_embeddings_success(self): + image_id = "test-image-uuid" + embeddings = [np.array([0.1, 0.2, 0.3])] + with patch("app.database.faces.sqlite3.connect") as mock_connect: + mock_conn = MagicMock() + mock_cursor = MagicMock() + mock_cursor.lastrowid = 1 + mock_conn.cursor.return_value = mock_cursor + mock_connect.return_value = mock_conn + result = db_insert_face_embeddings(image_id, embeddings) + assert result == 1 + mock_cursor.execute.assert_called_once() + mock_conn.commit.assert_called_once() + + # Tests that face embedding is inserted with optional confidence and bbox + def test_insert_face_embeddings_with_metadata(self): + image_id = "test-image-uuid" + embeddings = [np.array([0.4, 0.5, 0.6])] + bbox = {"x": 10, "y": 20, "width": 50, "height": 60} + with patch("app.database.faces.sqlite3.connect") as mock_connect: + mock_conn = MagicMock() + mock_cursor = MagicMock() + mock_cursor.lastrowid = 2 + mock_conn.cursor.return_value = mock_cursor + mock_connect.return_value = mock_conn + result = db_insert_face_embeddings( + image_id, embeddings, confidence=0.98, bbox=bbox + ) + assert result == 2 + call_args = mock_cursor.execute.call_args[0][1] + assert call_args[3] == 0.98 + assert json.loads(call_args[4]) == bbox + + # Tests that connection is always closed after insert + def test_insert_face_embeddings_closes_connection(self): + image_id = "test-image-uuid" + embeddings = [np.array([0.1, 0.2])] + with patch("app.database.faces.sqlite3.connect") as mock_connect: + mock_conn = MagicMock() + mock_cursor = MagicMock() + mock_cursor.lastrowid = 3 + mock_conn.cursor.return_value = mock_cursor + mock_connect.return_value = mock_conn + db_insert_face_embeddings(image_id, embeddings) + mock_conn.close.assert_called_once() + + +class TestDbGetFacesUnassignedClusters: + # Tests that faces without cluster_id are returned as list of dicts + def test_get_unassigned_faces_returns_list(self): + fake_embedding = np.array([0.1, 0.2, 0.3]) + fake_rows = [(1, json.dumps(fake_embedding.tolist()))] + with patch("app.database.faces.sqlite3.connect") as mock_connect: + mock_conn = MagicMock() + mock_cursor = MagicMock() + mock_cursor.fetchall.return_value = fake_rows + mock_conn.cursor.return_value = mock_cursor + mock_connect.return_value = mock_conn + result = db_get_faces_unassigned_clusters() + assert len(result) == 1 + assert result[0]["face_id"] == 1 + assert isinstance(result[0]["embeddings"], np.ndarray) + + # Tests that empty list is returned when all faces have clusters assigned + def test_get_unassigned_faces_empty(self): + with patch("app.database.faces.sqlite3.connect") as mock_connect: + mock_conn = MagicMock() + mock_cursor = MagicMock() + mock_cursor.fetchall.return_value = [] + mock_conn.cursor.return_value = mock_cursor + mock_connect.return_value = mock_conn + result = db_get_faces_unassigned_clusters() + assert result == [] + + +class TestDbGetAllFacesWithClusterNames: + # Tests that faces are returned with their cluster names + def test_get_faces_with_cluster_names(self): + fake_embedding = np.array([0.1, 0.2, 0.3]) + fake_rows = [(1, json.dumps(fake_embedding.tolist()), "Alice")] + with patch("app.database.faces.sqlite3.connect") as mock_connect: + mock_conn = MagicMock() + mock_cursor = MagicMock() + mock_cursor.fetchall.return_value = fake_rows + mock_conn.cursor.return_value = mock_cursor + mock_connect.return_value = mock_conn + result = db_get_all_faces_with_cluster_names() + assert len(result) == 1 + assert result[0]["face_id"] == 1 + assert result[0]["cluster_name"] == "Alice" + assert isinstance(result[0]["embeddings"], np.ndarray) + + # Tests that cluster_name is None for faces not assigned to any cluster + def test_get_faces_with_no_cluster_name(self): + fake_embedding = np.array([0.5, 0.6]) + fake_rows = [(2, json.dumps(fake_embedding.tolist()), None)] + with patch("app.database.faces.sqlite3.connect") as mock_connect: + mock_conn = MagicMock() + mock_cursor = MagicMock() + mock_cursor.fetchall.return_value = fake_rows + mock_conn.cursor.return_value = mock_cursor + mock_connect.return_value = mock_conn + result = db_get_all_faces_with_cluster_names() + assert result[0]["cluster_name"] is None + + # Tests that empty list is returned when no faces exist in database + def test_get_faces_empty(self): + with patch("app.database.faces.sqlite3.connect") as mock_connect: + mock_conn = MagicMock() + mock_cursor = MagicMock() + mock_cursor.fetchall.return_value = [] + mock_conn.cursor.return_value = mock_cursor + mock_connect.return_value = mock_conn + result = db_get_all_faces_with_cluster_names() + assert result == [] + + +class TestDbUpdateFaceClusterIdsBatch: + # Tests that batch update runs without error for valid mapping list + def test_update_batch_success(self): + mapping = [{"face_id": 1, "cluster_id": 10}, {"face_id": 2, "cluster_id": 20}] + with patch("app.database.faces.sqlite3.connect") as mock_connect: + mock_conn = MagicMock() + mock_cursor = MagicMock() + mock_conn.cursor.return_value = mock_cursor + mock_connect.return_value = mock_conn + db_update_face_cluster_ids_batch(mapping) + mock_cursor.executemany.assert_called_once() + mock_conn.commit.assert_called_once() + + # Tests that empty mapping list returns early without any database calls + def test_update_batch_empty_mapping(self): + with patch("app.database.faces.sqlite3.connect") as mock_connect: + db_update_face_cluster_ids_batch([]) + mock_connect.assert_not_called() + + # Tests that cluster_id can be set to None to unassign a face from cluster + def test_update_batch_with_none_cluster(self): + mapping = [{"face_id": 1, "cluster_id": None}] + with patch("app.database.faces.sqlite3.connect") as mock_connect: + mock_conn = MagicMock() + mock_cursor = MagicMock() + mock_conn.cursor.return_value = mock_cursor + mock_connect.return_value = mock_conn + db_update_face_cluster_ids_batch(mapping) + call_args = mock_cursor.executemany.call_args[0][1] + assert call_args[0] == (None, 1) + + +class TestDbGetClusterMeanEmbeddings: + # Tests that mean embeddings are calculated correctly per cluster + def test_get_cluster_mean_embeddings_success(self): + emb1 = np.array([0.2, 0.4]) + emb2 = np.array([0.6, 0.8]) + fake_rows = [ + (1, json.dumps(emb1.tolist())), + (1, json.dumps(emb2.tolist())), + ] + with patch("app.database.faces.sqlite3.connect") as mock_connect: + mock_conn = MagicMock() + mock_cursor = MagicMock() + mock_cursor.fetchall.return_value = fake_rows + mock_conn.cursor.return_value = mock_cursor + mock_connect.return_value = mock_conn + result = db_get_cluster_mean_embeddings() + assert len(result) == 1 + assert result[0]["cluster_id"] == 1 + expected_mean = np.mean([emb1, emb2], axis=0) + np.testing.assert_array_almost_equal( + result[0]["mean_embedding"], expected_mean + ) + + # Tests that empty list is returned when no faces have cluster assigned + def test_get_cluster_mean_embeddings_empty(self): + with patch("app.database.faces.sqlite3.connect") as mock_connect: + mock_conn = MagicMock() + mock_cursor = MagicMock() + mock_cursor.fetchall.return_value = [] + mock_conn.cursor.return_value = mock_cursor + mock_connect.return_value = mock_conn + result = db_get_cluster_mean_embeddings() + assert result == [] \ No newline at end of file From 152f7d402490f948ae16ad8ce4db6deb26e929c1 Mon Sep 17 00:00:00 2001 From: Mansi2007275 Date: Thu, 25 Jun 2026 19:04:25 +0530 Subject: [PATCH 2/2] address CodeRabbit review - add connection close assertions Signed-off-by: Mansi2007275 --- backend/tests/test_albums_db.py | 11 ++++++++++- backend/tests/test_faces_db.py | 9 +++++---- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/backend/tests/test_albums_db.py b/backend/tests/test_albums_db.py index b4f30c9b6..4d8cb6fbc 100644 --- a/backend/tests/test_albums_db.py +++ b/backend/tests/test_albums_db.py @@ -47,7 +47,16 @@ def test_create_album_images_table_success(self): db_create_album_images_table() mock_conn.cursor.return_value.execute.assert_called_once() mock_conn.commit.assert_called_once() - + + # Tests that connection is closed even if an error occurs during table creation +def test_create_album_images_table_closes_connection_on_error(self): + with patch("app.database.albums.sqlite3.connect") as mock_connect: + mock_conn = MagicMock() + mock_conn.cursor.return_value.execute.side_effect = sqlite3.Error("fail") + mock_connect.return_value = mock_conn + with pytest.raises(sqlite3.Error): + db_create_album_images_table() + mock_conn.close.assert_called_once() class TestDbGetAllAlbums: # Tests that all visible albums are returned when show_hidden is False diff --git a/backend/tests/test_faces_db.py b/backend/tests/test_faces_db.py index 9b73192da..fc87ea85d 100644 --- a/backend/tests/test_faces_db.py +++ b/backend/tests/test_faces_db.py @@ -109,7 +109,7 @@ def test_get_unassigned_faces_empty(self): mock_connect.return_value = mock_conn result = db_get_faces_unassigned_clusters() assert result == [] - + mock_conn.close.assert_called_once() class TestDbGetAllFacesWithClusterNames: # Tests that faces are returned with their cluster names @@ -151,7 +151,7 @@ def test_get_faces_empty(self): mock_connect.return_value = mock_conn result = db_get_all_faces_with_cluster_names() assert result == [] - + mock_conn.close.assert_called_once() class TestDbUpdateFaceClusterIdsBatch: # Tests that batch update runs without error for valid mapping list @@ -183,7 +183,7 @@ def test_update_batch_with_none_cluster(self): db_update_face_cluster_ids_batch(mapping) call_args = mock_cursor.executemany.call_args[0][1] assert call_args[0] == (None, 1) - + mock_conn.close.assert_called_once() class TestDbGetClusterMeanEmbeddings: # Tests that mean embeddings are calculated correctly per cluster @@ -217,4 +217,5 @@ def test_get_cluster_mean_embeddings_empty(self): mock_conn.cursor.return_value = mock_cursor mock_connect.return_value = mock_conn result = db_get_cluster_mean_embeddings() - assert result == [] \ No newline at end of file + assert result == [] + mock_conn.close.assert_called_once() \ No newline at end of file