From 0e5a72c870b05e43e993b8c6711ae3ac7acfe6d3 Mon Sep 17 00:00:00 2001 From: Matt Davis Date: Sat, 22 Aug 2026 06:40:56 -0400 Subject: [PATCH] feat: add camera location hierarchy --- db/migrations/0049_add_camera_locations.sql | 57 +++ include/core/config.h | 1 + include/database/db_embedded_migrations.h | 63 ++- include/database/db_locations.h | 47 +++ include/web/api_handlers_locations.h | 14 + src/core/config.c | 7 +- src/database/db_locations.c | 444 ++++++++++++++++++++ src/database/db_streams.c | 25 +- src/web/api_handlers_locations.c | 382 +++++++++++++++++ src/web/api_handlers_streams_get.c | 3 + src/web/api_handlers_streams_modify.c | 3 + src/web/api_handlers_system.c | 2 + src/web/libuv_api_handlers.c | 10 + tests/unit/CMakeLists.txt | 2 + tests/unit/test_api_handlers_locations.c | 303 +++++++++++++ tests/unit/test_api_handlers_system.c | 13 + tests/unit/test_db_locations.c | 224 ++++++++++ 17 files changed, 1591 insertions(+), 9 deletions(-) create mode 100644 db/migrations/0049_add_camera_locations.sql create mode 100644 include/database/db_locations.h create mode 100644 include/web/api_handlers_locations.h create mode 100644 src/database/db_locations.c create mode 100644 src/web/api_handlers_locations.c create mode 100644 tests/unit/test_api_handlers_locations.c create mode 100644 tests/unit/test_db_locations.c diff --git a/db/migrations/0049_add_camera_locations.sql b/db/migrations/0049_add_camera_locations.sql new file mode 100644 index 00000000..314c9414 --- /dev/null +++ b/db/migrations/0049_add_camera_locations.sql @@ -0,0 +1,57 @@ +-- Hierarchical physical locations and one primary location per camera. + +-- migrate:up + +CREATE TABLE camera_locations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + uuid TEXT NOT NULL UNIQUE, + parent_uuid TEXT REFERENCES camera_locations(uuid) ON DELETE RESTRICT, + name TEXT NOT NULL, + type TEXT NOT NULL DEFAULT 'area', + sort_order INTEGER NOT NULL DEFAULT 0, + metadata_json TEXT NOT NULL DEFAULT '{}', + is_system INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')), + updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')) +); + +CREATE UNIQUE INDEX idx_camera_locations_sibling_name +ON camera_locations(ifnull(parent_uuid, ''), name COLLATE NOCASE); + +CREATE INDEX idx_camera_locations_parent +ON camera_locations(parent_uuid, sort_order, name COLLATE NOCASE); + +INSERT INTO camera_locations (uuid, parent_uuid, name, type, is_system) +VALUES ( + lower( + hex(randomblob(4)) || '-' || + hex(randomblob(2)) || '-4' || + substr(hex(randomblob(2)), 2) || '-' || + substr('89ab', (abs(random()) % 4) + 1, 1) || + substr(hex(randomblob(2)), 2) || '-' || + hex(randomblob(6)) + ), + NULL, + 'Unassigned', + 'system', + 1 +); + +ALTER TABLE streams ADD COLUMN location_uuid TEXT DEFAULT NULL +REFERENCES camera_locations(uuid) ON DELETE RESTRICT; + +UPDATE streams +SET location_uuid = (SELECT uuid FROM camera_locations WHERE is_system = 1 LIMIT 1) +WHERE location_uuid IS NULL; + +CREATE INDEX idx_streams_location_uuid +ON streams(location_uuid); + +-- migrate:down + +DROP INDEX IF EXISTS idx_streams_location_uuid; +UPDATE streams SET location_uuid = NULL; +DROP INDEX IF EXISTS idx_camera_locations_parent; +DROP INDEX IF EXISTS idx_camera_locations_sibling_name; +DROP TABLE IF EXISTS camera_locations; +SELECT 1; diff --git a/include/core/config.h b/include/core/config.h index 885adc19..f9f5c7b3 100644 --- a/include/core/config.h +++ b/include/core/config.h @@ -26,6 +26,7 @@ typedef enum { // Stream configuration structure typedef struct { char camera_uuid[CAMERA_UUID_STRING_SIZE]; // Immutable fleet identity + char location_uuid[CAMERA_UUID_STRING_SIZE]; // Primary physical location char name[MAX_STREAM_NAME]; char url[MAX_URL_LENGTH]; bool enabled; diff --git a/include/database/db_embedded_migrations.h b/include/database/db_embedded_migrations.h index 12301d63..38bbb0ac 100644 --- a/include/database/db_embedded_migrations.h +++ b/include/database/db_embedded_migrations.h @@ -699,6 +699,60 @@ static const char migration_0048_down[] = "DROP INDEX IF EXISTS idx_streams_camera_uuid;\n" "SELECT 1;"; +static const char migration_0049_up[] = + "CREATE TABLE camera_locations (\n" + " id INTEGER PRIMARY KEY AUTOINCREMENT,\n" + " uuid TEXT NOT NULL UNIQUE,\n" + " parent_uuid TEXT REFERENCES camera_locations(uuid) ON DELETE RESTRICT,\n" + " name TEXT NOT NULL,\n" + " type TEXT NOT NULL DEFAULT 'area',\n" + " sort_order INTEGER NOT NULL DEFAULT 0,\n" + " metadata_json TEXT NOT NULL DEFAULT '{}',\n" + " is_system INTEGER NOT NULL DEFAULT 0,\n" + " created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),\n" + " updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))\n" + ");\n" + "\n" + "CREATE UNIQUE INDEX idx_camera_locations_sibling_name\n" + "ON camera_locations(ifnull(parent_uuid, ''), name COLLATE NOCASE);\n" + "\n" + "CREATE INDEX idx_camera_locations_parent\n" + "ON camera_locations(parent_uuid, sort_order, name COLLATE NOCASE);\n" + "\n" + "INSERT INTO camera_locations (uuid, parent_uuid, name, type, is_system)\n" + "VALUES (\n" + " lower(\n" + " hex(randomblob(4)) || '-' ||\n" + " hex(randomblob(2)) || '-4' ||\n" + " substr(hex(randomblob(2)), 2) || '-' ||\n" + " substr('89ab', (abs(random()) % 4) + 1, 1) ||\n" + " substr(hex(randomblob(2)), 2) || '-' ||\n" + " hex(randomblob(6))\n" + " ),\n" + " NULL,\n" + " 'Unassigned',\n" + " 'system',\n" + " 1\n" + ");\n" + "\n" + "ALTER TABLE streams ADD COLUMN location_uuid TEXT DEFAULT NULL\n" + "REFERENCES camera_locations(uuid) ON DELETE RESTRICT;\n" + "\n" + "UPDATE streams\n" + "SET location_uuid = (SELECT uuid FROM camera_locations WHERE is_system = 1 LIMIT 1)\n" + "WHERE location_uuid IS NULL;\n" + "\n" + "CREATE INDEX idx_streams_location_uuid\n" + "ON streams(location_uuid);"; + +static const char migration_0049_down[] = + "DROP INDEX IF EXISTS idx_streams_location_uuid;\n" + "UPDATE streams SET location_uuid = NULL;\n" + "DROP INDEX IF EXISTS idx_camera_locations_parent;\n" + "DROP INDEX IF EXISTS idx_camera_locations_sibling_name;\n" + "DROP TABLE IF EXISTS camera_locations;\n" + "SELECT 1;"; + static const migration_t embedded_migrations_data[] = { { .version = "0001", @@ -1029,8 +1083,15 @@ static const migration_t embedded_migrations_data[] = { .sql_down = migration_0048_down, .is_embedded = true }, + { + .version = "0049", + .description = "add_camera_locations", + .sql_up = migration_0049_up, + .sql_down = migration_0049_down, + .is_embedded = true + }, }; -#define EMBEDDED_MIGRATIONS_COUNT 47 +#define EMBEDDED_MIGRATIONS_COUNT 48 #endif /* DB_EMBEDDED_MIGRATIONS_H */ diff --git a/include/database/db_locations.h b/include/database/db_locations.h new file mode 100644 index 00000000..ca4a9979 --- /dev/null +++ b/include/database/db_locations.h @@ -0,0 +1,47 @@ +#ifndef LIGHTNVR_DB_LOCATIONS_H +#define LIGHTNVR_DB_LOCATIONS_H + +#include + +#include "core/config.h" + +#define LOCATION_NAME_MAX 128 +#define LOCATION_TYPE_MAX 64 +#define LOCATION_METADATA_MAX 512 + +typedef struct { + char uuid[CAMERA_UUID_STRING_SIZE]; + char parent_uuid[CAMERA_UUID_STRING_SIZE]; + char name[LOCATION_NAME_MAX]; + char type[LOCATION_TYPE_MAX]; + char metadata_json[LOCATION_METADATA_MAX]; + int sort_order; + int is_system; + int direct_child_count; + int direct_camera_count; + int64_t created_at; + int64_t updated_at; +} camera_location_t; + +typedef enum { + DB_LOCATION_OK = 0, + DB_LOCATION_NOT_FOUND = -1, + DB_LOCATION_CONFLICT = -2, + DB_LOCATION_INVALID = -3, + DB_LOCATION_ERROR = -4 +} db_location_result_t; + +db_location_result_t db_location_get_unassigned(camera_location_t *location); +db_location_result_t db_location_get(const char *uuid, + camera_location_t *location); +int db_location_count(void); +int db_location_list(camera_location_t *locations, int max_count); +db_location_result_t db_location_create(camera_location_t *location); +db_location_result_t db_location_update(camera_location_t *location); +db_location_result_t db_location_delete(const char *uuid); +db_location_result_t db_location_assign_camera(const char *camera_uuid, + const char *location_uuid); +db_location_result_t db_location_get_for_camera(const char *camera_uuid, + camera_location_t *location); + +#endif /* LIGHTNVR_DB_LOCATIONS_H */ diff --git a/include/web/api_handlers_locations.h b/include/web/api_handlers_locations.h new file mode 100644 index 00000000..c2c02a2f --- /dev/null +++ b/include/web/api_handlers_locations.h @@ -0,0 +1,14 @@ +#ifndef API_HANDLERS_LOCATIONS_H +#define API_HANDLERS_LOCATIONS_H + +#include "web/request_response.h" + +void handle_get_locations(const http_request_t *req, http_response_t *res); +void handle_post_location(const http_request_t *req, http_response_t *res); +void handle_get_location(const http_request_t *req, http_response_t *res); +void handle_put_location(const http_request_t *req, http_response_t *res); +void handle_delete_location(const http_request_t *req, http_response_t *res); +void handle_put_camera_location(const http_request_t *req, + http_response_t *res); + +#endif /* API_HANDLERS_LOCATIONS_H */ diff --git a/src/core/config.c b/src/core/config.c index 06841523..823ea63c 100644 --- a/src/core/config.c +++ b/src/core/config.c @@ -1206,8 +1206,8 @@ int save_stream_configs(const config_t *config) { } if (identical) { - /* The database owns stable camera identity. Hydrate UUIDs even when - * no stream configuration write is necessary. */ + /* The database owns stable camera identity and location. Hydrate + * both even when no stream configuration write is necessary. */ for (int i = 0; i < config->max_streams; i++) { if (config->streams[i].name[0] == '\0') { continue; @@ -1218,6 +1218,9 @@ int save_stream_configs(const config_t *config) { safe_strcpy(config->streams[i].camera_uuid, db_streams[j].camera_uuid, sizeof(config->streams[i].camera_uuid), 0); + safe_strcpy(config->streams[i].location_uuid, + db_streams[j].location_uuid, + sizeof(config->streams[i].location_uuid), 0); break; } } diff --git a/src/database/db_locations.c b/src/database/db_locations.c new file mode 100644 index 00000000..1db9f300 --- /dev/null +++ b/src/database/db_locations.c @@ -0,0 +1,444 @@ +#define _POSIX_C_SOURCE 200809L + +#include +#include +#include +#include +#include + +#include "core/logger.h" +#include "database/db_core.h" +#include "database/db_locations.h" +#include "utils/strings.h" + +#define LOCATION_SELECT_FIELDS \ + "l.uuid, l.parent_uuid, l.name, l.type, l.sort_order, l.metadata_json, " \ + "l.is_system, l.created_at, l.updated_at, " \ + "(SELECT count(*) FROM camera_locations c WHERE c.parent_uuid = l.uuid), " \ + "(SELECT count(*) FROM streams s WHERE s.location_uuid = l.uuid) " + +static bool has_non_whitespace(const char *value) { + if (!value) return false; + while (*value == ' ' || *value == '\t' || *value == '\n' || *value == '\r') { + value++; + } + return *value != '\0'; +} + +static bool valid_uuid_string(const char *uuid) { + return uuid && strlen(uuid) == CAMERA_UUID_STRING_SIZE - 1; +} + +static void copy_column(char *destination, size_t destination_size, + sqlite3_stmt *stmt, int column) { + const char *value = (const char *)sqlite3_column_text(stmt, column); + if (value) { + safe_strcpy(destination, value, destination_size, 0); + } else if (destination_size > 0) { + destination[0] = '\0'; + } +} + +static void populate_location(sqlite3_stmt *stmt, camera_location_t *location) { + memset(location, 0, sizeof(*location)); + copy_column(location->uuid, sizeof(location->uuid), stmt, 0); + copy_column(location->parent_uuid, sizeof(location->parent_uuid), stmt, 1); + copy_column(location->name, sizeof(location->name), stmt, 2); + copy_column(location->type, sizeof(location->type), stmt, 3); + location->sort_order = sqlite3_column_int(stmt, 4); + copy_column(location->metadata_json, sizeof(location->metadata_json), stmt, 5); + location->is_system = sqlite3_column_int(stmt, 6); + location->created_at = sqlite3_column_int64(stmt, 7); + location->updated_at = sqlite3_column_int64(stmt, 8); + location->direct_child_count = sqlite3_column_int(stmt, 9); + location->direct_camera_count = sqlite3_column_int(stmt, 10); +} + +static db_location_result_t prepare_error(sqlite3 *db, const char *operation) { + log_error("Failed to %s camera location: %s", operation, sqlite3_errmsg(db)); + return DB_LOCATION_ERROR; +} + +static db_location_result_t get_locked(sqlite3 *db, const char *where_clause, + const char *value, + camera_location_t *location) { + char sql[768]; + int written = snprintf(sql, sizeof(sql), + "SELECT " LOCATION_SELECT_FIELDS + "FROM camera_locations l WHERE %s LIMIT 1;", + where_clause); + if (written < 0 || (size_t)written >= sizeof(sql)) { + return DB_LOCATION_ERROR; + } + + sqlite3_stmt *stmt = NULL; + int rc = sqlite3_prepare_v2(db, sql, -1, &stmt, NULL); + if (rc != SQLITE_OK) return prepare_error(db, "read"); + if (value) sqlite3_bind_text(stmt, 1, value, -1, SQLITE_TRANSIENT); + + db_location_result_t result = DB_LOCATION_NOT_FOUND; + rc = sqlite3_step(stmt); + if (rc == SQLITE_ROW) { + populate_location(stmt, location); + result = DB_LOCATION_OK; + } else if (rc != SQLITE_DONE) { + result = prepare_error(db, "read"); + } + sqlite3_finalize(stmt); + return result; +} + +db_location_result_t db_location_get_unassigned(camera_location_t *location) { + sqlite3 *db = get_db_handle(); + pthread_mutex_t *mutex = get_db_mutex(); + if (!db || !location) return DB_LOCATION_INVALID; + + pthread_mutex_lock(mutex); + db_location_result_t result = + get_locked(db, "l.is_system = 1", NULL, location); + pthread_mutex_unlock(mutex); + return result; +} + +db_location_result_t db_location_get(const char *uuid, + camera_location_t *location) { + sqlite3 *db = get_db_handle(); + pthread_mutex_t *mutex = get_db_mutex(); + if (!db || !location || !valid_uuid_string(uuid)) return DB_LOCATION_INVALID; + + pthread_mutex_lock(mutex); + db_location_result_t result = get_locked(db, "l.uuid = ?", uuid, location); + pthread_mutex_unlock(mutex); + return result; +} + +int db_location_count(void) { + sqlite3 *db = get_db_handle(); + pthread_mutex_t *mutex = get_db_mutex(); + if (!db) return -1; + + pthread_mutex_lock(mutex); + sqlite3_stmt *stmt = NULL; + int rc = sqlite3_prepare_v2(db, "SELECT count(*) FROM camera_locations;", + -1, &stmt, NULL); + int count = -1; + if (rc == SQLITE_OK && sqlite3_step(stmt) == SQLITE_ROW) { + count = sqlite3_column_int(stmt, 0); + } else { + prepare_error(db, "count"); + } + if (stmt) sqlite3_finalize(stmt); + pthread_mutex_unlock(mutex); + return count; +} + +int db_location_list(camera_location_t *locations, int max_count) { + sqlite3 *db = get_db_handle(); + pthread_mutex_t *mutex = get_db_mutex(); + if (!db || !locations || max_count <= 0) return -1; + + const char *sql = + "SELECT " LOCATION_SELECT_FIELDS + "FROM camera_locations l " + "ORDER BY CASE WHEN l.parent_uuid IS NULL THEN 0 ELSE 1 END, " + "l.parent_uuid, l.sort_order, l.name COLLATE NOCASE;"; + + pthread_mutex_lock(mutex); + sqlite3_stmt *stmt = NULL; + int rc = sqlite3_prepare_v2(db, sql, -1, &stmt, NULL); + if (rc != SQLITE_OK) { + prepare_error(db, "list"); + pthread_mutex_unlock(mutex); + return -1; + } + + int count = 0; + while (count < max_count && (rc = sqlite3_step(stmt)) == SQLITE_ROW) { + populate_location(stmt, &locations[count++]); + } + if (rc != SQLITE_ROW && rc != SQLITE_DONE) { + prepare_error(db, "list"); + count = -1; + } + sqlite3_finalize(stmt); + pthread_mutex_unlock(mutex); + return count; +} + +static bool row_exists_locked(sqlite3 *db, const char *sql, const char *value) { + sqlite3_stmt *stmt = NULL; + if (sqlite3_prepare_v2(db, sql, -1, &stmt, NULL) != SQLITE_OK) return false; + sqlite3_bind_text(stmt, 1, value, -1, SQLITE_TRANSIENT); + bool exists = sqlite3_step(stmt) == SQLITE_ROW; + sqlite3_finalize(stmt); + return exists; +} + +db_location_result_t db_location_create(camera_location_t *location) { + sqlite3 *db = get_db_handle(); + pthread_mutex_t *mutex = get_db_mutex(); + if (!db || !location || !has_non_whitespace(location->name) || + (location->parent_uuid[0] && !valid_uuid_string(location->parent_uuid))) { + return DB_LOCATION_INVALID; + } + + const char *sql = + "INSERT INTO camera_locations " + "(uuid, parent_uuid, name, type, sort_order, metadata_json) VALUES (" + "lower(hex(randomblob(4)) || '-' || hex(randomblob(2)) || '-4' || " + "substr(hex(randomblob(2)), 2) || '-' || " + "substr('89ab', (abs(random()) % 4) + 1, 1) || " + "substr(hex(randomblob(2)), 2) || '-' || hex(randomblob(6))), " + "?, ?, ?, ?, ?);"; + + pthread_mutex_lock(mutex); + if (location->parent_uuid[0] && + !row_exists_locked(db, "SELECT 1 FROM camera_locations WHERE uuid = ?;", + location->parent_uuid)) { + pthread_mutex_unlock(mutex); + return DB_LOCATION_NOT_FOUND; + } + + sqlite3_stmt *stmt = NULL; + int rc = sqlite3_prepare_v2(db, sql, -1, &stmt, NULL); + if (rc != SQLITE_OK) { + db_location_result_t result = prepare_error(db, "create"); + pthread_mutex_unlock(mutex); + return result; + } + + if (location->parent_uuid[0]) { + sqlite3_bind_text(stmt, 1, location->parent_uuid, -1, SQLITE_STATIC); + } else { + sqlite3_bind_null(stmt, 1); + } + sqlite3_bind_text(stmt, 2, location->name, -1, SQLITE_STATIC); + sqlite3_bind_text(stmt, 3, + location->type[0] ? location->type : "area", + -1, SQLITE_STATIC); + sqlite3_bind_int(stmt, 4, location->sort_order); + sqlite3_bind_text(stmt, 5, + location->metadata_json[0] ? location->metadata_json : "{}", + -1, SQLITE_STATIC); + + rc = sqlite3_step(stmt); + sqlite3_finalize(stmt); + if (rc != SQLITE_DONE) { + db_location_result_t result = + (rc == SQLITE_CONSTRAINT) ? DB_LOCATION_CONFLICT + : prepare_error(db, "create"); + pthread_mutex_unlock(mutex); + return result; + } + + char row_id[32]; + snprintf(row_id, sizeof(row_id), "%lld", + (long long)sqlite3_last_insert_rowid(db)); + db_location_result_t result = get_locked(db, "l.id = ?", row_id, location); + pthread_mutex_unlock(mutex); + return result; +} + +db_location_result_t db_location_update(camera_location_t *location) { + sqlite3 *db = get_db_handle(); + pthread_mutex_t *mutex = get_db_mutex(); + if (!db || !location || !valid_uuid_string(location->uuid) || + !has_non_whitespace(location->name) || + (location->parent_uuid[0] && !valid_uuid_string(location->parent_uuid))) { + return DB_LOCATION_INVALID; + } + + pthread_mutex_lock(mutex); + camera_location_t existing; + db_location_result_t result = get_locked(db, "l.uuid = ?", location->uuid, + &existing); + if (result != DB_LOCATION_OK) { + pthread_mutex_unlock(mutex); + return result; + } + if (existing.is_system) { + pthread_mutex_unlock(mutex); + return DB_LOCATION_INVALID; + } + + if (location->parent_uuid[0]) { + if (!row_exists_locked(db, + "SELECT 1 FROM camera_locations WHERE uuid = ?;", + location->parent_uuid)) { + pthread_mutex_unlock(mutex); + return DB_LOCATION_NOT_FOUND; + } + + const char *cycle_sql = + "WITH RECURSIVE ancestors(uuid, parent_uuid) AS (" + "SELECT uuid, parent_uuid FROM camera_locations WHERE uuid = ? " + "UNION ALL " + "SELECT l.uuid, l.parent_uuid FROM camera_locations l " + "JOIN ancestors a ON l.uuid = a.parent_uuid" + ") SELECT 1 FROM ancestors WHERE uuid = ? LIMIT 1;"; + sqlite3_stmt *cycle_stmt = NULL; + int rc = sqlite3_prepare_v2(db, cycle_sql, -1, &cycle_stmt, NULL); + if (rc != SQLITE_OK) { + result = prepare_error(db, "validate"); + pthread_mutex_unlock(mutex); + return result; + } + sqlite3_bind_text(cycle_stmt, 1, location->parent_uuid, -1, + SQLITE_STATIC); + sqlite3_bind_text(cycle_stmt, 2, location->uuid, -1, SQLITE_STATIC); + bool creates_cycle = sqlite3_step(cycle_stmt) == SQLITE_ROW; + sqlite3_finalize(cycle_stmt); + if (creates_cycle) { + pthread_mutex_unlock(mutex); + return DB_LOCATION_CONFLICT; + } + } + + const char *update_sql = + "UPDATE camera_locations SET parent_uuid = ?, name = ?, type = ?, " + "sort_order = ?, metadata_json = ?, updated_at = strftime('%s', 'now') " + "WHERE uuid = ? AND is_system = 0;"; + sqlite3_stmt *stmt = NULL; + int rc = sqlite3_prepare_v2(db, update_sql, -1, &stmt, NULL); + if (rc != SQLITE_OK) { + result = prepare_error(db, "update"); + pthread_mutex_unlock(mutex); + return result; + } + if (location->parent_uuid[0]) { + sqlite3_bind_text(stmt, 1, location->parent_uuid, -1, SQLITE_STATIC); + } else { + sqlite3_bind_null(stmt, 1); + } + sqlite3_bind_text(stmt, 2, location->name, -1, SQLITE_STATIC); + sqlite3_bind_text(stmt, 3, + location->type[0] ? location->type : "area", + -1, SQLITE_STATIC); + sqlite3_bind_int(stmt, 4, location->sort_order); + sqlite3_bind_text(stmt, 5, + location->metadata_json[0] ? location->metadata_json : "{}", + -1, SQLITE_STATIC); + sqlite3_bind_text(stmt, 6, location->uuid, -1, SQLITE_STATIC); + rc = sqlite3_step(stmt); + sqlite3_finalize(stmt); + if (rc != SQLITE_DONE) { + result = (rc == SQLITE_CONSTRAINT) ? DB_LOCATION_CONFLICT + : prepare_error(db, "update"); + pthread_mutex_unlock(mutex); + return result; + } + + result = get_locked(db, "l.uuid = ?", location->uuid, location); + pthread_mutex_unlock(mutex); + return result; +} + +db_location_result_t db_location_delete(const char *uuid) { + sqlite3 *db = get_db_handle(); + pthread_mutex_t *mutex = get_db_mutex(); + if (!db || !valid_uuid_string(uuid)) return DB_LOCATION_INVALID; + + pthread_mutex_lock(mutex); + camera_location_t existing; + db_location_result_t result = get_locked(db, "l.uuid = ?", uuid, &existing); + if (result != DB_LOCATION_OK) { + pthread_mutex_unlock(mutex); + return result; + } + if (existing.is_system) { + pthread_mutex_unlock(mutex); + return DB_LOCATION_INVALID; + } + if (existing.direct_child_count > 0 || existing.direct_camera_count > 0) { + pthread_mutex_unlock(mutex); + return DB_LOCATION_CONFLICT; + } + + sqlite3_stmt *stmt = NULL; + int rc = sqlite3_prepare_v2(db, + "DELETE FROM camera_locations WHERE uuid = ? " + "AND is_system = 0;", + -1, &stmt, NULL); + if (rc == SQLITE_OK) { + sqlite3_bind_text(stmt, 1, uuid, -1, SQLITE_STATIC); + rc = sqlite3_step(stmt); + } + if (stmt) sqlite3_finalize(stmt); + if (rc != SQLITE_DONE) { + result = (rc == SQLITE_CONSTRAINT) ? DB_LOCATION_CONFLICT + : prepare_error(db, "delete"); + } else { + result = DB_LOCATION_OK; + } + pthread_mutex_unlock(mutex); + return result; +} + +db_location_result_t db_location_assign_camera(const char *camera_uuid, + const char *location_uuid) { + sqlite3 *db = get_db_handle(); + pthread_mutex_t *mutex = get_db_mutex(); + if (!db || !valid_uuid_string(camera_uuid) || + !valid_uuid_string(location_uuid)) { + return DB_LOCATION_INVALID; + } + + pthread_mutex_lock(mutex); + if (!row_exists_locked(db, "SELECT 1 FROM camera_locations WHERE uuid = ?;", + location_uuid) || + !row_exists_locked(db, "SELECT 1 FROM streams WHERE camera_uuid = ?;", + camera_uuid)) { + pthread_mutex_unlock(mutex); + return DB_LOCATION_NOT_FOUND; + } + + sqlite3_stmt *stmt = NULL; + int rc = sqlite3_prepare_v2(db, + "UPDATE streams SET location_uuid = ? " + "WHERE camera_uuid = ?;", + -1, &stmt, NULL); + if (rc == SQLITE_OK) { + sqlite3_bind_text(stmt, 1, location_uuid, -1, SQLITE_STATIC); + sqlite3_bind_text(stmt, 2, camera_uuid, -1, SQLITE_STATIC); + rc = sqlite3_step(stmt); + } + if (stmt) sqlite3_finalize(stmt); + db_location_result_t result = + (rc == SQLITE_DONE) ? DB_LOCATION_OK : prepare_error(db, "assign"); + pthread_mutex_unlock(mutex); + return result; +} + +db_location_result_t db_location_get_for_camera(const char *camera_uuid, + camera_location_t *location) { + sqlite3 *db = get_db_handle(); + pthread_mutex_t *mutex = get_db_mutex(); + if (!db || !location || !valid_uuid_string(camera_uuid)) { + return DB_LOCATION_INVALID; + } + + const char *sql = + "SELECT " LOCATION_SELECT_FIELDS + "FROM camera_locations l JOIN streams s ON s.location_uuid = l.uuid " + "WHERE s.camera_uuid = ? LIMIT 1;"; + pthread_mutex_lock(mutex); + sqlite3_stmt *stmt = NULL; + int rc = sqlite3_prepare_v2(db, sql, -1, &stmt, NULL); + if (rc != SQLITE_OK) { + db_location_result_t result = prepare_error(db, "read camera"); + pthread_mutex_unlock(mutex); + return result; + } + sqlite3_bind_text(stmt, 1, camera_uuid, -1, SQLITE_STATIC); + db_location_result_t result = DB_LOCATION_NOT_FOUND; + rc = sqlite3_step(stmt); + if (rc == SQLITE_ROW) { + populate_location(stmt, location); + result = DB_LOCATION_OK; + } else if (rc != SQLITE_DONE) { + result = prepare_error(db, "read camera"); + } + sqlite3_finalize(stmt); + pthread_mutex_unlock(mutex); + return result; +} diff --git a/src/database/db_streams.c b/src/database/db_streams.c index 6c6e2306..202dfa7b 100644 --- a/src/database/db_streams.c +++ b/src/database/db_streams.c @@ -279,12 +279,13 @@ uint64_t add_stream_config(const stream_config_t *stream) { "onvif_username, onvif_password, onvif_profile, onvif_port, " "record_on_schedule, recording_schedule, tags, admin_url, privacy_mode, motion_trigger_source, " "go2rtc_source_override, sub_stream_url, audio_voice_enhancement, detection_url, publish_url, " - "detection_record_on_schedule, detection_recording_schedule, camera_uuid) " + "detection_record_on_schedule, detection_recording_schedule, camera_uuid, location_uuid) " "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, " "lower(hex(randomblob(4)) || '-' || hex(randomblob(2)) || '-4' || " "substr(hex(randomblob(2)), 2) || '-' || " "substr('89ab', (abs(random()) % 4) + 1, 1) || " - "substr(hex(randomblob(2)), 2) || '-' || hex(randomblob(6))));"; + "substr(hex(randomblob(2)), 2) || '-' || hex(randomblob(6))), " + "(SELECT uuid FROM camera_locations WHERE is_system = 1 LIMIT 1));"; rc = sqlite3_prepare_v2(db, sql, -1, &stmt, NULL); if (rc != SQLITE_OK) { @@ -818,7 +819,7 @@ int get_stream_config_by_name(const char *name, stream_config_t *stream) { "onvif_username, onvif_password, onvif_profile, onvif_port, " "record_on_schedule, recording_schedule, tags, admin_url, privacy_mode, motion_trigger_source, " "go2rtc_source_override, sub_stream_url, audio_voice_enhancement, detection_url, publish_url, " - "detection_record_on_schedule, detection_recording_schedule, camera_uuid " + "detection_record_on_schedule, detection_recording_schedule, camera_uuid, location_uuid " "FROM streams WHERE name = ?;"; // Column index constants for readability @@ -837,7 +838,7 @@ int get_stream_config_by_name(const char *name, stream_config_t *stream) { COL_MOTION_TRIGGER_SOURCE, COL_GO2RTC_SOURCE_OVERRIDE, COL_SUB_STREAM_URL, COL_AUDIO_VOICE_ENHANCEMENT, COL_DETECTION_URL, COL_PUBLISH_URL, COL_DETECTION_RECORD_ON_SCHEDULE, COL_DETECTION_RECORDING_SCHEDULE, - COL_CAMERA_UUID + COL_CAMERA_UUID, COL_LOCATION_UUID }; rc = sqlite3_prepare_v2(db, sql, -1, &stmt, NULL); @@ -858,6 +859,12 @@ int get_stream_config_by_name(const char *name, stream_config_t *stream) { safe_strcpy(stream->camera_uuid, camera_uuid, sizeof(stream->camera_uuid), 0); } + const char *location_uuid = + (const char *)sqlite3_column_text(stmt, COL_LOCATION_UUID); + if (location_uuid) { + safe_strcpy(stream->location_uuid, location_uuid, + sizeof(stream->location_uuid), 0); + } // Basic stream settings const char *stream_name = (const char *)sqlite3_column_text(stmt, COL_NAME); @@ -1146,7 +1153,7 @@ int get_all_stream_configs(stream_config_t *streams, int max_count) { "onvif_username, onvif_password, onvif_profile, onvif_port, " "record_on_schedule, recording_schedule, tags, admin_url, privacy_mode, motion_trigger_source, " "go2rtc_source_override, sub_stream_url, audio_voice_enhancement, detection_url, publish_url, " - "detection_record_on_schedule, detection_recording_schedule, camera_uuid " + "detection_record_on_schedule, detection_recording_schedule, camera_uuid, location_uuid " "FROM streams ORDER BY name;"; // Column index constants (same as get_stream_config_by_name) @@ -1165,7 +1172,7 @@ int get_all_stream_configs(stream_config_t *streams, int max_count) { COL_MOTION_TRIGGER_SOURCE, COL_GO2RTC_SOURCE_OVERRIDE, COL_SUB_STREAM_URL, COL_AUDIO_VOICE_ENHANCEMENT, COL_DETECTION_URL, COL_PUBLISH_URL, COL_DETECTION_RECORD_ON_SCHEDULE, COL_DETECTION_RECORDING_SCHEDULE, - COL_CAMERA_UUID + COL_CAMERA_UUID, COL_LOCATION_UUID }; rc = sqlite3_prepare_v2(db, sql, -1, &stmt, NULL); @@ -1185,6 +1192,12 @@ int get_all_stream_configs(stream_config_t *streams, int max_count) { safe_strcpy(s->camera_uuid, camera_uuid, sizeof(s->camera_uuid), 0); } + const char *location_uuid = + (const char *)sqlite3_column_text(stmt, COL_LOCATION_UUID); + if (location_uuid) { + safe_strcpy(s->location_uuid, location_uuid, + sizeof(s->location_uuid), 0); + } // Basic settings const char *name = (const char *)sqlite3_column_text(stmt, COL_NAME); diff --git a/src/web/api_handlers_locations.c b/src/web/api_handlers_locations.c new file mode 100644 index 00000000..34fd21bd --- /dev/null +++ b/src/web/api_handlers_locations.c @@ -0,0 +1,382 @@ +#define _POSIX_C_SOURCE 200809L + +#include +#include +#include +#include + +#include "core/config.h" +#include "database/db_locations.h" +#include "utils/strings.h" +#include "web/api_handlers_locations.h" +#include "web/httpd_utils.h" +#include "web/request_response.h" + +static bool valid_uuid_string(const char *uuid) { + return uuid && strlen(uuid) == CAMERA_UUID_STRING_SIZE - 1; +} + +static cJSON *location_to_json(const camera_location_t *location) { + cJSON *object = cJSON_CreateObject(); + if (!object) return NULL; + + cJSON_AddStringToObject(object, "uuid", location->uuid); + if (location->parent_uuid[0]) { + cJSON_AddStringToObject(object, "parent_uuid", location->parent_uuid); + } else { + cJSON_AddNullToObject(object, "parent_uuid"); + } + cJSON_AddStringToObject(object, "name", location->name); + cJSON_AddStringToObject(object, "type", location->type); + cJSON_AddNumberToObject(object, "sort_order", location->sort_order); + cJSON_AddBoolToObject(object, "is_system", location->is_system != 0); + + cJSON *metadata = cJSON_Parse(location->metadata_json); + if (!metadata || !cJSON_IsObject(metadata)) { + cJSON_Delete(metadata); + metadata = cJSON_CreateObject(); + } + cJSON_AddItemToObject(object, "metadata", metadata); + cJSON_AddNumberToObject(object, "child_count", + location->direct_child_count); + cJSON_AddNumberToObject(object, "camera_count", + location->direct_camera_count); + cJSON_AddNumberToObject(object, "created_at", (double)location->created_at); + cJSON_AddNumberToObject(object, "updated_at", (double)location->updated_at); + return object; +} + +static void set_location_json(http_response_t *res, int status, + const camera_location_t *location) { + cJSON *object = location_to_json(location); + char *json = object ? cJSON_PrintUnformatted(object) : NULL; + cJSON_Delete(object); + if (!json) { + http_response_set_json_error(res, 500, "Failed to serialize location"); + return; + } + http_response_set_json(res, status, json); + free(json); +} + +static void set_db_error(http_response_t *res, db_location_result_t result, + const char *conflict_message) { + switch (result) { + case DB_LOCATION_NOT_FOUND: + http_response_set_json_error(res, 404, "Location or camera not found"); + break; + case DB_LOCATION_CONFLICT: + http_response_set_json_error(res, 409, conflict_message); + break; + case DB_LOCATION_INVALID: + http_response_set_json_error(res, 400, + "Invalid or immutable location"); + break; + default: + http_response_set_json_error(res, 500, + "Location database operation failed"); + break; + } +} + +static bool copy_json_string(cJSON *root, const char *field, char *destination, + size_t destination_size, bool required, + http_response_t *res) { + cJSON *item = cJSON_GetObjectItemCaseSensitive(root, field); + if (!item) { + if (required) { + http_response_set_json_error(res, 400, "Missing required field"); + return false; + } + return true; + } + if (!cJSON_IsString(item) || !item->valuestring || + item->valuestring[0] == '\0' || + strlen(item->valuestring) >= destination_size) { + http_response_set_json_error(res, 400, "Invalid string field"); + return false; + } + safe_strcpy(destination, item->valuestring, destination_size, 0); + return true; +} + +static bool apply_parent_field(cJSON *root, camera_location_t *location, + http_response_t *res) { + cJSON *parent = cJSON_GetObjectItemCaseSensitive(root, "parent_uuid"); + if (!parent) return true; + if (cJSON_IsNull(parent)) { + location->parent_uuid[0] = '\0'; + return true; + } + if (!cJSON_IsString(parent) || !valid_uuid_string(parent->valuestring)) { + http_response_set_json_error(res, 400, "Invalid parent_uuid"); + return false; + } + safe_strcpy(location->parent_uuid, parent->valuestring, + sizeof(location->parent_uuid), 0); + return true; +} + +static bool apply_metadata_field(cJSON *root, camera_location_t *location, + http_response_t *res) { + cJSON *metadata = cJSON_GetObjectItemCaseSensitive(root, "metadata"); + if (!metadata) return true; + if (!cJSON_IsObject(metadata)) { + http_response_set_json_error(res, 400, "metadata must be an object"); + return false; + } + char *serialized = cJSON_PrintUnformatted(metadata); + if (!serialized || strlen(serialized) >= sizeof(location->metadata_json)) { + free(serialized); + http_response_set_json_error(res, 400, "metadata is too large"); + return false; + } + safe_strcpy(location->metadata_json, serialized, + sizeof(location->metadata_json), 0); + free(serialized); + return true; +} + +static bool apply_location_fields(cJSON *root, camera_location_t *location, + bool creating, http_response_t *res) { + if (!cJSON_IsObject(root)) { + http_response_set_json_error(res, 400, "Request body must be an object"); + return false; + } + if (!copy_json_string(root, "name", location->name, + sizeof(location->name), creating, res) || + !copy_json_string(root, "type", location->type, + sizeof(location->type), false, res) || + !apply_parent_field(root, location, res) || + !apply_metadata_field(root, location, res)) { + return false; + } + + cJSON *sort_order = cJSON_GetObjectItemCaseSensitive(root, "sort_order"); + if (sort_order) { + if (!cJSON_IsNumber(sort_order)) { + http_response_set_json_error(res, 400, + "sort_order must be a number"); + return false; + } + location->sort_order = sort_order->valueint; + } + return true; +} + +static bool extract_location_uuid(const http_request_t *req, char *uuid, + size_t uuid_size, http_response_t *res) { + if (http_request_extract_path_param(req, "/api/locations/", uuid, + uuid_size) != 0) { + http_response_set_json_error(res, 400, "Invalid location path"); + return false; + } + char *slash = strchr(uuid, '/'); + if (slash) *slash = '\0'; + if (!valid_uuid_string(uuid)) { + http_response_set_json_error(res, 400, "Invalid location UUID"); + return false; + } + return true; +} + +void handle_get_locations(const http_request_t *req, http_response_t *res) { + if (!httpd_check_admin_privileges(req, res)) return; + + int total = db_location_count(); + if (total <= 0) { + http_response_set_json_error(res, 500, "Failed to count locations"); + return; + } + camera_location_t *locations = calloc((size_t)total, sizeof(*locations)); + if (!locations) { + http_response_set_json_error(res, 500, "Out of memory"); + return; + } + int count = db_location_list(locations, total); + if (count < 0) { + free(locations); + http_response_set_json_error(res, 500, "Failed to list locations"); + return; + } + + cJSON *root = cJSON_CreateObject(); + cJSON *items = cJSON_CreateArray(); + if (!root || !items) { + cJSON_Delete(root); + cJSON_Delete(items); + free(locations); + http_response_set_json_error(res, 500, "Failed to create response"); + return; + } + cJSON_AddItemToObject(root, "locations", items); + cJSON_AddNumberToObject(root, "count", count); + for (int i = 0; i < count; i++) { + cJSON *item = location_to_json(&locations[i]); + if (!item) { + cJSON_Delete(root); + free(locations); + http_response_set_json_error(res, 500, + "Failed to create location response"); + return; + } + cJSON_AddItemToArray(items, item); + if (locations[i].is_system) { + cJSON_AddStringToObject(root, "unassigned_uuid", locations[i].uuid); + } + } + free(locations); + + char *json = cJSON_PrintUnformatted(root); + cJSON_Delete(root); + if (!json) { + http_response_set_json_error(res, 500, "Failed to serialize locations"); + return; + } + http_response_set_json(res, 200, json); + free(json); +} + +void handle_post_location(const http_request_t *req, http_response_t *res) { + if (!httpd_check_admin_privileges(req, res)) return; + cJSON *body = httpd_parse_json_body(req); + if (!body) { + http_response_set_json_error(res, 400, "Invalid JSON request body"); + return; + } + + camera_location_t location; + memset(&location, 0, sizeof(location)); + safe_strcpy(location.type, "area", sizeof(location.type), 0); + safe_strcpy(location.metadata_json, "{}", sizeof(location.metadata_json), 0); + if (!apply_location_fields(body, &location, true, res)) { + cJSON_Delete(body); + return; + } + cJSON_Delete(body); + + db_location_result_t result = db_location_create(&location); + if (result != DB_LOCATION_OK) { + set_db_error(res, result, + "A location with that name already exists under this parent"); + return; + } + set_location_json(res, 201, &location); +} + +void handle_get_location(const http_request_t *req, http_response_t *res) { + if (!httpd_check_admin_privileges(req, res)) return; + char uuid[CAMERA_UUID_STRING_SIZE]; + if (!extract_location_uuid(req, uuid, sizeof(uuid), res)) return; + + camera_location_t location; + db_location_result_t result = db_location_get(uuid, &location); + if (result != DB_LOCATION_OK) { + set_db_error(res, result, "Location conflict"); + return; + } + set_location_json(res, 200, &location); +} + +void handle_put_location(const http_request_t *req, http_response_t *res) { + if (!httpd_check_admin_privileges(req, res)) return; + char uuid[CAMERA_UUID_STRING_SIZE]; + if (!extract_location_uuid(req, uuid, sizeof(uuid), res)) return; + + camera_location_t location; + db_location_result_t result = db_location_get(uuid, &location); + if (result != DB_LOCATION_OK) { + set_db_error(res, result, "Location conflict"); + return; + } + + cJSON *body = httpd_parse_json_body(req); + if (!body) { + http_response_set_json_error(res, 400, "Invalid JSON request body"); + return; + } + if (!apply_location_fields(body, &location, false, res)) { + cJSON_Delete(body); + return; + } + cJSON_Delete(body); + + result = db_location_update(&location); + if (result != DB_LOCATION_OK) { + set_db_error(res, result, + "Location move creates a cycle or sibling name conflicts"); + return; + } + set_location_json(res, 200, &location); +} + +void handle_delete_location(const http_request_t *req, http_response_t *res) { + if (!httpd_check_admin_privileges(req, res)) return; + char uuid[CAMERA_UUID_STRING_SIZE]; + if (!extract_location_uuid(req, uuid, sizeof(uuid), res)) return; + + db_location_result_t result = db_location_delete(uuid); + if (result != DB_LOCATION_OK) { + set_db_error(res, result, + "Location still contains cameras or child locations"); + return; + } + http_response_set_json(res, 200, "{\"success\":true}"); +} + +void handle_put_camera_location(const http_request_t *req, + http_response_t *res) { + if (!httpd_check_admin_privileges(req, res)) return; + + char camera_path[MAX_PATH_LENGTH]; + if (http_request_extract_path_param(req, "/api/cameras/", camera_path, + sizeof(camera_path)) != 0) { + http_response_set_json_error(res, 400, "Invalid camera path"); + return; + } + char *slash = strchr(camera_path, '/'); + if (slash) *slash = '\0'; + if (!valid_uuid_string(camera_path)) { + http_response_set_json_error(res, 400, "Invalid camera UUID"); + return; + } + + cJSON *body = httpd_parse_json_body(req); + cJSON *location_item = body ? + cJSON_GetObjectItemCaseSensitive(body, "location_uuid") : NULL; + if (!body || !cJSON_IsObject(body) || !cJSON_IsString(location_item) || + !valid_uuid_string(location_item->valuestring)) { + cJSON_Delete(body); + http_response_set_json_error(res, 400, + "A valid location_uuid is required"); + return; + } + char location_uuid[CAMERA_UUID_STRING_SIZE]; + safe_strcpy(location_uuid, location_item->valuestring, + sizeof(location_uuid), 0); + cJSON_Delete(body); + + db_location_result_t result = + db_location_assign_camera(camera_path, location_uuid); + if (result != DB_LOCATION_OK) { + set_db_error(res, result, "Camera location assignment conflict"); + return; + } + + cJSON *response = cJSON_CreateObject(); + if (!response) { + http_response_set_json_error(res, 500, "Failed to create response"); + return; + } + cJSON_AddBoolToObject(response, "success", true); + cJSON_AddStringToObject(response, "camera_uuid", camera_path); + cJSON_AddStringToObject(response, "location_uuid", location_uuid); + char *json = cJSON_PrintUnformatted(response); + cJSON_Delete(response); + if (!json) { + http_response_set_json_error(res, 500, "Failed to serialize response"); + return; + } + http_response_set_json(res, 200, json); + free(json); +} diff --git a/src/web/api_handlers_streams_get.c b/src/web/api_handlers_streams_get.c index 9a894ff2..cda07af7 100644 --- a/src/web/api_handlers_streams_get.c +++ b/src/web/api_handlers_streams_get.c @@ -224,6 +224,7 @@ void handle_get_streams(const http_request_t *req, http_response_t *res) { // Add stream properties cJSON_AddStringToObject(stream_obj, "camera_uuid", db_streams[i].camera_uuid); + cJSON_AddStringToObject(stream_obj, "location_uuid", db_streams[i].location_uuid); cJSON_AddStringToObject(stream_obj, "name", db_streams[i].name); cJSON_AddStringToObject(stream_obj, "url", safe_url); cJSON_AddBoolToObject(stream_obj, "enabled", db_streams[i].enabled); @@ -429,6 +430,7 @@ void handle_get_stream(const http_request_t *req, http_response_t *res) { // Add stream properties cJSON_AddStringToObject(stream_obj, "camera_uuid", config.camera_uuid); + cJSON_AddStringToObject(stream_obj, "location_uuid", config.location_uuid); cJSON_AddStringToObject(stream_obj, "name", config.name); cJSON_AddStringToObject(stream_obj, "url", safe_url); cJSON_AddBoolToObject(stream_obj, "enabled", config.enabled); @@ -631,6 +633,7 @@ void handle_get_stream_full(const http_request_t *req, http_response_t *res) { expose_sensitive_config); cJSON_AddStringToObject(stream_obj, "camera_uuid", config.camera_uuid); + cJSON_AddStringToObject(stream_obj, "location_uuid", config.location_uuid); cJSON_AddStringToObject(stream_obj, "name", config.name); cJSON_AddStringToObject(stream_obj, "url", safe_url_full); cJSON_AddBoolToObject(stream_obj, "enabled", config.enabled); diff --git a/src/web/api_handlers_streams_modify.c b/src/web/api_handlers_streams_modify.c index 4faa9dd9..6512c4b4 100644 --- a/src/web/api_handlers_streams_modify.c +++ b/src/web/api_handlers_streams_modify.c @@ -1057,6 +1057,8 @@ void handle_post_stream(const http_request_t *req, http_response_t *res) { } safe_strcpy(config.camera_uuid, persisted_config->camera_uuid, sizeof(config.camera_uuid), 0); + safe_strcpy(config.location_uuid, persisted_config->location_uuid, + sizeof(config.location_uuid), 0); free(persisted_config); // Create stream in memory from the database configuration @@ -1123,6 +1125,7 @@ void handle_post_stream(const http_request_t *req, http_response_t *res) { cJSON_AddBoolToObject(success, "success", true); cJSON_AddStringToObject(success, "camera_uuid", config.camera_uuid); + cJSON_AddStringToObject(success, "location_uuid", config.location_uuid); // Add ONVIF detection result if applicable if (onvif_test_performed) { diff --git a/src/web/api_handlers_system.c b/src/web/api_handlers_system.c index 2e90d226..567be5d4 100644 --- a/src/web/api_handlers_system.c +++ b/src/web/api_handlers_system.c @@ -1450,6 +1450,8 @@ void handle_post_system_backup(const http_request_t *req, http_response_t *res) cJSON_AddStringToObject(stream, "camera_uuid", g_config.streams[i].camera_uuid); + cJSON_AddStringToObject(stream, "location_uuid", + g_config.streams[i].location_uuid); cJSON_AddStringToObject(stream, "name", g_config.streams[i].name); cJSON_AddStringToObject(stream, "url", g_config.streams[i].url); cJSON_AddBoolToObject(stream, "enabled", g_config.streams[i].enabled); diff --git a/src/web/libuv_api_handlers.c b/src/web/libuv_api_handlers.c index a65af5bc..80b4bae6 100644 --- a/src/web/libuv_api_handlers.c +++ b/src/web/libuv_api_handlers.c @@ -36,6 +36,7 @@ #include "web/api_handlers_metrics.h" #include "web/api_handlers_motion.h" #include "web/api_handlers_recording_control.h" +#include "web/api_handlers_locations.h" #define LOG_COMPONENT "HTTP" #include "core/logger.h" #include "core/config.h" @@ -82,6 +83,15 @@ int register_all_libuv_handlers(http_server_handle_t server) { http_server_register_handler(server, "/api/streams", "POST", handle_post_stream); http_server_register_handler(server, "/api/streams/test", "POST", handle_test_stream); + // Fleet location hierarchy (UUID-based, admin-only until scoped auth lands) + http_server_register_handler(server, "/api/locations", "GET", handle_get_locations); + http_server_register_handler(server, "/api/locations", "POST", handle_post_location); + http_server_register_handler(server, "/api/locations/#", "GET", handle_get_location); + http_server_register_handler(server, "/api/locations/#", "PUT", handle_put_location); + http_server_register_handler(server, "/api/locations/#", "DELETE", handle_delete_location); + http_server_register_handler(server, "/api/cameras/#/location", "PUT", + handle_put_camera_location); + // Stream-specific routes (must come before /api/streams/# wildcard) http_server_register_handler(server, "/api/streams/#/recording", "GET", handle_get_stream_recording); diff --git a/tests/unit/CMakeLists.txt b/tests/unit/CMakeLists.txt index 3124065c..0fd22d3f 100644 --- a/tests/unit/CMakeLists.txt +++ b/tests/unit/CMakeLists.txt @@ -136,6 +136,8 @@ add_layer2_test_with_curl(test_detection_system_onvif) add_layer2_test_with_ffmpeg(test_api_detection) add_layer2_test_with_curl(test_url_utils) add_layer2_test(test_db_streams) +add_layer2_test(test_db_locations) +add_layer2_test(test_api_handlers_locations) add_layer2_test(test_db_recordings_extended) add_layer2_test(test_storage_manager_retention) add_layer2_test(test_db_detections) diff --git a/tests/unit/test_api_handlers_locations.c b/tests/unit/test_api_handlers_locations.c new file mode 100644 index 00000000..d132730b --- /dev/null +++ b/tests/unit/test_api_handlers_locations.c @@ -0,0 +1,303 @@ +/** + * @file test_api_handlers_locations.c + * @brief Layer 2 tests for UUID-based fleet location HTTP handlers. + */ + +#define _POSIX_C_SOURCE 200809L + +#include +#include +#include +#include +#include +#include + +#include "unity.h" +#include "core/config.h" +#include "database/db_core.h" +#include "database/db_locations.h" +#include "database/db_streams.h" +#include "utils/strings.h" +#include "web/api_handlers_locations.h" +#include "web/request_response.h" + +#define TEST_DB_PATH "/tmp/lightnvr_unit_location_handlers_test.db" + +extern config_t g_config; + +static cJSON *parse_response(const http_response_t *response) { + TEST_ASSERT_NOT_NULL(response->body); + cJSON *json = cJSON_Parse((const char *)response->body); + TEST_ASSERT_NOT_NULL(json); + return json; +} + +static void init_request(http_request_t *request, const char *path, + const char *body) { + http_request_init(request); + safe_strcpy(request->path, path, sizeof(request->path), 0); + if (body) { + request->body = (void *)body; + request->body_len = strlen(body); + } +} + +static camera_location_t create_location(const char *name, + const char *parent_uuid) { + camera_location_t location; + memset(&location, 0, sizeof(location)); + safe_strcpy(location.name, name, sizeof(location.name), 0); + safe_strcpy(location.type, "area", sizeof(location.type), 0); + safe_strcpy(location.metadata_json, "{}", sizeof(location.metadata_json), 0); + if (parent_uuid) { + safe_strcpy(location.parent_uuid, parent_uuid, + sizeof(location.parent_uuid), 0); + } + TEST_ASSERT_EQUAL_INT(DB_LOCATION_OK, db_location_create(&location)); + return location; +} + +static stream_config_t create_stream(const char *name) { + stream_config_t stream; + memset(&stream, 0, sizeof(stream)); + safe_strcpy(stream.name, name, sizeof(stream.name), 0); + safe_strcpy(stream.url, "rtsp://camera/stream", sizeof(stream.url), 0); + safe_strcpy(stream.codec, "h264", sizeof(stream.codec), 0); + stream.enabled = true; + stream.streaming_enabled = true; + stream.width = 1920; + stream.height = 1080; + stream.fps = 25; + TEST_ASSERT_NOT_EQUAL(0, add_stream_config(&stream)); + TEST_ASSERT_EQUAL_INT(0, get_stream_config_by_name(name, &stream)); + return stream; +} + +void setUp(void) { + g_config.web_auth_enabled = false; + sqlite3 *db = get_db_handle(); + sqlite3_exec(db, "DELETE FROM streams;", NULL, NULL, NULL); + do { + sqlite3_exec(db, + "DELETE FROM camera_locations WHERE is_system = 0 " + "AND NOT EXISTS (SELECT 1 FROM camera_locations child " + "WHERE child.parent_uuid = camera_locations.uuid);", + NULL, NULL, NULL); + } while (sqlite3_changes(db) > 0); +} + +void tearDown(void) {} + +void test_get_locations_returns_seeded_unassigned_root(void) { + http_request_t request; + http_response_t response; + init_request(&request, "/api/locations", NULL); + http_response_init(&response); + + handle_get_locations(&request, &response); + TEST_ASSERT_EQUAL_INT(200, response.status_code); + + cJSON *root = parse_response(&response); + cJSON *locations = cJSON_GetObjectItemCaseSensitive(root, "locations"); + cJSON *unassigned = + cJSON_GetObjectItemCaseSensitive(root, "unassigned_uuid"); + TEST_ASSERT_TRUE(cJSON_IsArray(locations)); + TEST_ASSERT_EQUAL_INT(1, cJSON_GetArraySize(locations)); + TEST_ASSERT_TRUE(cJSON_IsString(unassigned)); + cJSON *item = cJSON_GetArrayItem(locations, 0); + TEST_ASSERT_EQUAL_STRING( + "Unassigned", + cJSON_GetObjectItemCaseSensitive(item, "name")->valuestring); + TEST_ASSERT_TRUE(cJSON_IsTrue( + cJSON_GetObjectItemCaseSensitive(item, "is_system"))); + + cJSON_Delete(root); + http_response_free(&response); +} + +void test_location_crud_preserves_hierarchy_and_metadata(void) { + http_request_t request; + http_response_t response; + init_request(&request, "/api/locations", + "{\"name\":\"SJC\",\"type\":\"site\"}"); + http_response_init(&response); + handle_post_location(&request, &response); + TEST_ASSERT_EQUAL_INT(201, response.status_code); + cJSON *site_json = parse_response(&response); + const char *site_value = + cJSON_GetObjectItemCaseSensitive(site_json, "uuid")->valuestring; + char site_uuid[CAMERA_UUID_STRING_SIZE]; + safe_strcpy(site_uuid, site_value, sizeof(site_uuid), 0); + cJSON_Delete(site_json); + http_response_free(&response); + + char body[1024]; + snprintf(body, sizeof(body), + "{\"name\":\"Building C\",\"type\":\"building\"," + "\"parent_uuid\":\"%s\",\"sort_order\":30," + "\"metadata\":{\"address\":\"100 Main St\"}}", + site_uuid); + init_request(&request, "/api/locations", body); + http_response_init(&response); + handle_post_location(&request, &response); + TEST_ASSERT_EQUAL_INT(201, response.status_code); + cJSON *building_json = parse_response(&response); + char building_uuid[CAMERA_UUID_STRING_SIZE]; + safe_strcpy(building_uuid, + cJSON_GetObjectItemCaseSensitive(building_json, "uuid")->valuestring, + sizeof(building_uuid), 0); + TEST_ASSERT_EQUAL_STRING( + site_uuid, + cJSON_GetObjectItemCaseSensitive(building_json, + "parent_uuid")->valuestring); + cJSON *metadata = + cJSON_GetObjectItemCaseSensitive(building_json, "metadata"); + TEST_ASSERT_EQUAL_STRING( + "100 Main St", + cJSON_GetObjectItemCaseSensitive(metadata, "address")->valuestring); + cJSON_Delete(building_json); + http_response_free(&response); + + char path[MAX_PATH_LENGTH]; + snprintf(path, sizeof(path), "/api/locations/%s", building_uuid); + init_request(&request, path, + "{\"name\":\"Building Charlie\",\"sort_order\":10}"); + http_response_init(&response); + handle_put_location(&request, &response); + TEST_ASSERT_EQUAL_INT(200, response.status_code); + building_json = parse_response(&response); + TEST_ASSERT_EQUAL_STRING( + "Building Charlie", + cJSON_GetObjectItemCaseSensitive(building_json, "name")->valuestring); + TEST_ASSERT_EQUAL_INT( + 10, cJSON_GetObjectItemCaseSensitive(building_json, + "sort_order")->valueint); + cJSON_Delete(building_json); + http_response_free(&response); + + snprintf(path, sizeof(path), "/api/locations/%s", site_uuid); + init_request(&request, path, NULL); + http_response_init(&response); + handle_delete_location(&request, &response); + TEST_ASSERT_EQUAL_INT(409, response.status_code); + http_response_free(&response); + + snprintf(path, sizeof(path), "/api/locations/%s", building_uuid); + init_request(&request, path, NULL); + http_response_init(&response); + handle_delete_location(&request, &response); + TEST_ASSERT_EQUAL_INT(200, response.status_code); + http_response_free(&response); + + snprintf(path, sizeof(path), "/api/locations/%s", site_uuid); + init_request(&request, path, NULL); + http_response_init(&response); + handle_delete_location(&request, &response); + TEST_ASSERT_EQUAL_INT(200, response.status_code); + http_response_free(&response); +} + +void test_put_camera_location_assigns_by_stable_uuid(void) { + camera_location_t location = create_location("North Lobby", NULL); + stream_config_t stream = create_stream("lobby_camera"); + + char path[MAX_PATH_LENGTH]; + char body[128]; + snprintf(path, sizeof(path), "/api/cameras/%s/location", + stream.camera_uuid); + snprintf(body, sizeof(body), "{\"location_uuid\":\"%s\"}", + location.uuid); + http_request_t request; + http_response_t response; + init_request(&request, path, body); + http_response_init(&response); + handle_put_camera_location(&request, &response); + TEST_ASSERT_EQUAL_INT(200, response.status_code); + + cJSON *json = parse_response(&response); + TEST_ASSERT_EQUAL_STRING( + stream.camera_uuid, + cJSON_GetObjectItemCaseSensitive(json, "camera_uuid")->valuestring); + TEST_ASSERT_EQUAL_STRING( + location.uuid, + cJSON_GetObjectItemCaseSensitive(json, "location_uuid")->valuestring); + cJSON_Delete(json); + http_response_free(&response); + + TEST_ASSERT_EQUAL_INT(0, + get_stream_config_by_uuid(stream.camera_uuid, &stream)); + TEST_ASSERT_EQUAL_STRING(location.uuid, stream.location_uuid); + TEST_ASSERT_EQUAL_INT(DB_LOCATION_CONFLICT, + db_location_delete(location.uuid)); +} + +void test_location_cycle_returns_conflict(void) { + camera_location_t parent = create_location("Parent", NULL); + camera_location_t child = create_location("Child", parent.uuid); + + char path[MAX_PATH_LENGTH]; + char body[128]; + snprintf(path, sizeof(path), "/api/locations/%s", parent.uuid); + snprintf(body, sizeof(body), "{\"parent_uuid\":\"%s\"}", child.uuid); + http_request_t request; + http_response_t response; + init_request(&request, path, body); + http_response_init(&response); + handle_put_location(&request, &response); + TEST_ASSERT_EQUAL_INT(409, response.status_code); + http_response_free(&response); +} + +void test_location_handlers_require_admin_when_auth_enabled(void) { + g_config.web_auth_enabled = true; + http_request_t request; + http_response_t response; + init_request(&request, "/api/locations", NULL); + http_response_init(&response); + + handle_get_locations(&request, &response); + TEST_ASSERT_EQUAL_INT(401, response.status_code); + http_response_free(&response); +} + +void test_location_handlers_reject_invalid_payloads(void) { + http_request_t request; + http_response_t response; + init_request(&request, "/api/locations", + "{\"name\":\"Bad Metadata\",\"metadata\":[]}"); + http_response_init(&response); + handle_post_location(&request, &response); + TEST_ASSERT_EQUAL_INT(400, response.status_code); + http_response_free(&response); + + init_request(&request, "/api/cameras/not-a-uuid/location", + "{\"location_uuid\":\"also-bad\"}"); + http_response_init(&response); + handle_put_camera_location(&request, &response); + TEST_ASSERT_EQUAL_INT(400, response.status_code); + http_response_free(&response); +} + +int main(void) { + load_default_config(&g_config); + unlink(TEST_DB_PATH); + if (init_database(TEST_DB_PATH) != 0) { + fprintf(stderr, "FATAL: init_database failed\n"); + return 1; + } + + UNITY_BEGIN(); + RUN_TEST(test_get_locations_returns_seeded_unassigned_root); + RUN_TEST(test_location_crud_preserves_hierarchy_and_metadata); + RUN_TEST(test_put_camera_location_assigns_by_stable_uuid); + RUN_TEST(test_location_cycle_returns_conflict); + RUN_TEST(test_location_handlers_require_admin_when_auth_enabled); + RUN_TEST(test_location_handlers_reject_invalid_payloads); + int result = UNITY_END(); + + shutdown_database(); + unlink(TEST_DB_PATH); + free(g_config.streams); + g_config.streams = NULL; + return result; +} diff --git a/tests/unit/test_api_handlers_system.c b/tests/unit/test_api_handlers_system.c index ebd0d816..2b7abcd7 100644 --- a/tests/unit/test_api_handlers_system.c +++ b/tests/unit/test_api_handlers_system.c @@ -387,6 +387,11 @@ void test_handle_get_streams_includes_audio_voice_enhancement(void) { TEST_ASSERT_TRUE(cJSON_IsString(camera_uuid)); TEST_ASSERT_EQUAL_UINT(CAMERA_UUID_STRING_SIZE - 1, strlen(camera_uuid->valuestring)); + cJSON *location_uuid = + cJSON_GetObjectItemCaseSensitive(stream, "location_uuid"); + TEST_ASSERT_TRUE(cJSON_IsString(location_uuid)); + TEST_ASSERT_EQUAL_UINT(CAMERA_UUID_STRING_SIZE - 1, + strlen(location_uuid->valuestring)); cJSON *avoe = cJSON_GetObjectItemCaseSensitive(stream, "audio_voice_enhancement"); TEST_ASSERT_NOT_NULL(avoe); TEST_ASSERT_TRUE(cJSON_IsBool(avoe)); @@ -430,6 +435,10 @@ void test_handle_get_stream_by_name_includes_audio_voice_enhancement(void) { cJSON_GetObjectItemCaseSensitive(root, "camera_uuid"); TEST_ASSERT_TRUE(cJSON_IsString(camera_uuid)); TEST_ASSERT_EQUAL_STRING(s.camera_uuid, camera_uuid->valuestring); + cJSON *location_uuid = + cJSON_GetObjectItemCaseSensitive(root, "location_uuid"); + TEST_ASSERT_TRUE(cJSON_IsString(location_uuid)); + TEST_ASSERT_EQUAL_STRING(s.location_uuid, location_uuid->valuestring); cJSON *avoe = cJSON_GetObjectItemCaseSensitive(root, "audio_voice_enhancement"); TEST_ASSERT_NOT_NULL(avoe); TEST_ASSERT_TRUE(cJSON_IsTrue(avoe)); @@ -456,6 +465,10 @@ void test_handle_get_stream_by_name_includes_audio_voice_enhancement(void) { cJSON_GetObjectItemCaseSensitive(stream_obj, "camera_uuid"); TEST_ASSERT_TRUE(cJSON_IsString(camera_uuid)); TEST_ASSERT_EQUAL_STRING(s.camera_uuid, camera_uuid->valuestring); + cJSON *location_uuid = + cJSON_GetObjectItemCaseSensitive(stream_obj, "location_uuid"); + TEST_ASSERT_TRUE(cJSON_IsString(location_uuid)); + TEST_ASSERT_EQUAL_STRING(s.location_uuid, location_uuid->valuestring); cJSON *avoe = cJSON_GetObjectItemCaseSensitive(stream_obj, "audio_voice_enhancement"); TEST_ASSERT_NOT_NULL(avoe); TEST_ASSERT_TRUE(cJSON_IsTrue(avoe)); diff --git a/tests/unit/test_db_locations.c b/tests/unit/test_db_locations.c new file mode 100644 index 00000000..a6b4bcc2 --- /dev/null +++ b/tests/unit/test_db_locations.c @@ -0,0 +1,224 @@ +/** + * @file test_db_locations.c + * @brief Layer 2 integration tests for camera location hierarchy operations. + */ + +#define _POSIX_C_SOURCE 200809L + +#include +#include +#include +#include + +#include "unity.h" +#include "database/db_core.h" +#include "database/db_locations.h" +#include "database/db_streams.h" +#include "utils/strings.h" + +#define TEST_DB_PATH "/tmp/lightnvr_unit_locations_test.db" + +static camera_location_t make_location(const char *name, const char *type, + const char *parent_uuid) { + camera_location_t location; + memset(&location, 0, sizeof(location)); + safe_strcpy(location.name, name, sizeof(location.name), 0); + safe_strcpy(location.type, type, sizeof(location.type), 0); + safe_strcpy(location.metadata_json, "{}", sizeof(location.metadata_json), 0); + if (parent_uuid) { + safe_strcpy(location.parent_uuid, parent_uuid, + sizeof(location.parent_uuid), 0); + } + return location; +} + +static stream_config_t make_stream(const char *name) { + stream_config_t stream; + memset(&stream, 0, sizeof(stream)); + safe_strcpy(stream.name, name, sizeof(stream.name), 0); + safe_strcpy(stream.url, "rtsp://camera/stream", sizeof(stream.url), 0); + safe_strcpy(stream.codec, "h264", sizeof(stream.codec), 0); + stream.enabled = true; + stream.streaming_enabled = true; + stream.width = 1920; + stream.height = 1080; + stream.fps = 25; + return stream; +} + +void setUp(void) { + sqlite3 *db = get_db_handle(); + sqlite3_exec(db, "DELETE FROM streams;", NULL, NULL, NULL); + do { + sqlite3_exec(db, + "DELETE FROM camera_locations WHERE is_system = 0 " + "AND NOT EXISTS (SELECT 1 FROM camera_locations child " + "WHERE child.parent_uuid = camera_locations.uuid);", + NULL, NULL, NULL); + } while (sqlite3_changes(db) > 0); +} + +void tearDown(void) {} + +void test_unassigned_root_is_seeded_and_immutable(void) { + camera_location_t root; + TEST_ASSERT_EQUAL_INT(DB_LOCATION_OK, db_location_get_unassigned(&root)); + TEST_ASSERT_EQUAL_STRING("Unassigned", root.name); + TEST_ASSERT_EQUAL_STRING("system", root.type); + TEST_ASSERT_TRUE(root.is_system); + TEST_ASSERT_EQUAL_STRING("", root.parent_uuid); + + safe_strcpy(root.name, "Renamed", sizeof(root.name), 0); + TEST_ASSERT_EQUAL_INT(DB_LOCATION_INVALID, db_location_update(&root)); + TEST_ASSERT_EQUAL_INT(DB_LOCATION_INVALID, db_location_delete(root.uuid)); +} + +void test_new_camera_defaults_to_unassigned(void) { + camera_location_t root; + TEST_ASSERT_EQUAL_INT(DB_LOCATION_OK, db_location_get_unassigned(&root)); + + stream_config_t stream = make_stream("default_location"); + TEST_ASSERT_NOT_EQUAL(0, add_stream_config(&stream)); + + stream_config_t persisted; + TEST_ASSERT_EQUAL_INT(0, + get_stream_config_by_name("default_location", + &persisted)); + TEST_ASSERT_EQUAL_STRING(root.uuid, persisted.location_uuid); + + camera_location_t camera_location; + TEST_ASSERT_EQUAL_INT(DB_LOCATION_OK, + db_location_get_for_camera(persisted.camera_uuid, + &camera_location)); + TEST_ASSERT_EQUAL_STRING(root.uuid, camera_location.uuid); +} + +void test_create_nested_locations_and_report_direct_counts(void) { + camera_location_t site = make_location("SJC", "site", NULL); + TEST_ASSERT_EQUAL_INT(DB_LOCATION_OK, db_location_create(&site)); + + camera_location_t building = + make_location("Building C", "building", site.uuid); + TEST_ASSERT_EQUAL_INT(DB_LOCATION_OK, db_location_create(&building)); + + camera_location_t floor = make_location("Floor 2", "floor", building.uuid); + floor.sort_order = 20; + safe_strcpy(floor.metadata_json, "{\"map\":\"floor-2.svg\"}", + sizeof(floor.metadata_json), 0); + TEST_ASSERT_EQUAL_INT(DB_LOCATION_OK, db_location_create(&floor)); + + TEST_ASSERT_EQUAL_INT(DB_LOCATION_OK, db_location_get(site.uuid, &site)); + TEST_ASSERT_EQUAL_INT(1, site.direct_child_count); + TEST_ASSERT_EQUAL_INT(0, site.direct_camera_count); + TEST_ASSERT_EQUAL_INT(DB_LOCATION_OK, + db_location_get(building.uuid, &building)); + TEST_ASSERT_EQUAL_INT(1, building.direct_child_count); + TEST_ASSERT_EQUAL_STRING(site.uuid, building.parent_uuid); + + camera_location_t locations[8]; + int count = db_location_list(locations, 8); + TEST_ASSERT_EQUAL_INT(4, count); +} + +void test_sibling_names_are_case_insensitively_unique(void) { + camera_location_t site = make_location("Campus", "site", NULL); + TEST_ASSERT_EQUAL_INT(DB_LOCATION_OK, db_location_create(&site)); + + camera_location_t first = make_location("North", "area", site.uuid); + camera_location_t duplicate = make_location("north", "area", site.uuid); + TEST_ASSERT_EQUAL_INT(DB_LOCATION_OK, db_location_create(&first)); + TEST_ASSERT_EQUAL_INT(DB_LOCATION_CONFLICT, + db_location_create(&duplicate)); + + camera_location_t other_root = make_location("north", "site", NULL); + TEST_ASSERT_EQUAL_INT(DB_LOCATION_OK, db_location_create(&other_root)); +} + +void test_move_rejects_cycles_and_allows_subtree_move(void) { + camera_location_t site = make_location("Site", "site", NULL); + TEST_ASSERT_EQUAL_INT(DB_LOCATION_OK, db_location_create(&site)); + camera_location_t building = + make_location("Building", "building", site.uuid); + TEST_ASSERT_EQUAL_INT(DB_LOCATION_OK, db_location_create(&building)); + camera_location_t area = make_location("Area", "area", building.uuid); + TEST_ASSERT_EQUAL_INT(DB_LOCATION_OK, db_location_create(&area)); + + safe_strcpy(site.parent_uuid, area.uuid, sizeof(site.parent_uuid), 0); + TEST_ASSERT_EQUAL_INT(DB_LOCATION_CONFLICT, db_location_update(&site)); + + camera_location_t second_site = make_location("Second Site", "site", NULL); + TEST_ASSERT_EQUAL_INT(DB_LOCATION_OK, db_location_create(&second_site)); + safe_strcpy(building.parent_uuid, second_site.uuid, + sizeof(building.parent_uuid), 0); + TEST_ASSERT_EQUAL_INT(DB_LOCATION_OK, db_location_update(&building)); + TEST_ASSERT_EQUAL_STRING(second_site.uuid, building.parent_uuid); + + TEST_ASSERT_EQUAL_INT(DB_LOCATION_OK, db_location_get(area.uuid, &area)); + TEST_ASSERT_EQUAL_STRING(building.uuid, area.parent_uuid); +} + +void test_delete_requires_empty_location_and_camera_can_be_reassigned(void) { + camera_location_t root; + TEST_ASSERT_EQUAL_INT(DB_LOCATION_OK, db_location_get_unassigned(&root)); + camera_location_t site = make_location("Site", "site", NULL); + TEST_ASSERT_EQUAL_INT(DB_LOCATION_OK, db_location_create(&site)); + camera_location_t area = make_location("Area", "area", site.uuid); + TEST_ASSERT_EQUAL_INT(DB_LOCATION_OK, db_location_create(&area)); + + TEST_ASSERT_EQUAL_INT(DB_LOCATION_CONFLICT, db_location_delete(site.uuid)); + + stream_config_t stream = make_stream("assigned_camera"); + TEST_ASSERT_NOT_EQUAL(0, add_stream_config(&stream)); + TEST_ASSERT_EQUAL_INT(0, + get_stream_config_by_name("assigned_camera", &stream)); + TEST_ASSERT_EQUAL_INT(DB_LOCATION_OK, + db_location_assign_camera(stream.camera_uuid, + area.uuid)); + TEST_ASSERT_EQUAL_INT(DB_LOCATION_CONFLICT, db_location_delete(area.uuid)); + + TEST_ASSERT_EQUAL_INT(DB_LOCATION_OK, + db_location_assign_camera(stream.camera_uuid, + root.uuid)); + TEST_ASSERT_EQUAL_INT(DB_LOCATION_OK, db_location_delete(area.uuid)); + TEST_ASSERT_EQUAL_INT(DB_LOCATION_OK, db_location_delete(site.uuid)); + TEST_ASSERT_EQUAL_INT(DB_LOCATION_NOT_FOUND, + db_location_get(area.uuid, &area)); +} + +void test_assignment_rejects_unknown_camera_or_location(void) { + camera_location_t root; + TEST_ASSERT_EQUAL_INT(DB_LOCATION_OK, db_location_get_unassigned(&root)); + TEST_ASSERT_EQUAL_INT( + DB_LOCATION_NOT_FOUND, + db_location_assign_camera("11111111-1111-4111-8111-111111111111", + root.uuid)); + + stream_config_t stream = make_stream("known_camera"); + TEST_ASSERT_NOT_EQUAL(0, add_stream_config(&stream)); + TEST_ASSERT_EQUAL_INT(0, + get_stream_config_by_name("known_camera", &stream)); + TEST_ASSERT_EQUAL_INT( + DB_LOCATION_NOT_FOUND, + db_location_assign_camera(stream.camera_uuid, + "22222222-2222-4222-8222-222222222222")); +} + +int main(void) { + unlink(TEST_DB_PATH); + if (init_database(TEST_DB_PATH) != 0) { + fprintf(stderr, "FATAL: init_database failed\n"); + return 1; + } + UNITY_BEGIN(); + RUN_TEST(test_unassigned_root_is_seeded_and_immutable); + RUN_TEST(test_new_camera_defaults_to_unassigned); + RUN_TEST(test_create_nested_locations_and_report_direct_counts); + RUN_TEST(test_sibling_names_are_case_insensitively_unique); + RUN_TEST(test_move_rejects_cycles_and_allows_subtree_move); + RUN_TEST(test_delete_requires_empty_location_and_camera_can_be_reassigned); + RUN_TEST(test_assignment_rejects_unknown_camera_or_location); + int result = UNITY_END(); + shutdown_database(); + unlink(TEST_DB_PATH); + return result; +}