From b493ba6cd44c99411e18b5990786e23965794c20 Mon Sep 17 00:00:00 2001 From: grayTerminal-sh Date: Mon, 20 Jul 2026 22:14:52 +0200 Subject: [PATCH] feat: add relation DAO --- Makefile | 32 +- include/dao/relation_dao.h | 151 ++ include/models/relation_record.h | 187 +++ src/dao/relation_dao.c | 1745 +++++++++++++++++++++++ src/models/relation_record.c | 648 +++++++++ tests/test_relation_dao.c | 2265 ++++++++++++++++++++++++++++++ tests/test_relation_record.c | 665 +++++++++ 7 files changed, 5691 insertions(+), 2 deletions(-) create mode 100644 include/dao/relation_dao.h create mode 100644 include/models/relation_record.h create mode 100644 src/dao/relation_dao.c create mode 100644 src/models/relation_record.c create mode 100644 tests/test_relation_dao.c create mode 100644 tests/test_relation_record.c diff --git a/Makefile b/Makefile index 935df3f..f47e081 100644 --- a/Makefile +++ b/Makefile @@ -45,6 +45,8 @@ ENTITY_RECORD_TEST_CFLAGS := $(TEST_CFLAGS) -Wpedantic ENTITY_TYPE_DAO_TEST_CFLAGS := $(TEST_CFLAGS) -Wpedantic ENTITY_DAO_TEST_CFLAGS := $(TEST_CFLAGS) -Wpedantic EVIDENCE_ENTITY_DAO_TEST_CFLAGS := $(TEST_CFLAGS) -Wpedantic +RELATION_RECORD_TEST_CFLAGS := $(TEST_CFLAGS) -Wpedantic +RELATION_DAO_TEST_CFLAGS := $(TEST_CFLAGS) -Wpedantic SRC := $(shell find src -name "*.c") @@ -90,6 +92,8 @@ TEST_ENTITY_RECORD := tests/test_entity_record TEST_ENTITY_TYPE_DAO := tests/test_entity_type_dao TEST_ENTITY_DAO := tests/test_entity_dao TEST_EVIDENCE_ENTITY_DAO := tests/test_evidence_entity_dao +TEST_RELATION_RECORD := tests/test_relation_record +TEST_RELATION_DAO := tests/test_relation_dao all: $(TARGET) @@ -417,6 +421,24 @@ $(TEST_EVIDENCE_ENTITY_DAO): \ src/database/error.c $(CC) $(EVIDENCE_ENTITY_DAO_TEST_CFLAGS) $^ -o $@ $(TEST_LDFLAGS) -lsqlite3 +$(TEST_RELATION_RECORD): \ + tests/test_relation_record.c \ + src/models/relation_record.c + $(CC) $(RELATION_RECORD_TEST_CFLAGS) $^ -o $@ $(TEST_LDFLAGS) + +$(TEST_RELATION_DAO): \ + tests/test_relation_dao.c \ + src/dao/relation_dao.c \ + src/dao/entity_dao.c \ + src/models/relation_record.c \ + src/models/entity_record.c \ + src/database/database.c \ + src/database/schema.c \ + src/database/statement.c \ + src/database/transaction.c \ + src/database/error.c + $(CC) $(RELATION_DAO_TEST_CFLAGS) $^ -o $@ $(TEST_LDFLAGS) -lsqlite3 + test: \ $(TEST_NODE) \ $(TEST_TREE_MODEL) \ @@ -455,7 +477,9 @@ test: \ $(TEST_ENTITY_RECORD) \ $(TEST_ENTITY_TYPE_DAO) \ $(TEST_ENTITY_DAO) \ - $(TEST_EVIDENCE_ENTITY_DAO) + $(TEST_EVIDENCE_ENTITY_DAO) \ + $(TEST_RELATION_RECORD) \ + $(TEST_RELATION_DAO) @echo "Exécution des tests..." @./$(TEST_NODE) @./$(TEST_TREE_MODEL) @@ -495,6 +519,8 @@ test: \ @$(TEST_ENTITY_TYPE_DAO) @$(TEST_ENTITY_DAO) @$(TEST_EVIDENCE_ENTITY_DAO) + @$(TEST_RELATION_RECORD) + @$(TEST_RELATION_DAO) @echo "Tous les tests sont valides." %.o: %.c @@ -540,6 +566,8 @@ clean: $(TEST_EVIDENCE_IMPORT_DIALOG) \ $(TEST_EVIDENCE_INTEGRITY_TASK) \ $(TEST_ENTITY_DAO) \ - $(TEST_EVIDENCE_ENTITY_DAO) + $(TEST_EVIDENCE_ENTITY_DAO) \ + $(TEST_RELATION_RECORD) \ + $(TEST_RELATION_DAO) .PHONY: clean run test diff --git a/include/dao/relation_dao.h b/include/dao/relation_dao.h new file mode 100644 index 0000000..cac9eee --- /dev/null +++ b/include/dao/relation_dao.h @@ -0,0 +1,151 @@ +/****************************************************************************** + * @file relation_dao.h + * @brief Persistance des relations entre entités dans SQLite. + ******************************************************************************/ + +#ifndef LABFY_INVESTIGATION_RELATION_DAO_H +#define LABFY_INVESTIGATION_RELATION_DAO_H + +#include "database/database.h" +#include "models/relation_record.h" + +#include + +G_BEGIN_DECLS + +/** + * @brief Catégories d'erreurs produites par RelationDao. + */ +typedef enum +{ + RELATION_DAO_ERROR_INVALID_ARGUMENT, + RELATION_DAO_ERROR_MEMORY, + RELATION_DAO_ERROR_PREPARE, + RELATION_DAO_ERROR_BIND, + RELATION_DAO_ERROR_EXECUTE, + RELATION_DAO_ERROR_CONSTRAINT, + RELATION_DAO_ERROR_READ, + RELATION_DAO_ERROR_MODEL, + RELATION_DAO_ERROR_SCHEMA, + RELATION_DAO_ERROR_RANGE +} RelationDaoError; + +/** + * @brief Domaine d'erreur du DAO des relations. + */ +#define RELATION_DAO_ERROR \ + relation_dao_error_quark() + +/** + * @brief Retourne le domaine d'erreur du DAO. + */ +GQuark relation_dao_error_quark(void); + +/** + * @brief DAO opaque donnant accès aux relations persistées. + * + * Le DAO emprunte la connexion Database reçue lors de sa création. + */ +typedef struct RelationDao RelationDao; + +/** + * @brief Crée un DAO utilisant une connexion Database existante. + * + * La connexion est empruntée et doit rester valide pendant toute + * la durée de vie du DAO. + * + * @param database Connexion SQLite ouverte. + * @param error Emplacement facultatif recevant une erreur. + * + * @return Nouveau DAO, ou NULL en cas d'échec. + */ +RelationDao *relation_dao_new( + Database *database, + GError **error +); + +/** + * @brief Libère le DAO. + * + * La connexion Database empruntée n'est pas fermée. + * Cette fonction accepte NULL. + * + * @param relation_dao DAO à libérer. + */ +void relation_dao_free( + RelationDao *relation_dao +); + +/** + * @brief Insère une nouvelle relation. + * + * Aucun enregistrement existant n'est remplacé. + * + * @param relation_dao DAO valide. + * @param relation_record Relation empruntée. + * @param error Emplacement facultatif recevant une erreur. + * + * @return TRUE si l'insertion réussit. + */ +gboolean relation_dao_insert( + RelationDao *relation_dao, + const RelationRecord *relation_record, + GError **error +); + +/** + * @brief Recherche une relation par son UUID. + * + * Une relation absente retourne NULL sans produire d'erreur. + * + * @param relation_dao DAO valide. + * @param identifier UUID recherché. + * @param error Emplacement facultatif recevant une erreur. + * + * @return Nouveau modèle possédé par l'appelant, ou NULL. + */ +RelationRecord *relation_dao_find_by_identifier( + RelationDao *relation_dao, + const char *identifier, + GError **error +); + +/** + * @brief Charge toutes les relations dans un ordre déterministe. + * + * L'ordre utilisé est : + * + * - date de création croissante ; + * - UUID croissant en cas d'égalité. + * + * Le tableau retourné utilise relation_record_free() comme fonction + * de destruction. + * + * @param relation_dao DAO valide. + * @param error Emplacement facultatif recevant une erreur. + * + * @return Nouveau GPtrArray, ou NULL en cas d'échec. + */ +GPtrArray *relation_dao_list_all( + RelationDao *relation_dao, + GError **error +); + +/** + * @brief Compte les relations persistées. + * + * @param relation_dao DAO valide. + * @param out_count Destination du nombre de relations. + * @param error Emplacement facultatif recevant une erreur. + * + * @return TRUE si le comptage réussit. + */ +gboolean relation_dao_count( + RelationDao *relation_dao, + guint64 *out_count, + GError **error +); + +G_END_DECLS + +#endif diff --git a/include/models/relation_record.h b/include/models/relation_record.h new file mode 100644 index 0000000..74872ae --- /dev/null +++ b/include/models/relation_record.h @@ -0,0 +1,187 @@ +/****************************************************************************** + * @file relation_record.h + * @brief Modèle représentant une relation persistée entre deux entités. + ******************************************************************************/ + +#ifndef LABFY_INVESTIGATION_RELATION_RECORD_H +#define LABFY_INVESTIGATION_RELATION_RECORD_H + +#include + +G_BEGIN_DECLS + +/** + * @brief Modèle opaque représentant une relation entre deux entités. + */ +typedef struct RelationRecord RelationRecord; + +/** + * @brief Statut métier d'une relation. + * + * UNKNOWN sert uniquement de valeur défensive et ne doit jamais être + * persisté dans SQLite. + */ +typedef enum +{ + RELATION_STATUS_UNKNOWN, + RELATION_STATUS_ACTIVE, + RELATION_STATUS_ARCHIVED, + RELATION_STATUS_DELETED, + RELATION_STATUS_DISPUTED +} RelationStatus; + +/** + * @brief Codes d'erreur produits par RelationRecord. + */ +typedef enum +{ + RELATION_RECORD_ERROR_INVALID_ARGUMENT, + RELATION_RECORD_ERROR_INVALID_IDENTIFIER, + RELATION_RECORD_ERROR_INVALID_SOURCE_IDENTIFIER, + RELATION_RECORD_ERROR_INVALID_TARGET_IDENTIFIER, + RELATION_RECORD_ERROR_IDENTICAL_ENTITIES, + RELATION_RECORD_ERROR_INVALID_TYPE, + RELATION_RECORD_ERROR_INVALID_CONFIDENCE, + RELATION_RECORD_ERROR_INVALID_DATE, + RELATION_RECORD_ERROR_INVALID_STATUS +} RelationRecordError; + +/** + * @brief Domaine d'erreur du modèle RelationRecord. + */ +#define RELATION_RECORD_ERROR \ + relation_record_error_quark() + +/** + * @brief Retourne le domaine d'erreur du modèle. + */ +GQuark relation_record_error_quark(void); + +/** + * @brief Crée une relation métier. + * + * Toutes les chaînes sont copiées. + * + * identifier, source_entity_identifier, target_entity_identifier, + * relation_type, created_at et updated_at sont obligatoires. + * + * label et justification peuvent être NULL ou vides. + * + * confidence doit être compris entre 0 et 100. + * + * La source et la cible doivent être différentes. + * + * @param identifier UUID de la relation. + * @param source_entity_identifier UUID de l'entité source. + * @param target_entity_identifier UUID de l'entité cible. + * @param relation_type Type métier de la relation. + * @param label Libellé facultatif. + * @param justification Justification facultative. + * @param confidence Niveau de confiance compris entre 0 et 100. + * @param created_at Date UTC de création. + * @param updated_at Date UTC de dernière modification. + * @param status Statut métier. + * @param error Emplacement facultatif recevant une erreur. + * + * @return Nouvelle relation, ou NULL lorsque les données sont invalides. + */ +RelationRecord *relation_record_new( + const char *identifier, + const char *source_entity_identifier, + const char *target_entity_identifier, + const char *relation_type, + const char *label, + const char *justification, + gint confidence, + const char *created_at, + const char *updated_at, + RelationStatus status, + GError **error +); + +/** + * @brief Libère une relation. + * + * Cette fonction accepte NULL. + */ +void relation_record_free( + RelationRecord *relation_record +); + +/** + * @brief Retourne l'UUID de la relation. + */ +const char *relation_record_get_identifier( + const RelationRecord *relation_record +); + +/** + * @brief Retourne l'UUID de l'entité source. + */ +const char *relation_record_get_source_entity_identifier( + const RelationRecord *relation_record +); + +/** + * @brief Retourne l'UUID de l'entité cible. + */ +const char *relation_record_get_target_entity_identifier( + const RelationRecord *relation_record +); + +/** + * @brief Retourne le type métier de la relation. + */ +const char *relation_record_get_relation_type( + const RelationRecord *relation_record +); + +/** + * @brief Retourne le libellé facultatif. + */ +const char *relation_record_get_label( + const RelationRecord *relation_record +); + +/** + * @brief Retourne la justification facultative. + */ +const char *relation_record_get_justification( + const RelationRecord *relation_record +); + +/** + * @brief Retourne le niveau de confiance. + * + * @return Valeur comprise entre 0 et 100, ou 0 si relation_record est NULL. + */ +gint relation_record_get_confidence( + const RelationRecord *relation_record +); + +/** + * @brief Retourne la date UTC de création. + */ +const char *relation_record_get_created_at( + const RelationRecord *relation_record +); + +/** + * @brief Retourne la date UTC de dernière modification. + */ +const char *relation_record_get_updated_at( + const RelationRecord *relation_record +); + +/** + * @brief Retourne le statut métier. + * + * @return Statut ou RELATION_STATUS_UNKNOWN si relation_record est NULL. + */ +RelationStatus relation_record_get_status( + const RelationRecord *relation_record +); + +G_END_DECLS + +#endif diff --git a/src/dao/relation_dao.c b/src/dao/relation_dao.c new file mode 100644 index 0000000..e180a4e --- /dev/null +++ b/src/dao/relation_dao.c @@ -0,0 +1,1745 @@ +/****************************************************************************** + * @file relation_dao.c + * @brief Persistance des relations entre entités dans SQLite. + ******************************************************************************/ + +#include "dao/relation_dao.h" + +#include "database/error.h" +#include "database/statement.h" + +#include +#include + +/** + * @brief Représentation privée du DAO. + * + * La connexion Database est empruntée. + */ +struct RelationDao +{ + Database *database; +}; + +/** + * @brief Requête d'insertion d'une relation. + */ +static const char *const relation_dao_insert_sql = + "INSERT INTO relations" + "(" + " id," + " entite_source_id," + " entite_cible_id," + " type_relation," + " label," + " justification," + " confiance," + " created_at," + " updated_at," + " status" + ")" + "VALUES" + "(" + " ?," + " ?," + " ?," + " ?," + " ?," + " ?," + " ?," + " ?," + " ?," + " ?" + ");"; + +/** + * @brief Requête de recherche par UUID. + */ +static const char *const relation_dao_find_by_identifier_sql = + "SELECT " + " id," + " entite_source_id," + " entite_cible_id," + " type_relation," + " label," + " justification," + " confiance," + " created_at," + " updated_at," + " status " + "FROM relations " + "WHERE id = ?;"; + +/** + * @brief Requête de chargement de toutes les relations. + */ +static const char *const relation_dao_list_all_sql = + "SELECT " + " id," + " entite_source_id," + " entite_cible_id," + " type_relation," + " label," + " justification," + " confiance," + " created_at," + " updated_at," + " status " + "FROM relations " + "ORDER BY " + " created_at ASC," + " id ASC;"; + +/** + * @brief Requête de comptage. + */ +static const char *const relation_dao_count_sql = + "SELECT COUNT(*) " + "FROM relations;"; + +/** + * @brief Requête de détection d'un UUID de relation. + */ +static const char *const relation_dao_identifier_exists_sql = + "SELECT 1 " + "FROM relations " + "WHERE id = ? " + "LIMIT 1;"; + +/** + * @brief Requête de détection d'une entité. + */ +static const char *const relation_dao_entity_exists_sql = + "SELECT 1 " + "FROM entites " + "WHERE id = ? " + "LIMIT 1;"; + +/** + * @brief Requête de détection d'un doublon orienté. + */ +static const char *const relation_dao_duplicate_exists_sql = + "SELECT 1 " + "FROM relations " + "WHERE entite_source_id = ? " + "AND entite_cible_id = ? " + "AND type_relation = ? " + "LIMIT 1;"; + +/** + * @brief Enregistre une erreur littérale. + */ +static void relation_dao_set_error_literal( + GError **error, + RelationDaoError error_code, + const char *message +) +{ + if (error == NULL || + *error != NULL) + { + return; + } + + g_set_error_literal( + error, + RELATION_DAO_ERROR, + error_code, + message + ); +} + +/** + * @brief Détermine si SQLite signale un schéma incompatible. + */ +static gboolean relation_dao_is_schema_error( + const char *database_message +) +{ + if (database_message == NULL) + { + return FALSE; + } + + return g_strstr_len( + database_message, + -1, + "no such table" + ) != NULL || + g_strstr_len( + database_message, + -1, + "no such column" + ) != NULL; +} + +/** + * @brief Détermine si SQLite signale une contrainte. + */ +static gboolean relation_dao_is_constraint_error( + const char *database_message +) +{ + if (database_message == NULL) + { + return FALSE; + } + + return g_strstr_len( + database_message, + -1, + "constraint" + ) != NULL || + g_strstr_len( + database_message, + -1, + "CONSTRAINT" + ) != NULL; +} + +/** + * @brief Transforme la dernière erreur Database en erreur du DAO. + */ +static void relation_dao_set_database_error( + RelationDao *relation_dao, + GError **error, + RelationDaoError error_code, + const char *context +) +{ + const char *database_message = + NULL; + + if (error == NULL || + *error != NULL) + { + return; + } + + if (relation_dao != NULL && + relation_dao->database != NULL) + { + database_message = + database_error_get_message( + relation_dao->database + ); + } + + if (error_code == RELATION_DAO_ERROR_PREPARE && + relation_dao_is_schema_error( + database_message + )) + { + error_code = + RELATION_DAO_ERROR_SCHEMA; + } + + if (database_message != NULL && + database_message[0] != '\0') + { + g_set_error( + error, + RELATION_DAO_ERROR, + error_code, + "%s : %s", + context, + database_message + ); + + return; + } + + g_set_error_literal( + error, + RELATION_DAO_ERROR, + error_code, + context + ); +} + +/** + * @brief Convertit un statut métier en texte SQLite. + */ +static const char *relation_dao_status_to_text( + RelationStatus status +) +{ + switch (status) + { + case RELATION_STATUS_ACTIVE: + return "active"; + + case RELATION_STATUS_ARCHIVED: + return "archived"; + + case RELATION_STATUS_DELETED: + return "deleted"; + + case RELATION_STATUS_DISPUTED: + return "disputed"; + + case RELATION_STATUS_UNKNOWN: + default: + return NULL; + } +} + +/** + * @brief Convertit un statut SQLite en statut métier. + */ +static gboolean relation_dao_status_from_text( + const char *status_text, + RelationStatus *out_status +) +{ + if (status_text == NULL || + out_status == NULL) + { + return FALSE; + } + + if (g_strcmp0( + status_text, + "active" + ) == 0) + { + *out_status = + RELATION_STATUS_ACTIVE; + + return TRUE; + } + + if (g_strcmp0( + status_text, + "archived" + ) == 0) + { + *out_status = + RELATION_STATUS_ARCHIVED; + + return TRUE; + } + + if (g_strcmp0( + status_text, + "deleted" + ) == 0) + { + *out_status = + RELATION_STATUS_DELETED; + + return TRUE; + } + + if (g_strcmp0( + status_text, + "disputed" + ) == 0) + { + *out_status = + RELATION_STATUS_DISPUTED; + + return TRUE; + } + + return FALSE; +} + +/** + * @brief Lie un texte facultatif ou SQL NULL. + */ +static gboolean relation_dao_bind_optional_text( + DatabaseStatement *statement, + int parameter_index, + const char *value +) +{ + if (value == NULL) + { + return database_statement_bind_null( + statement, + parameter_index + ); + } + + return database_statement_bind_text( + statement, + parameter_index, + value + ); +} + +/** + * @brief Exécute une recherche d'existence avec un paramètre. + */ +static gboolean relation_dao_value_exists( + RelationDao *relation_dao, + const char *sql, + const char *value, + gboolean *out_exists, + GError **error +) +{ + DatabaseStatement *statement = + NULL; + + DatabaseStatementStepResult step_result = + DATABASE_STATEMENT_STEP_ERROR; + + gboolean success = + FALSE; + + if (relation_dao == NULL || + relation_dao->database == NULL || + sql == NULL || + value == NULL || + out_exists == NULL) + { + relation_dao_set_error_literal( + error, + RELATION_DAO_ERROR_INVALID_ARGUMENT, + "Les paramètres de la recherche d'existence sont invalides." + ); + + return FALSE; + } + + *out_exists = + FALSE; + + statement = + database_statement_prepare( + relation_dao->database, + sql + ); + + if (statement == NULL) + { + relation_dao_set_database_error( + relation_dao, + error, + RELATION_DAO_ERROR_PREPARE, + "Impossible de préparer la recherche d'existence" + ); + + goto cleanup; + } + + if (!database_statement_bind_text( + statement, + 1, + value + )) + { + relation_dao_set_database_error( + relation_dao, + error, + RELATION_DAO_ERROR_BIND, + "Impossible de lier la valeur recherchée" + ); + + goto cleanup; + } + + step_result = + database_statement_step( + statement + ); + + if (step_result == + DATABASE_STATEMENT_STEP_ERROR) + { + relation_dao_set_database_error( + relation_dao, + error, + RELATION_DAO_ERROR_EXECUTE, + "Impossible d'exécuter la recherche d'existence" + ); + + goto cleanup; + } + + *out_exists = + step_result == + DATABASE_STATEMENT_STEP_ROW; + + success = + TRUE; + +cleanup: + + database_statement_finalize( + statement + ); + + return success; +} + +/** + * @brief Vérifie l'existence d'un doublon orienté. + */ +static gboolean relation_dao_duplicate_exists( + RelationDao *relation_dao, + const char *source_entity_identifier, + const char *target_entity_identifier, + const char *relation_type, + gboolean *out_exists, + GError **error +) +{ + DatabaseStatement *statement = + NULL; + + DatabaseStatementStepResult step_result = + DATABASE_STATEMENT_STEP_ERROR; + + gboolean success = + FALSE; + + if (relation_dao == NULL || + relation_dao->database == NULL || + source_entity_identifier == NULL || + target_entity_identifier == NULL || + relation_type == NULL || + out_exists == NULL) + { + relation_dao_set_error_literal( + error, + RELATION_DAO_ERROR_INVALID_ARGUMENT, + "Les paramètres de détection du doublon sont invalides." + ); + + return FALSE; + } + + *out_exists = + FALSE; + + statement = + database_statement_prepare( + relation_dao->database, + relation_dao_duplicate_exists_sql + ); + + if (statement == NULL) + { + relation_dao_set_database_error( + relation_dao, + error, + RELATION_DAO_ERROR_PREPARE, + "Impossible de préparer la recherche du doublon" + ); + + goto cleanup; + } + + if (!database_statement_bind_text( + statement, + 1, + source_entity_identifier + ) || + !database_statement_bind_text( + statement, + 2, + target_entity_identifier + ) || + !database_statement_bind_text( + statement, + 3, + relation_type + )) + { + relation_dao_set_database_error( + relation_dao, + error, + RELATION_DAO_ERROR_BIND, + "Impossible de lier les données du doublon" + ); + + goto cleanup; + } + + step_result = + database_statement_step( + statement + ); + + if (step_result == + DATABASE_STATEMENT_STEP_ERROR) + { + relation_dao_set_database_error( + relation_dao, + error, + RELATION_DAO_ERROR_EXECUTE, + "Impossible d'exécuter la recherche du doublon" + ); + + goto cleanup; + } + + *out_exists = + step_result == + DATABASE_STATEMENT_STEP_ROW; + + success = + TRUE; + +cleanup: + + database_statement_finalize( + statement + ); + + return success; +} + +/** + * @brief Construit un RelationRecord depuis la ligne courante. + */ +static RelationRecord *relation_dao_read_current_record( + RelationDao *relation_dao, + DatabaseStatement *statement, + GError **error +) +{ + RelationRecord *relation_record = + NULL; + + char *identifier = + NULL; + + char *source_entity_identifier = + NULL; + + char *target_entity_identifier = + NULL; + + char *relation_type = + NULL; + + char *label = + NULL; + + char *justification = + NULL; + + char *created_at = + NULL; + + char *updated_at = + NULL; + + char *status_text = + NULL; + + int64_t confidence_value = + -1; + + RelationStatus status = + RELATION_STATUS_UNKNOWN; + + GError *model_error = + NULL; + + if (relation_dao == NULL || + relation_dao->database == NULL || + statement == NULL) + { + relation_dao_set_error_literal( + error, + RELATION_DAO_ERROR_INVALID_ARGUMENT, + "Impossible de lire une relation avec des paramètres invalides." + ); + + return NULL; + } + + if (!database_statement_column_text( + statement, + 0, + &identifier + ) || + !database_statement_column_text( + statement, + 1, + &source_entity_identifier + ) || + !database_statement_column_text( + statement, + 2, + &target_entity_identifier + ) || + !database_statement_column_text( + statement, + 3, + &relation_type + ) || + !database_statement_column_text( + statement, + 4, + &label + ) || + !database_statement_column_text( + statement, + 5, + &justification + ) || + !database_statement_column_int64( + statement, + 6, + &confidence_value + ) || + !database_statement_column_text( + statement, + 7, + &created_at + ) || + !database_statement_column_text( + statement, + 8, + &updated_at + ) || + !database_statement_column_text( + statement, + 9, + &status_text + )) + { + relation_dao_set_error_literal( + error, + RELATION_DAO_ERROR_READ, + "Impossible de lire les colonnes de la relation." + ); + + goto cleanup; + } + + if (confidence_value < 0 || + confidence_value > 100) + { + relation_dao_set_error_literal( + error, + RELATION_DAO_ERROR_RANGE, + "La relation persistée possède une confiance invalide." + ); + + goto cleanup; + } + + if (!relation_dao_status_from_text( + status_text, + &status + )) + { + relation_dao_set_error_literal( + error, + RELATION_DAO_ERROR_MODEL, + "La relation persistée possède un statut invalide." + ); + + goto cleanup; + } + + relation_record = + relation_record_new( + identifier, + source_entity_identifier, + target_entity_identifier, + relation_type, + label, + justification, + (gint) confidence_value, + created_at, + updated_at, + status, + &model_error + ); + + if (relation_record == NULL) + { + if (model_error != NULL) + { + g_set_error( + error, + RELATION_DAO_ERROR, + RELATION_DAO_ERROR_MODEL, + "La relation persistée est invalide : %s", + model_error->message + ); + } + else + { + relation_dao_set_error_literal( + error, + RELATION_DAO_ERROR_MODEL, + "La relation persistée ne peut pas être reconstruite." + ); + } + } + +cleanup: + + g_clear_error( + &model_error + ); + + g_free( + status_text + ); + + g_free( + updated_at + ); + + g_free( + created_at + ); + + g_free( + justification + ); + + g_free( + label + ); + + g_free( + relation_type + ); + + g_free( + target_entity_identifier + ); + + g_free( + source_entity_identifier + ); + + g_free( + identifier + ); + + return relation_record; +} + +/** + * @brief Libère un modèle contenu dans un GPtrArray. + */ +static void relation_dao_record_destroy( + gpointer user_data +) +{ + relation_record_free( + user_data + ); +} + +GQuark relation_dao_error_quark(void) +{ + return g_quark_from_static_string( + "relation-dao-error-quark" + ); +} + +RelationDao *relation_dao_new( + Database *database, + GError **error +) +{ + RelationDao *relation_dao = + NULL; + + g_return_val_if_fail( + error == NULL || *error == NULL, + NULL + ); + + if (database == NULL) + { + relation_dao_set_error_literal( + error, + RELATION_DAO_ERROR_INVALID_ARGUMENT, + "La connexion Database est obligatoire." + ); + + return NULL; + } + + relation_dao = + g_try_new0( + RelationDao, + 1 + ); + + if (relation_dao == NULL) + { + relation_dao_set_error_literal( + error, + RELATION_DAO_ERROR_MEMORY, + "Impossible d'allouer le DAO des relations." + ); + + return NULL; + } + + relation_dao->database = + database; + + return relation_dao; +} + +void relation_dao_free( + RelationDao *relation_dao +) +{ + if (relation_dao == NULL) + { + return; + } + + relation_dao->database = + NULL; + + g_free( + relation_dao + ); +} + +gboolean relation_dao_insert( + RelationDao *relation_dao, + const RelationRecord *relation_record, + GError **error +) +{ + DatabaseStatement *statement = + NULL; + + const char *identifier = + NULL; + + const char *source_entity_identifier = + NULL; + + const char *target_entity_identifier = + NULL; + + const char *relation_type = + NULL; + + const char *label = + NULL; + + const char *justification = + NULL; + + const char *created_at = + NULL; + + const char *updated_at = + NULL; + + const char *status_text = + NULL; + + const char *database_message = + NULL; + + gint confidence = + 0; + + gboolean value_exists = + FALSE; + + gboolean success = + FALSE; + + g_return_val_if_fail( + error == NULL || *error == NULL, + FALSE + ); + + if (relation_dao == NULL || + relation_dao->database == NULL || + relation_record == NULL) + { + relation_dao_set_error_literal( + error, + RELATION_DAO_ERROR_INVALID_ARGUMENT, + "Le DAO ou la relation est absent." + ); + + return FALSE; + } + + identifier = + relation_record_get_identifier( + relation_record + ); + + source_entity_identifier = + relation_record_get_source_entity_identifier( + relation_record + ); + + target_entity_identifier = + relation_record_get_target_entity_identifier( + relation_record + ); + + relation_type = + relation_record_get_relation_type( + relation_record + ); + + label = + relation_record_get_label( + relation_record + ); + + justification = + relation_record_get_justification( + relation_record + ); + + confidence = + relation_record_get_confidence( + relation_record + ); + + created_at = + relation_record_get_created_at( + relation_record + ); + + updated_at = + relation_record_get_updated_at( + relation_record + ); + + status_text = + relation_dao_status_to_text( + relation_record_get_status( + relation_record + ) + ); + + if (identifier == NULL || + source_entity_identifier == NULL || + target_entity_identifier == NULL || + relation_type == NULL || + created_at == NULL || + updated_at == NULL || + status_text == NULL) + { + relation_dao_set_error_literal( + error, + RELATION_DAO_ERROR_INVALID_ARGUMENT, + "La relation contient une valeur obligatoire absente." + ); + + return FALSE; + } + + if (!g_uuid_string_is_valid( + identifier + ) || + !g_uuid_string_is_valid( + source_entity_identifier + ) || + !g_uuid_string_is_valid( + target_entity_identifier + )) + { + relation_dao_set_error_literal( + error, + RELATION_DAO_ERROR_INVALID_ARGUMENT, + "La relation contient un UUID invalide." + ); + + return FALSE; + } + + if (g_strcmp0( + source_entity_identifier, + target_entity_identifier + ) == 0) + { + relation_dao_set_error_literal( + error, + RELATION_DAO_ERROR_CONSTRAINT, + "Une relation ne peut pas relier une entité à elle-même." + ); + + return FALSE; + } + + if (confidence < 0 || + confidence > 100) + { + relation_dao_set_error_literal( + error, + RELATION_DAO_ERROR_RANGE, + "Le niveau de confiance dépasse les limites autorisées." + ); + + return FALSE; + } + + if (!relation_dao_value_exists( + relation_dao, + relation_dao_identifier_exists_sql, + identifier, + &value_exists, + error + )) + { + return FALSE; + } + + if (value_exists) + { + relation_dao_set_error_literal( + error, + RELATION_DAO_ERROR_CONSTRAINT, + "Une relation possède déjà cet identifiant." + ); + + return FALSE; + } + + if (!relation_dao_value_exists( + relation_dao, + relation_dao_entity_exists_sql, + source_entity_identifier, + &value_exists, + error + )) + { + return FALSE; + } + + if (!value_exists) + { + relation_dao_set_error_literal( + error, + RELATION_DAO_ERROR_CONSTRAINT, + "L'entité source n'existe pas." + ); + + return FALSE; + } + + if (!relation_dao_value_exists( + relation_dao, + relation_dao_entity_exists_sql, + target_entity_identifier, + &value_exists, + error + )) + { + return FALSE; + } + + if (!value_exists) + { + relation_dao_set_error_literal( + error, + RELATION_DAO_ERROR_CONSTRAINT, + "L'entité cible n'existe pas." + ); + + return FALSE; + } + + if (!relation_dao_duplicate_exists( + relation_dao, + source_entity_identifier, + target_entity_identifier, + relation_type, + &value_exists, + error + )) + { + return FALSE; + } + + if (value_exists) + { + relation_dao_set_error_literal( + error, + RELATION_DAO_ERROR_CONSTRAINT, + "Cette relation orientée existe déjà pour ce type." + ); + + return FALSE; + } + + statement = + database_statement_prepare( + relation_dao->database, + relation_dao_insert_sql + ); + + if (statement == NULL) + { + relation_dao_set_database_error( + relation_dao, + error, + RELATION_DAO_ERROR_PREPARE, + "Impossible de préparer l'insertion de la relation" + ); + + goto cleanup; + } + + if (!database_statement_bind_text( + statement, + 1, + identifier + ) || + !database_statement_bind_text( + statement, + 2, + source_entity_identifier + ) || + !database_statement_bind_text( + statement, + 3, + target_entity_identifier + ) || + !database_statement_bind_text( + statement, + 4, + relation_type + ) || + !relation_dao_bind_optional_text( + statement, + 5, + label + ) || + !relation_dao_bind_optional_text( + statement, + 6, + justification + ) || + !database_statement_bind_int64( + statement, + 7, + (int64_t) confidence + ) || + !database_statement_bind_text( + statement, + 8, + created_at + ) || + !database_statement_bind_text( + statement, + 9, + updated_at + ) || + !database_statement_bind_text( + statement, + 10, + status_text + )) + { + relation_dao_set_database_error( + relation_dao, + error, + RELATION_DAO_ERROR_BIND, + "Impossible de lier les données de la relation" + ); + + goto cleanup; + } + + if (database_statement_step( + statement + ) != DATABASE_STATEMENT_STEP_DONE) + { + database_message = + database_error_get_message( + relation_dao->database + ); + + if (relation_dao_is_constraint_error( + database_message + )) + { + relation_dao_set_database_error( + relation_dao, + error, + RELATION_DAO_ERROR_CONSTRAINT, + "La relation viole une contrainte SQLite" + ); + } + else + { + relation_dao_set_database_error( + relation_dao, + error, + RELATION_DAO_ERROR_EXECUTE, + "Impossible d'insérer la relation" + ); + } + + goto cleanup; + } + + success = + TRUE; + +cleanup: + + database_statement_finalize( + statement + ); + + return success; +} + +RelationRecord *relation_dao_find_by_identifier( + RelationDao *relation_dao, + const char *identifier, + GError **error +) +{ + DatabaseStatement *statement = + NULL; + + RelationRecord *relation_record = + NULL; + + DatabaseStatementStepResult step_result = + DATABASE_STATEMENT_STEP_ERROR; + + g_return_val_if_fail( + error == NULL || *error == NULL, + NULL + ); + + if (relation_dao == NULL || + relation_dao->database == NULL || + identifier == NULL || + !g_uuid_string_is_valid( + identifier + )) + { + relation_dao_set_error_literal( + error, + RELATION_DAO_ERROR_INVALID_ARGUMENT, + "L'identifiant de relation est invalide." + ); + + return NULL; + } + + statement = + database_statement_prepare( + relation_dao->database, + relation_dao_find_by_identifier_sql + ); + + if (statement == NULL) + { + relation_dao_set_database_error( + relation_dao, + error, + RELATION_DAO_ERROR_PREPARE, + "Impossible de préparer la recherche de la relation" + ); + + goto cleanup; + } + + if (!database_statement_bind_text( + statement, + 1, + identifier + )) + { + relation_dao_set_database_error( + relation_dao, + error, + RELATION_DAO_ERROR_BIND, + "Impossible de lier l'identifiant de la relation" + ); + + goto cleanup; + } + + step_result = + database_statement_step( + statement + ); + + if (step_result == + DATABASE_STATEMENT_STEP_ERROR) + { + relation_dao_set_database_error( + relation_dao, + error, + RELATION_DAO_ERROR_EXECUTE, + "Impossible d'exécuter la recherche de la relation" + ); + + goto cleanup; + } + + if (step_result == + DATABASE_STATEMENT_STEP_DONE) + { + goto cleanup; + } + + relation_record = + relation_dao_read_current_record( + relation_dao, + statement, + error + ); + + if (relation_record == NULL) + { + goto cleanup; + } + + step_result = + database_statement_step( + statement + ); + + if (step_result != + DATABASE_STATEMENT_STEP_DONE) + { + if (step_result == + DATABASE_STATEMENT_STEP_ROW) + { + relation_dao_set_error_literal( + error, + RELATION_DAO_ERROR_SCHEMA, + "Plusieurs relations possèdent le même identifiant." + ); + } + else + { + relation_dao_set_database_error( + relation_dao, + error, + RELATION_DAO_ERROR_EXECUTE, + "Impossible de terminer la lecture de la relation" + ); + } + + relation_record_free( + relation_record + ); + + relation_record = + NULL; + } + +cleanup: + + database_statement_finalize( + statement + ); + + return relation_record; +} + +GPtrArray *relation_dao_list_all( + RelationDao *relation_dao, + GError **error +) +{ + DatabaseStatement *statement = + NULL; + + GPtrArray *relation_records = + NULL; + + RelationRecord *relation_record = + NULL; + + DatabaseStatementStepResult step_result = + DATABASE_STATEMENT_STEP_ERROR; + + g_return_val_if_fail( + error == NULL || *error == NULL, + NULL + ); + + if (relation_dao == NULL || + relation_dao->database == NULL) + { + relation_dao_set_error_literal( + error, + RELATION_DAO_ERROR_INVALID_ARGUMENT, + "Le DAO des relations est invalide." + ); + + return NULL; + } + + relation_records = + g_ptr_array_new_with_free_func( + relation_dao_record_destroy + ); + + if (relation_records == NULL) + { + relation_dao_set_error_literal( + error, + RELATION_DAO_ERROR_MEMORY, + "Impossible d'allouer la liste des relations." + ); + + return NULL; + } + + statement = + database_statement_prepare( + relation_dao->database, + relation_dao_list_all_sql + ); + + if (statement == NULL) + { + relation_dao_set_database_error( + relation_dao, + error, + RELATION_DAO_ERROR_PREPARE, + "Impossible de préparer le chargement des relations" + ); + + goto failure; + } + + while (TRUE) + { + step_result = + database_statement_step( + statement + ); + + if (step_result == + DATABASE_STATEMENT_STEP_DONE) + { + break; + } + + if (step_result == + DATABASE_STATEMENT_STEP_ERROR) + { + relation_dao_set_database_error( + relation_dao, + error, + RELATION_DAO_ERROR_EXECUTE, + "Impossible d'exécuter le chargement des relations" + ); + + goto failure; + } + + relation_record = + relation_dao_read_current_record( + relation_dao, + statement, + error + ); + + if (relation_record == NULL) + { + goto failure; + } + + g_ptr_array_add( + relation_records, + relation_record + ); + + relation_record = + NULL; + } + + database_statement_finalize( + statement + ); + + return relation_records; + +failure: + + relation_record_free( + relation_record + ); + + database_statement_finalize( + statement + ); + + g_ptr_array_unref( + relation_records + ); + + return NULL; +} + +gboolean relation_dao_count( + RelationDao *relation_dao, + guint64 *out_count, + GError **error +) +{ + DatabaseStatement *statement = + NULL; + + DatabaseStatementStepResult step_result = + DATABASE_STATEMENT_STEP_ERROR; + + int64_t count_value = + -1; + + gboolean success = + FALSE; + + g_return_val_if_fail( + error == NULL || *error == NULL, + FALSE + ); + + if (relation_dao == NULL || + relation_dao->database == NULL || + out_count == NULL) + { + relation_dao_set_error_literal( + error, + RELATION_DAO_ERROR_INVALID_ARGUMENT, + "Les paramètres du comptage des relations sont invalides." + ); + + return FALSE; + } + + *out_count = + 0; + + statement = + database_statement_prepare( + relation_dao->database, + relation_dao_count_sql + ); + + if (statement == NULL) + { + relation_dao_set_database_error( + relation_dao, + error, + RELATION_DAO_ERROR_PREPARE, + "Impossible de préparer le comptage des relations" + ); + + goto cleanup; + } + + step_result = + database_statement_step( + statement + ); + + if (step_result == + DATABASE_STATEMENT_STEP_ERROR) + { + relation_dao_set_database_error( + relation_dao, + error, + RELATION_DAO_ERROR_EXECUTE, + "Impossible d'exécuter le comptage des relations" + ); + + goto cleanup; + } + + if (step_result != + DATABASE_STATEMENT_STEP_ROW) + { + relation_dao_set_error_literal( + error, + RELATION_DAO_ERROR_READ, + "Le comptage des relations n'a retourné aucune ligne." + ); + + goto cleanup; + } + + if (!database_statement_column_int64( + statement, + 0, + &count_value + )) + { + relation_dao_set_error_literal( + error, + RELATION_DAO_ERROR_READ, + "Impossible de lire le nombre de relations." + ); + + goto cleanup; + } + + if (count_value < 0) + { + relation_dao_set_error_literal( + error, + RELATION_DAO_ERROR_RANGE, + "Le nombre de relations retourné par SQLite est invalide." + ); + + goto cleanup; + } + + step_result = + database_statement_step( + statement + ); + + if (step_result != + DATABASE_STATEMENT_STEP_DONE) + { + if (step_result == + DATABASE_STATEMENT_STEP_ROW) + { + relation_dao_set_error_literal( + error, + RELATION_DAO_ERROR_SCHEMA, + "Le comptage des relations a retourné plusieurs lignes." + ); + } + else + { + relation_dao_set_database_error( + relation_dao, + error, + RELATION_DAO_ERROR_EXECUTE, + "Impossible de terminer le comptage des relations" + ); + } + + goto cleanup; + } + + *out_count = + (guint64) count_value; + + success = + TRUE; + +cleanup: + + database_statement_finalize( + statement + ); + + return success; +} diff --git a/src/models/relation_record.c b/src/models/relation_record.c new file mode 100644 index 0000000..96bc728 --- /dev/null +++ b/src/models/relation_record.c @@ -0,0 +1,648 @@ +/****************************************************************************** + * @file relation_record.c + * @brief Implémentation du modèle représentant une relation persistée. + ******************************************************************************/ + +#include "models/relation_record.h" + +#include + +/** + * @brief Représentation privée d'une relation. + */ +struct RelationRecord +{ + char *identifier; + char *source_entity_identifier; + char *target_entity_identifier; + char *relation_type; + char *label; + char *justification; + + gint confidence; + + char *created_at; + char *updated_at; + + RelationStatus status; +}; + +/** + * @brief Copie et nettoie une chaîne. + */ +static char *relation_record_duplicate_trimmed( + const char *value +) +{ + char *copy = + NULL; + + if (value == NULL) + { + return NULL; + } + + copy = + g_strdup( + value + ); + + g_strstrip( + copy + ); + + return copy; +} + +/** + * @brief Normalise un champ facultatif. + * + * Une chaîne vide après nettoyage devient NULL. + */ +static char *relation_record_duplicate_optional( + const char *value +) +{ + char *copy = + NULL; + + copy = + relation_record_duplicate_trimmed( + value + ); + + if (copy != NULL && + copy[0] == '\0') + { + g_free( + copy + ); + + copy = + NULL; + } + + return copy; +} + +/** + * @brief Vérifie qu'une chaîne obligatoire est présente. + */ +static gboolean relation_record_required_value_is_valid( + const char *value +) +{ + return value != NULL && + value[0] != '\0'; +} + +/** + * @brief Vérifie une date UTC stricte au format YYYY-MM-DDTHH:MM:SSZ. + */ +static gboolean relation_record_timestamp_is_valid( + const char *timestamp +) +{ + GDateTime *date_time = + NULL; + + char *normalized_timestamp = + NULL; + + gboolean valid = + FALSE; + + gsize character_index = + 0; + + if (timestamp == NULL || + strlen(timestamp) != 20U) + { + return FALSE; + } + + if (timestamp[4] != '-' || + timestamp[7] != '-' || + timestamp[10] != 'T' || + timestamp[13] != ':' || + timestamp[16] != ':' || + timestamp[19] != 'Z') + { + return FALSE; + } + + for (character_index = 0; + character_index < 20U; + character_index++) + { + if (character_index == 4U || + character_index == 7U || + character_index == 10U || + character_index == 13U || + character_index == 16U || + character_index == 19U) + { + continue; + } + + if (!g_ascii_isdigit( + timestamp[character_index] + )) + { + return FALSE; + } + } + + date_time = + g_date_time_new_from_iso8601( + timestamp, + NULL + ); + + if (date_time == NULL) + { + return FALSE; + } + + normalized_timestamp = + g_date_time_format( + date_time, + "%Y-%m-%dT%H:%M:%SZ" + ); + + valid = + normalized_timestamp != NULL && + g_strcmp0( + normalized_timestamp, + timestamp + ) == 0; + + g_free( + normalized_timestamp + ); + + g_date_time_unref( + date_time + ); + + return valid; +} + +/** + * @brief Vérifie qu'un statut peut être persisté. + */ +static gboolean relation_record_status_is_valid( + RelationStatus status +) +{ + return status == RELATION_STATUS_ACTIVE || + status == RELATION_STATUS_ARCHIVED || + status == RELATION_STATUS_DELETED || + status == RELATION_STATUS_DISPUTED; +} + +GQuark relation_record_error_quark(void) +{ + return g_quark_from_static_string( + "relation-record-error-quark" + ); +} + +RelationRecord *relation_record_new( + const char *identifier, + const char *source_entity_identifier, + const char *target_entity_identifier, + const char *relation_type, + const char *label, + const char *justification, + gint confidence, + const char *created_at, + const char *updated_at, + RelationStatus status, + GError **error +) +{ + RelationRecord *relation_record = + NULL; + + char *identifier_copy = + NULL; + + char *source_entity_identifier_copy = + NULL; + + char *target_entity_identifier_copy = + NULL; + + char *relation_type_copy = + NULL; + + char *label_copy = + NULL; + + char *justification_copy = + NULL; + + char *created_at_copy = + NULL; + + char *updated_at_copy = + NULL; + + g_return_val_if_fail( + error == NULL || *error == NULL, + NULL + ); + + identifier_copy = + relation_record_duplicate_trimmed( + identifier + ); + + if (!relation_record_required_value_is_valid( + identifier_copy + ) || + !g_uuid_string_is_valid( + identifier_copy + )) + { + g_set_error_literal( + error, + RELATION_RECORD_ERROR, + RELATION_RECORD_ERROR_INVALID_IDENTIFIER, + "L'identifiant de la relation n'est pas un UUID valide." + ); + + goto cleanup; + } + + source_entity_identifier_copy = + relation_record_duplicate_trimmed( + source_entity_identifier + ); + + if (!relation_record_required_value_is_valid( + source_entity_identifier_copy + ) || + !g_uuid_string_is_valid( + source_entity_identifier_copy + )) + { + g_set_error_literal( + error, + RELATION_RECORD_ERROR, + RELATION_RECORD_ERROR_INVALID_SOURCE_IDENTIFIER, + "L'identifiant de l'entité source n'est pas un UUID valide." + ); + + goto cleanup; + } + + target_entity_identifier_copy = + relation_record_duplicate_trimmed( + target_entity_identifier + ); + + if (!relation_record_required_value_is_valid( + target_entity_identifier_copy + ) || + !g_uuid_string_is_valid( + target_entity_identifier_copy + )) + { + g_set_error_literal( + error, + RELATION_RECORD_ERROR, + RELATION_RECORD_ERROR_INVALID_TARGET_IDENTIFIER, + "L'identifiant de l'entité cible n'est pas un UUID valide." + ); + + goto cleanup; + } + + if (g_strcmp0( + source_entity_identifier_copy, + target_entity_identifier_copy + ) == 0) + { + g_set_error_literal( + error, + RELATION_RECORD_ERROR, + RELATION_RECORD_ERROR_IDENTICAL_ENTITIES, + "Une relation ne peut pas relier une entité à elle-même." + ); + + goto cleanup; + } + + relation_type_copy = + relation_record_duplicate_trimmed( + relation_type + ); + + if (!relation_record_required_value_is_valid( + relation_type_copy + )) + { + g_set_error_literal( + error, + RELATION_RECORD_ERROR, + RELATION_RECORD_ERROR_INVALID_TYPE, + "Le type de relation est obligatoire." + ); + + goto cleanup; + } + + if (confidence < 0 || + confidence > 100) + { + g_set_error_literal( + error, + RELATION_RECORD_ERROR, + RELATION_RECORD_ERROR_INVALID_CONFIDENCE, + "Le niveau de confiance doit être compris entre 0 et 100." + ); + + goto cleanup; + } + + created_at_copy = + relation_record_duplicate_trimmed( + created_at + ); + + if (!relation_record_timestamp_is_valid( + created_at_copy + )) + { + g_set_error_literal( + error, + RELATION_RECORD_ERROR, + RELATION_RECORD_ERROR_INVALID_DATE, + "La date de création de la relation est invalide." + ); + + goto cleanup; + } + + updated_at_copy = + relation_record_duplicate_trimmed( + updated_at + ); + + if (!relation_record_timestamp_is_valid( + updated_at_copy + )) + { + g_set_error_literal( + error, + RELATION_RECORD_ERROR, + RELATION_RECORD_ERROR_INVALID_DATE, + "La date de modification de la relation est invalide." + ); + + goto cleanup; + } + + if (!relation_record_status_is_valid( + status + )) + { + g_set_error_literal( + error, + RELATION_RECORD_ERROR, + RELATION_RECORD_ERROR_INVALID_STATUS, + "Le statut de la relation est invalide." + ); + + goto cleanup; + } + + label_copy = + relation_record_duplicate_optional( + label + ); + + justification_copy = + relation_record_duplicate_optional( + justification + ); + + relation_record = + g_new0( + RelationRecord, + 1 + ); + + relation_record->identifier = + identifier_copy; + + relation_record->source_entity_identifier = + source_entity_identifier_copy; + + relation_record->target_entity_identifier = + target_entity_identifier_copy; + + relation_record->relation_type = + relation_type_copy; + + relation_record->label = + label_copy; + + relation_record->justification = + justification_copy; + + relation_record->confidence = + confidence; + + relation_record->created_at = + created_at_copy; + + relation_record->updated_at = + updated_at_copy; + + relation_record->status = + status; + + identifier_copy = NULL; + source_entity_identifier_copy = NULL; + target_entity_identifier_copy = NULL; + relation_type_copy = NULL; + label_copy = NULL; + justification_copy = NULL; + created_at_copy = NULL; + updated_at_copy = NULL; + +cleanup: + + g_free( + updated_at_copy + ); + + g_free( + created_at_copy + ); + + g_free( + justification_copy + ); + + g_free( + label_copy + ); + + g_free( + relation_type_copy + ); + + g_free( + target_entity_identifier_copy + ); + + g_free( + source_entity_identifier_copy + ); + + g_free( + identifier_copy + ); + + return relation_record; +} + +void relation_record_free( + RelationRecord *relation_record +) +{ + if (relation_record == NULL) + { + return; + } + + g_free( + relation_record->updated_at + ); + + g_free( + relation_record->created_at + ); + + g_free( + relation_record->justification + ); + + g_free( + relation_record->label + ); + + g_free( + relation_record->relation_type + ); + + g_free( + relation_record->target_entity_identifier + ); + + g_free( + relation_record->source_entity_identifier + ); + + g_free( + relation_record->identifier + ); + + g_free( + relation_record + ); +} + +const char *relation_record_get_identifier( + const RelationRecord *relation_record +) +{ + return relation_record != NULL + ? relation_record->identifier + : NULL; +} + +const char *relation_record_get_source_entity_identifier( + const RelationRecord *relation_record +) +{ + return relation_record != NULL + ? relation_record->source_entity_identifier + : NULL; +} + +const char *relation_record_get_target_entity_identifier( + const RelationRecord *relation_record +) +{ + return relation_record != NULL + ? relation_record->target_entity_identifier + : NULL; +} + +const char *relation_record_get_relation_type( + const RelationRecord *relation_record +) +{ + return relation_record != NULL + ? relation_record->relation_type + : NULL; +} + +const char *relation_record_get_label( + const RelationRecord *relation_record +) +{ + return relation_record != NULL + ? relation_record->label + : NULL; +} + +const char *relation_record_get_justification( + const RelationRecord *relation_record +) +{ + return relation_record != NULL + ? relation_record->justification + : NULL; +} + +gint relation_record_get_confidence( + const RelationRecord *relation_record +) +{ + return relation_record != NULL + ? relation_record->confidence + : 0; +} + +const char *relation_record_get_created_at( + const RelationRecord *relation_record +) +{ + return relation_record != NULL + ? relation_record->created_at + : NULL; +} + +const char *relation_record_get_updated_at( + const RelationRecord *relation_record +) +{ + return relation_record != NULL + ? relation_record->updated_at + : NULL; +} + +RelationStatus relation_record_get_status( + const RelationRecord *relation_record +) +{ + return relation_record != NULL + ? relation_record->status + : RELATION_STATUS_UNKNOWN; +} diff --git a/tests/test_relation_dao.c b/tests/test_relation_dao.c new file mode 100644 index 0000000..244a302 --- /dev/null +++ b/tests/test_relation_dao.c @@ -0,0 +1,2265 @@ +/****************************************************************************** + * @file test_relation_dao.c + * @brief Tests du DAO des relations entre entités. + ******************************************************************************/ + +#include "dao/entity_dao.h" +#include "dao/relation_dao.h" + +#include "database/database.h" +#include "database/statement.h" +#include "models/entity_record.h" +#include "models/relation_record.h" + +#include +#include +#include +#include + +#include +#include + +#define TEST_ENTITY_A_IDENTIFIER \ + "10000000-0000-4000-8000-000000000001" + +#define TEST_ENTITY_B_IDENTIFIER \ + "20000000-0000-4000-8000-000000000001" + +#define TEST_ENTITY_C_IDENTIFIER \ + "30000000-0000-4000-8000-000000000001" + +/** + * @brief Environnement temporaire d'un test RelationDao. + */ +typedef struct +{ + char *temporary_directory; + char *database_path; + + Database *database; + EntityDao *entity_dao; + RelationDao *relation_dao; +} TestRelationDaoFixture; + +/** + * @brief Vérifie le domaine et le code d'une erreur RelationDao. + */ +static void test_relation_dao_assert_error( + const GError *error, + RelationDaoError expected_code +) +{ + assert(error != NULL); + + assert( + error->domain == + RELATION_DAO_ERROR + ); + + assert( + error->code == + (gint) expected_code + ); + + assert(error->message != NULL); + assert(error->message[0] != '\0'); +} + +/** + * @brief Crée une base temporaire et ses DAO. + */ +static TestRelationDaoFixture test_relation_dao_fixture_create(void) +{ + TestRelationDaoFixture fixture = + {0}; + + GError *error = + NULL; + + fixture.temporary_directory = + g_dir_make_tmp( + "labfy-relation-dao-test-XXXXXX", + &error + ); + + assert(fixture.temporary_directory != NULL); + assert(error == NULL); + + fixture.database_path = + g_build_filename( + fixture.temporary_directory, + "Enquete.sqlite", + NULL + ); + + assert(fixture.database_path != NULL); + + assert( + database_initialize( + fixture.database_path, + "Enquete_RelationDao", + fixture.temporary_directory + ) + ); + + fixture.database = + database_open( + fixture.database_path + ); + + assert(fixture.database != NULL); + + fixture.entity_dao = + entity_dao_new( + fixture.database, + &error + ); + + assert(fixture.entity_dao != NULL); + assert(error == NULL); + + fixture.relation_dao = + relation_dao_new( + fixture.database, + &error + ); + + assert(fixture.relation_dao != NULL); + assert(error == NULL); + + return fixture; +} + +/** + * @brief Libère la fixture et supprime sa base temporaire. + */ +static void test_relation_dao_fixture_clear( + TestRelationDaoFixture *fixture +) +{ + assert(fixture != NULL); + + relation_dao_free( + fixture->relation_dao + ); + + entity_dao_free( + fixture->entity_dao + ); + + database_close( + fixture->database + ); + + assert( + g_remove( + fixture->database_path + ) == 0 + ); + + assert( + g_rmdir( + fixture->temporary_directory + ) == 0 + ); + + g_free( + fixture->database_path + ); + + g_free( + fixture->temporary_directory + ); + + fixture->relation_dao = + NULL; + + fixture->entity_dao = + NULL; + + fixture->database = + NULL; + + fixture->database_path = + NULL; + + fixture->temporary_directory = + NULL; +} + +/** + * @brief Crée une entité de test. + */ +static EntityRecord *test_relation_dao_create_entity( + const char *identifier, + const char *value +) +{ + EntityRecord *entity_record = + NULL; + + GError *error = + NULL; + + entity_record = + entity_record_new( + identifier, + "person", + value, + NULL, + NULL, + 80, + "2026-07-20T20:00:00Z", + "2026-07-20T20:00:00Z", + ENTITY_STATUS_ACTIVE, + &error + ); + + assert(entity_record != NULL); + assert(error == NULL); + + return entity_record; +} + +/** + * @brief Insère une entité de test. + */ +static void test_relation_dao_insert_entity( + TestRelationDaoFixture *fixture, + const char *identifier, + const char *value +) +{ + EntityRecord *entity_record = + NULL; + + GError *error = + NULL; + + assert(fixture != NULL); + + entity_record = + test_relation_dao_create_entity( + identifier, + value + ); + + assert( + entity_dao_insert( + fixture->entity_dao, + entity_record, + &error + ) + ); + + assert(error == NULL); + + entity_record_free( + entity_record + ); +} + +/** + * @brief Insère les trois entités couramment utilisées par les tests. + */ +static void test_relation_dao_insert_standard_entities( + TestRelationDaoFixture *fixture +) +{ + test_relation_dao_insert_entity( + fixture, + TEST_ENTITY_A_IDENTIFIER, + "Personne A" + ); + + test_relation_dao_insert_entity( + fixture, + TEST_ENTITY_B_IDENTIFIER, + "Personne B" + ); + + test_relation_dao_insert_entity( + fixture, + TEST_ENTITY_C_IDENTIFIER, + "Personne C" + ); +} + +/** + * @brief Crée une relation de test. + */ +static RelationRecord *test_relation_dao_create_relation( + const char *identifier, + const char *source_entity_identifier, + const char *target_entity_identifier, + const char *relation_type, + const char *label, + const char *justification, + gint confidence, + const char *created_at, + const char *updated_at, + RelationStatus status +) +{ + RelationRecord *relation_record = + NULL; + + GError *error = + NULL; + + relation_record = + relation_record_new( + identifier, + source_entity_identifier, + target_entity_identifier, + relation_type, + label, + justification, + confidence, + created_at, + updated_at, + status, + &error + ); + + assert(relation_record != NULL); + assert(error == NULL); + + return relation_record; +} + +/** + * @brief Insère une relation et vérifie le succès. + */ +static void test_relation_dao_insert_relation( + TestRelationDaoFixture *fixture, + RelationRecord *relation_record +) +{ + GError *error = + NULL; + + assert(fixture != NULL); + assert(relation_record != NULL); + + assert( + relation_dao_insert( + fixture->relation_dao, + relation_record, + &error + ) + ); + + assert(error == NULL); +} + +/** + * @brief Retourne le nombre de relations persistées. + */ +static int64_t test_relation_dao_count_rows( + Database *database +) +{ + DatabaseStatement *statement = + NULL; + + int64_t relation_count = + -1; + + assert(database != NULL); + + statement = + database_statement_prepare( + database, + "SELECT COUNT(*) " + "FROM relations;" + ); + + assert(statement != NULL); + + assert( + database_statement_step( + statement + ) == DATABASE_STATEMENT_STEP_ROW + ); + + assert( + database_statement_column_int64( + statement, + 0, + &relation_count + ) + ); + + assert( + database_statement_step( + statement + ) == DATABASE_STATEMENT_STEP_DONE + ); + + database_statement_finalize( + statement + ); + + return relation_count; +} + +/** + * @brief Vérifie le refus d'une connexion absente. + */ +static void test_relation_dao_new_null_database(void) +{ + RelationDao *relation_dao = + NULL; + + GError *error = + NULL; + + relation_dao = + relation_dao_new( + NULL, + &error + ); + + assert(relation_dao == NULL); + + test_relation_dao_assert_error( + error, + RELATION_DAO_ERROR_INVALID_ARGUMENT + ); + + g_clear_error( + &error + ); +} + +/** + * @brief Vérifie la création et la destruction du DAO. + */ +static void test_relation_dao_lifecycle(void) +{ + Database *database = + NULL; + + RelationDao *relation_dao = + NULL; + + GError *error = + NULL; + + database = + database_open( + ":memory:" + ); + + assert(database != NULL); + + relation_dao = + relation_dao_new( + database, + &error + ); + + assert(relation_dao != NULL); + assert(error == NULL); + + relation_dao_free( + relation_dao + ); + + relation_dao_free( + NULL + ); + + database_close( + database + ); +} + +/** + * @brief Vérifie l'insertion complète d'une relation. + */ +static void test_relation_dao_insert_valid_full(void) +{ + TestRelationDaoFixture fixture = + test_relation_dao_fixture_create(); + + RelationRecord *relation_record = + NULL; + + DatabaseStatement *statement = + NULL; + + char *identifier = + NULL; + + char *source_identifier = + NULL; + + char *target_identifier = + NULL; + + char *relation_type = + NULL; + + char *label = + NULL; + + char *justification = + NULL; + + char *created_at = + NULL; + + char *updated_at = + NULL; + + char *status = + NULL; + + int64_t confidence = + -1; + + test_relation_dao_insert_standard_entities( + &fixture + ); + + relation_record = + test_relation_dao_create_relation( + "40000000-0000-4000-8000-000000000001", + TEST_ENTITY_A_IDENTIFIER, + TEST_ENTITY_B_IDENTIFIER, + "uses", + "Utilise cette identité", + "Association observée dans une conversation.", + 90, + "2026-07-20T20:10:00Z", + "2026-07-20T20:15:00Z", + RELATION_STATUS_DISPUTED + ); + + test_relation_dao_insert_relation( + &fixture, + relation_record + ); + + statement = + database_statement_prepare( + fixture.database, + "SELECT " + " id," + " entite_source_id," + " entite_cible_id," + " type_relation," + " label," + " justification," + " confiance," + " created_at," + " updated_at," + " status " + "FROM relations " + "WHERE id = ?;" + ); + + assert(statement != NULL); + + assert( + database_statement_bind_text( + statement, + 1, + "40000000-0000-4000-8000-000000000001" + ) + ); + + assert( + database_statement_step( + statement + ) == DATABASE_STATEMENT_STEP_ROW + ); + + assert( + database_statement_column_text( + statement, + 0, + &identifier + ) + ); + + assert( + database_statement_column_text( + statement, + 1, + &source_identifier + ) + ); + + assert( + database_statement_column_text( + statement, + 2, + &target_identifier + ) + ); + + assert( + database_statement_column_text( + statement, + 3, + &relation_type + ) + ); + + assert( + database_statement_column_text( + statement, + 4, + &label + ) + ); + + assert( + database_statement_column_text( + statement, + 5, + &justification + ) + ); + + assert( + database_statement_column_int64( + statement, + 6, + &confidence + ) + ); + + assert( + database_statement_column_text( + statement, + 7, + &created_at + ) + ); + + assert( + database_statement_column_text( + statement, + 8, + &updated_at + ) + ); + + assert( + database_statement_column_text( + statement, + 9, + &status + ) + ); + + assert( + database_statement_step( + statement + ) == DATABASE_STATEMENT_STEP_DONE + ); + + assert( + strcmp( + identifier, + "40000000-0000-4000-8000-000000000001" + ) == 0 + ); + + assert( + strcmp( + source_identifier, + TEST_ENTITY_A_IDENTIFIER + ) == 0 + ); + + assert( + strcmp( + target_identifier, + TEST_ENTITY_B_IDENTIFIER + ) == 0 + ); + + assert( + strcmp( + relation_type, + "uses" + ) == 0 + ); + + assert( + strcmp( + label, + "Utilise cette identité" + ) == 0 + ); + + assert( + strcmp( + justification, + "Association observée dans une conversation." + ) == 0 + ); + + assert(confidence == 90); + + assert( + strcmp( + created_at, + "2026-07-20T20:10:00Z" + ) == 0 + ); + + assert( + strcmp( + updated_at, + "2026-07-20T20:15:00Z" + ) == 0 + ); + + assert( + strcmp( + status, + "disputed" + ) == 0 + ); + + database_statement_finalize( + statement + ); + + g_free( + status + ); + + g_free( + updated_at + ); + + g_free( + created_at + ); + + g_free( + justification + ); + + g_free( + label + ); + + g_free( + relation_type + ); + + g_free( + target_identifier + ); + + g_free( + source_identifier + ); + + g_free( + identifier + ); + + relation_record_free( + relation_record + ); + + test_relation_dao_fixture_clear( + &fixture + ); +} + +/** + * @brief Vérifie l'insertion avec champs facultatifs absents. + */ +static void test_relation_dao_insert_optional_null(void) +{ + TestRelationDaoFixture fixture = + test_relation_dao_fixture_create(); + + RelationRecord *relation_record = + NULL; + + DatabaseStatement *statement = + NULL; + + bool label_is_null = + false; + + bool justification_is_null = + false; + + test_relation_dao_insert_standard_entities( + &fixture + ); + + relation_record = + test_relation_dao_create_relation( + "40000000-0000-4000-8000-000000000002", + TEST_ENTITY_A_IDENTIFIER, + TEST_ENTITY_B_IDENTIFIER, + "knows", + NULL, + NULL, + 50, + "2026-07-20T20:20:00Z", + "2026-07-20T20:20:00Z", + RELATION_STATUS_ACTIVE + ); + + test_relation_dao_insert_relation( + &fixture, + relation_record + ); + + statement = + database_statement_prepare( + fixture.database, + "SELECT " + " label," + " justification " + "FROM relations " + "WHERE id = ?;" + ); + + assert(statement != NULL); + + assert( + database_statement_bind_text( + statement, + 1, + "40000000-0000-4000-8000-000000000002" + ) + ); + + assert( + database_statement_step( + statement + ) == DATABASE_STATEMENT_STEP_ROW + ); + + assert( + database_statement_column_is_null( + statement, + 0, + &label_is_null + ) + ); + + assert( + database_statement_column_is_null( + statement, + 1, + &justification_is_null + ) + ); + + assert(label_is_null); + assert(justification_is_null); + + database_statement_finalize( + statement + ); + + relation_record_free( + relation_record + ); + + test_relation_dao_fixture_clear( + &fixture + ); +} + +/** + * @brief Vérifie le refus d'un UUID de relation dupliqué. + */ +static void test_relation_dao_insert_duplicate_identifier(void) +{ + TestRelationDaoFixture fixture = + test_relation_dao_fixture_create(); + + RelationRecord *first_record = + NULL; + + RelationRecord *second_record = + NULL; + + GError *error = + NULL; + + test_relation_dao_insert_standard_entities( + &fixture + ); + + first_record = + test_relation_dao_create_relation( + "40000000-0000-4000-8000-000000000003", + TEST_ENTITY_A_IDENTIFIER, + TEST_ENTITY_B_IDENTIFIER, + "uses", + NULL, + NULL, + 80, + "2026-07-20T20:30:00Z", + "2026-07-20T20:30:00Z", + RELATION_STATUS_ACTIVE + ); + + second_record = + test_relation_dao_create_relation( + "40000000-0000-4000-8000-000000000003", + TEST_ENTITY_A_IDENTIFIER, + TEST_ENTITY_C_IDENTIFIER, + "controls", + NULL, + NULL, + 70, + "2026-07-20T20:31:00Z", + "2026-07-20T20:31:00Z", + RELATION_STATUS_ACTIVE + ); + + test_relation_dao_insert_relation( + &fixture, + first_record + ); + + assert( + !relation_dao_insert( + fixture.relation_dao, + second_record, + &error + ) + ); + + test_relation_dao_assert_error( + error, + RELATION_DAO_ERROR_CONSTRAINT + ); + + assert( + strstr( + error->message, + "identifiant" + ) != NULL + ); + + assert( + test_relation_dao_count_rows( + fixture.database + ) == 1 + ); + + g_clear_error( + &error + ); + + relation_record_free( + second_record + ); + + relation_record_free( + first_record + ); + + test_relation_dao_fixture_clear( + &fixture + ); +} + +/** + * @brief Vérifie le refus d'une entité source inexistante. + */ +static void test_relation_dao_insert_missing_source(void) +{ + TestRelationDaoFixture fixture = + test_relation_dao_fixture_create(); + + RelationRecord *relation_record = + NULL; + + GError *error = + NULL; + + test_relation_dao_insert_entity( + &fixture, + TEST_ENTITY_B_IDENTIFIER, + "Personne B" + ); + + relation_record = + test_relation_dao_create_relation( + "40000000-0000-4000-8000-000000000004", + TEST_ENTITY_A_IDENTIFIER, + TEST_ENTITY_B_IDENTIFIER, + "uses", + NULL, + NULL, + 80, + "2026-07-20T20:40:00Z", + "2026-07-20T20:40:00Z", + RELATION_STATUS_ACTIVE + ); + + assert( + !relation_dao_insert( + fixture.relation_dao, + relation_record, + &error + ) + ); + + test_relation_dao_assert_error( + error, + RELATION_DAO_ERROR_CONSTRAINT + ); + + assert( + strstr( + error->message, + "source" + ) != NULL + ); + + g_clear_error( + &error + ); + + relation_record_free( + relation_record + ); + + test_relation_dao_fixture_clear( + &fixture + ); +} + +/** + * @brief Vérifie le refus d'une entité cible inexistante. + */ +static void test_relation_dao_insert_missing_target(void) +{ + TestRelationDaoFixture fixture = + test_relation_dao_fixture_create(); + + RelationRecord *relation_record = + NULL; + + GError *error = + NULL; + + test_relation_dao_insert_entity( + &fixture, + TEST_ENTITY_A_IDENTIFIER, + "Personne A" + ); + + relation_record = + test_relation_dao_create_relation( + "40000000-0000-4000-8000-000000000005", + TEST_ENTITY_A_IDENTIFIER, + TEST_ENTITY_B_IDENTIFIER, + "uses", + NULL, + NULL, + 80, + "2026-07-20T20:50:00Z", + "2026-07-20T20:50:00Z", + RELATION_STATUS_ACTIVE + ); + + assert( + !relation_dao_insert( + fixture.relation_dao, + relation_record, + &error + ) + ); + + test_relation_dao_assert_error( + error, + RELATION_DAO_ERROR_CONSTRAINT + ); + + assert( + strstr( + error->message, + "cible" + ) != NULL + ); + + g_clear_error( + &error + ); + + relation_record_free( + relation_record + ); + + test_relation_dao_fixture_clear( + &fixture + ); +} + +/** + * @brief Vérifie le refus d'un doublon source, cible et type. + */ +static void test_relation_dao_insert_duplicate_triple(void) +{ + TestRelationDaoFixture fixture = + test_relation_dao_fixture_create(); + + RelationRecord *first_record = + NULL; + + RelationRecord *second_record = + NULL; + + GError *error = + NULL; + + test_relation_dao_insert_standard_entities( + &fixture + ); + + first_record = + test_relation_dao_create_relation( + "40000000-0000-4000-8000-000000000006", + TEST_ENTITY_A_IDENTIFIER, + TEST_ENTITY_B_IDENTIFIER, + "uses", + "Première relation", + NULL, + 80, + "2026-07-20T21:00:00Z", + "2026-07-20T21:00:00Z", + RELATION_STATUS_ACTIVE + ); + + second_record = + test_relation_dao_create_relation( + "40000000-0000-4000-8000-000000000007", + TEST_ENTITY_A_IDENTIFIER, + TEST_ENTITY_B_IDENTIFIER, + "uses", + "Deuxième relation", + NULL, + 40, + "2026-07-20T21:01:00Z", + "2026-07-20T21:01:00Z", + RELATION_STATUS_DISPUTED + ); + + test_relation_dao_insert_relation( + &fixture, + first_record + ); + + assert( + !relation_dao_insert( + fixture.relation_dao, + second_record, + &error + ) + ); + + test_relation_dao_assert_error( + error, + RELATION_DAO_ERROR_CONSTRAINT + ); + + assert( + strstr( + error->message, + "orientée" + ) != NULL + ); + + assert( + test_relation_dao_count_rows( + fixture.database + ) == 1 + ); + + g_clear_error( + &error + ); + + relation_record_free( + second_record + ); + + relation_record_free( + first_record + ); + + test_relation_dao_fixture_clear( + &fixture + ); +} + +/** + * @brief Vérifie plusieurs types et les deux orientations A-B et B-A. + */ +static void test_relation_dao_orientation_and_multiple_types(void) +{ + TestRelationDaoFixture fixture = + test_relation_dao_fixture_create(); + + RelationRecord *first_record = + NULL; + + RelationRecord *second_record = + NULL; + + RelationRecord *reverse_record = + NULL; + + test_relation_dao_insert_standard_entities( + &fixture + ); + + first_record = + test_relation_dao_create_relation( + "40000000-0000-4000-8000-000000000008", + TEST_ENTITY_A_IDENTIFIER, + TEST_ENTITY_B_IDENTIFIER, + "uses", + NULL, + NULL, + 80, + "2026-07-20T21:10:00Z", + "2026-07-20T21:10:00Z", + RELATION_STATUS_ACTIVE + ); + + second_record = + test_relation_dao_create_relation( + "40000000-0000-4000-8000-000000000009", + TEST_ENTITY_A_IDENTIFIER, + TEST_ENTITY_B_IDENTIFIER, + "controls", + NULL, + NULL, + 70, + "2026-07-20T21:11:00Z", + "2026-07-20T21:11:00Z", + RELATION_STATUS_ACTIVE + ); + + reverse_record = + test_relation_dao_create_relation( + "40000000-0000-4000-8000-000000000010", + TEST_ENTITY_B_IDENTIFIER, + TEST_ENTITY_A_IDENTIFIER, + "uses", + NULL, + NULL, + 60, + "2026-07-20T21:12:00Z", + "2026-07-20T21:12:00Z", + RELATION_STATUS_ACTIVE + ); + + test_relation_dao_insert_relation( + &fixture, + first_record + ); + + test_relation_dao_insert_relation( + &fixture, + second_record + ); + + test_relation_dao_insert_relation( + &fixture, + reverse_record + ); + + assert( + test_relation_dao_count_rows( + fixture.database + ) == 3 + ); + + relation_record_free( + reverse_record + ); + + relation_record_free( + second_record + ); + + relation_record_free( + first_record + ); + + test_relation_dao_fixture_clear( + &fixture + ); +} + +/** + * @brief Vérifie la recherche d'une relation existante. + */ +static void test_relation_dao_find_existing(void) +{ + TestRelationDaoFixture fixture = + test_relation_dao_fixture_create(); + + RelationRecord *inserted_record = + NULL; + + RelationRecord *loaded_record = + NULL; + + GError *error = + NULL; + + test_relation_dao_insert_standard_entities( + &fixture + ); + + inserted_record = + test_relation_dao_create_relation( + "40000000-0000-4000-8000-000000000011", + TEST_ENTITY_A_IDENTIFIER, + TEST_ENTITY_C_IDENTIFIER, + "knows", + "Connaît cette personne", + "Déclaration issue d'un message.", + 65, + "2026-07-20T21:20:00Z", + "2026-07-20T21:25:00Z", + RELATION_STATUS_ARCHIVED + ); + + test_relation_dao_insert_relation( + &fixture, + inserted_record + ); + + loaded_record = + relation_dao_find_by_identifier( + fixture.relation_dao, + "40000000-0000-4000-8000-000000000011", + &error + ); + + assert(loaded_record != NULL); + assert(error == NULL); + + assert( + strcmp( + relation_record_get_identifier( + loaded_record + ), + "40000000-0000-4000-8000-000000000011" + ) == 0 + ); + + assert( + strcmp( + relation_record_get_source_entity_identifier( + loaded_record + ), + TEST_ENTITY_A_IDENTIFIER + ) == 0 + ); + + assert( + strcmp( + relation_record_get_target_entity_identifier( + loaded_record + ), + TEST_ENTITY_C_IDENTIFIER + ) == 0 + ); + + assert( + strcmp( + relation_record_get_relation_type( + loaded_record + ), + "knows" + ) == 0 + ); + + assert( + relation_record_get_confidence( + loaded_record + ) == 65 + ); + + assert( + relation_record_get_status( + loaded_record + ) == RELATION_STATUS_ARCHIVED + ); + + relation_record_free( + loaded_record + ); + + relation_record_free( + inserted_record + ); + + test_relation_dao_fixture_clear( + &fixture + ); +} + +/** + * @brief Vérifie qu'une relation absente retourne NULL sans erreur. + */ +static void test_relation_dao_find_missing(void) +{ + TestRelationDaoFixture fixture = + test_relation_dao_fixture_create(); + + RelationRecord *relation_record = + NULL; + + GError *error = + NULL; + + relation_record = + relation_dao_find_by_identifier( + fixture.relation_dao, + "40000000-0000-4000-8000-000000000012", + &error + ); + + assert(relation_record == NULL); + assert(error == NULL); + + test_relation_dao_fixture_clear( + &fixture + ); +} + +/** + * @brief Vérifie le refus d'un identifiant de recherche invalide. + */ +static void test_relation_dao_find_invalid_identifier(void) +{ + TestRelationDaoFixture fixture = + test_relation_dao_fixture_create(); + + RelationRecord *relation_record = + NULL; + + GError *error = + NULL; + + relation_record = + relation_dao_find_by_identifier( + fixture.relation_dao, + "relation-invalide", + &error + ); + + assert(relation_record == NULL); + + test_relation_dao_assert_error( + error, + RELATION_DAO_ERROR_INVALID_ARGUMENT + ); + + g_clear_error( + &error + ); + + test_relation_dao_fixture_clear( + &fixture + ); +} + +/** + * @brief Vérifie le chargement d'une liste vide. + */ +static void test_relation_dao_list_empty(void) +{ + TestRelationDaoFixture fixture = + test_relation_dao_fixture_create(); + + GPtrArray *relations = + NULL; + + GError *error = + NULL; + + relations = + relation_dao_list_all( + fixture.relation_dao, + &error + ); + + assert(relations != NULL); + assert(error == NULL); + assert(relations->len == 0); + + g_ptr_array_unref( + relations + ); + + test_relation_dao_fixture_clear( + &fixture + ); +} + +/** + * @brief Vérifie l'ordre stable de la liste. + */ +static void test_relation_dao_list_order(void) +{ + TestRelationDaoFixture fixture = + test_relation_dao_fixture_create(); + + RelationRecord *first_record = + NULL; + + RelationRecord *second_record = + NULL; + + RelationRecord *third_record = + NULL; + + const RelationRecord *first_loaded = + NULL; + + const RelationRecord *second_loaded = + NULL; + + const RelationRecord *third_loaded = + NULL; + + GPtrArray *relations = + NULL; + + GError *error = + NULL; + + test_relation_dao_insert_standard_entities( + &fixture + ); + + third_record = + test_relation_dao_create_relation( + "60000000-0000-4000-8000-000000000003", + TEST_ENTITY_C_IDENTIFIER, + TEST_ENTITY_A_IDENTIFIER, + "knows", + NULL, + NULL, + 50, + "2026-07-20T22:00:00Z", + "2026-07-20T22:00:00Z", + RELATION_STATUS_ACTIVE + ); + + second_record = + test_relation_dao_create_relation( + "60000000-0000-4000-8000-000000000002", + TEST_ENTITY_B_IDENTIFIER, + TEST_ENTITY_C_IDENTIFIER, + "uses", + NULL, + NULL, + 50, + "2026-07-20T21:50:00Z", + "2026-07-20T21:50:00Z", + RELATION_STATUS_ACTIVE + ); + + first_record = + test_relation_dao_create_relation( + "60000000-0000-4000-8000-000000000001", + TEST_ENTITY_A_IDENTIFIER, + TEST_ENTITY_B_IDENTIFIER, + "controls", + NULL, + NULL, + 50, + "2026-07-20T21:50:00Z", + "2026-07-20T21:50:00Z", + RELATION_STATUS_ACTIVE + ); + + test_relation_dao_insert_relation( + &fixture, + third_record + ); + + test_relation_dao_insert_relation( + &fixture, + second_record + ); + + test_relation_dao_insert_relation( + &fixture, + first_record + ); + + relations = + relation_dao_list_all( + fixture.relation_dao, + &error + ); + + assert(relations != NULL); + assert(error == NULL); + assert(relations->len == 3); + + first_loaded = + g_ptr_array_index( + relations, + 0 + ); + + second_loaded = + g_ptr_array_index( + relations, + 1 + ); + + third_loaded = + g_ptr_array_index( + relations, + 2 + ); + + assert( + strcmp( + relation_record_get_identifier( + first_loaded + ), + "60000000-0000-4000-8000-000000000001" + ) == 0 + ); + + assert( + strcmp( + relation_record_get_identifier( + second_loaded + ), + "60000000-0000-4000-8000-000000000002" + ) == 0 + ); + + assert( + strcmp( + relation_record_get_identifier( + third_loaded + ), + "60000000-0000-4000-8000-000000000003" + ) == 0 + ); + + g_ptr_array_unref( + relations + ); + + relation_record_free( + first_record + ); + + relation_record_free( + second_record + ); + + relation_record_free( + third_record + ); + + test_relation_dao_fixture_clear( + &fixture + ); +} + +/** + * @brief Vérifie le comptage d'une table vide. + */ +static void test_relation_dao_count_empty(void) +{ + TestRelationDaoFixture fixture = + test_relation_dao_fixture_create(); + + guint64 relation_count = + 42; + + GError *error = + NULL; + + assert( + relation_dao_count( + fixture.relation_dao, + &relation_count, + &error + ) + ); + + assert(error == NULL); + assert(relation_count == 0); + + test_relation_dao_fixture_clear( + &fixture + ); +} + +/** + * @brief Vérifie le comptage de plusieurs relations. + */ +static void test_relation_dao_count_records(void) +{ + TestRelationDaoFixture fixture = + test_relation_dao_fixture_create(); + + RelationRecord *first_record = + NULL; + + RelationRecord *second_record = + NULL; + + guint64 relation_count = + 0; + + GError *error = + NULL; + + test_relation_dao_insert_standard_entities( + &fixture + ); + + first_record = + test_relation_dao_create_relation( + "70000000-0000-4000-8000-000000000001", + TEST_ENTITY_A_IDENTIFIER, + TEST_ENTITY_B_IDENTIFIER, + "uses", + NULL, + NULL, + 50, + "2026-07-20T22:10:00Z", + "2026-07-20T22:10:00Z", + RELATION_STATUS_ACTIVE + ); + + second_record = + test_relation_dao_create_relation( + "70000000-0000-4000-8000-000000000002", + TEST_ENTITY_B_IDENTIFIER, + TEST_ENTITY_C_IDENTIFIER, + "controls", + NULL, + NULL, + 50, + "2026-07-20T22:11:00Z", + "2026-07-20T22:11:00Z", + RELATION_STATUS_ACTIVE + ); + + test_relation_dao_insert_relation( + &fixture, + first_record + ); + + test_relation_dao_insert_relation( + &fixture, + second_record + ); + + assert( + relation_dao_count( + fixture.relation_dao, + &relation_count, + &error + ) + ); + + assert(error == NULL); + assert(relation_count == 2); + + relation_record_free( + second_record + ); + + relation_record_free( + first_record + ); + + test_relation_dao_fixture_clear( + &fixture + ); +} + +/** + * @brief Vérifie les arguments invalides des opérations publiques. + */ +static void test_relation_dao_invalid_arguments(void) +{ + TestRelationDaoFixture fixture = + test_relation_dao_fixture_create(); + + GPtrArray *relations = + NULL; + + guint64 relation_count = + 123; + + GError *error = + NULL; + + assert( + !relation_dao_insert( + fixture.relation_dao, + NULL, + &error + ) + ); + + test_relation_dao_assert_error( + error, + RELATION_DAO_ERROR_INVALID_ARGUMENT + ); + + g_clear_error( + &error + ); + + relations = + relation_dao_list_all( + NULL, + &error + ); + + assert(relations == NULL); + + test_relation_dao_assert_error( + error, + RELATION_DAO_ERROR_INVALID_ARGUMENT + ); + + g_clear_error( + &error + ); + + assert( + !relation_dao_count( + fixture.relation_dao, + NULL, + &error + ) + ); + + test_relation_dao_assert_error( + error, + RELATION_DAO_ERROR_INVALID_ARGUMENT + ); + + g_clear_error( + &error + ); + + assert( + !relation_dao_count( + NULL, + &relation_count, + &error + ) + ); + + test_relation_dao_assert_error( + error, + RELATION_DAO_ERROR_INVALID_ARGUMENT + ); + + assert(relation_count == 123); + + g_clear_error( + &error + ); + + test_relation_dao_fixture_clear( + &fixture + ); +} + +/** + * @brief Vérifie le signalement d'un schéma absent. + */ +static void test_relation_dao_missing_table(void) +{ + Database *database = + NULL; + + RelationDao *relation_dao = + NULL; + + GPtrArray *relations = + NULL; + + GError *error = + NULL; + + database = + database_open( + ":memory:" + ); + + assert(database != NULL); + + relation_dao = + relation_dao_new( + database, + &error + ); + + assert(relation_dao != NULL); + assert(error == NULL); + + g_test_expect_message( + NULL, + G_LOG_LEVEL_WARNING, + "*Impossible de préparer la requête SQL*" + ); + + relations = + relation_dao_list_all( + relation_dao, + &error + ); + + g_test_assert_expected_messages(); + + assert(relations == NULL); + + test_relation_dao_assert_error( + error, + RELATION_DAO_ERROR_SCHEMA + ); + + g_clear_error( + &error + ); + + relation_dao_free( + relation_dao + ); + + database_close( + database + ); +} + +/** + * @brief Vérifie la contrainte interdisant une relation réflexive. + * + * Le modèle RelationRecord interdit déjà ce cas. Cette insertion directe + * vérifie que le schéma SQLite constitue également une seconde barrière. + */ +static void test_relation_dao_reflexive_schema_constraint(void) +{ + TestRelationDaoFixture fixture = + test_relation_dao_fixture_create(); + + DatabaseStatement *statement = + NULL; + + test_relation_dao_insert_entity( + &fixture, + TEST_ENTITY_A_IDENTIFIER, + "Personne A" + ); + + statement = + database_statement_prepare( + fixture.database, + "INSERT INTO relations" + "(" + " id," + " entite_source_id," + " entite_cible_id," + " type_relation," + " confiance," + " created_at," + " updated_at," + " status" + ")" + "VALUES" + "(" + " ?," + " ?," + " ?," + " ?," + " ?," + " ?," + " ?," + " ?" + ");" + ); + + assert(statement != NULL); + + assert( + database_statement_bind_text( + statement, + 1, + "80000000-0000-4000-8000-000000000001" + ) + ); + + assert( + database_statement_bind_text( + statement, + 2, + TEST_ENTITY_A_IDENTIFIER + ) + ); + + assert( + database_statement_bind_text( + statement, + 3, + TEST_ENTITY_A_IDENTIFIER + ) + ); + + assert( + database_statement_bind_text( + statement, + 4, + "uses" + ) + ); + + assert( + database_statement_bind_int64( + statement, + 5, + 50 + ) + ); + + assert( + database_statement_bind_text( + statement, + 6, + "2026-07-20T22:20:00Z" + ) + ); + + assert( + database_statement_bind_text( + statement, + 7, + "2026-07-20T22:20:00Z" + ) + ); + + assert( + database_statement_bind_text( + statement, + 8, + "active" + ) + ); + + g_test_expect_message( + NULL, + G_LOG_LEVEL_WARNING, + "*CHECK constraint failed*" + ); + + assert( + database_statement_step( + statement + ) == DATABASE_STATEMENT_STEP_ERROR + ); + + g_test_assert_expected_messages(); + + database_statement_finalize( + statement + ); + + assert( + test_relation_dao_count_rows( + fixture.database + ) == 0 + ); + + test_relation_dao_fixture_clear( + &fixture + ); +} + +/** + * @brief Vérifie les contraintes ON DELETE RESTRICT des deux entités. + */ +static void test_relation_dao_foreign_key_restrict(void) +{ + TestRelationDaoFixture fixture = + test_relation_dao_fixture_create(); + + RelationRecord *relation_record = + NULL; + + DatabaseStatement *statement = + NULL; + + test_relation_dao_insert_standard_entities( + &fixture + ); + + relation_record = + test_relation_dao_create_relation( + "90000000-0000-4000-8000-000000000001", + TEST_ENTITY_A_IDENTIFIER, + TEST_ENTITY_B_IDENTIFIER, + "uses", + NULL, + NULL, + 50, + "2026-07-20T22:30:00Z", + "2026-07-20T22:30:00Z", + RELATION_STATUS_ACTIVE + ); + + test_relation_dao_insert_relation( + &fixture, + relation_record + ); + + statement = + database_statement_prepare( + fixture.database, + "DELETE FROM entites " + "WHERE id = ?;" + ); + + assert(statement != NULL); + + assert( + database_statement_bind_text( + statement, + 1, + TEST_ENTITY_A_IDENTIFIER + ) + ); + + g_test_expect_message( + NULL, + G_LOG_LEVEL_WARNING, + "*FOREIGN KEY constraint failed*" + ); + + assert( + database_statement_step( + statement + ) == DATABASE_STATEMENT_STEP_ERROR + ); + + g_test_assert_expected_messages(); + + database_statement_finalize( + statement + ); + + statement = + database_statement_prepare( + fixture.database, + "DELETE FROM entites " + "WHERE id = ?;" + ); + + assert(statement != NULL); + + assert( + database_statement_bind_text( + statement, + 1, + TEST_ENTITY_B_IDENTIFIER + ) + ); + + g_test_expect_message( + NULL, + G_LOG_LEVEL_WARNING, + "*FOREIGN KEY constraint failed*" + ); + + assert( + database_statement_step( + statement + ) == DATABASE_STATEMENT_STEP_ERROR + ); + + g_test_assert_expected_messages(); + + database_statement_finalize( + statement + ); + + assert( + test_relation_dao_count_rows( + fixture.database + ) == 1 + ); + + relation_record_free( + relation_record + ); + + test_relation_dao_fixture_clear( + &fixture + ); +} + +int main( + int argc, + char **argv +) +{ + g_test_init( + &argc, + &argv, + NULL + ); + + test_relation_dao_new_null_database(); + test_relation_dao_lifecycle(); + test_relation_dao_insert_valid_full(); + test_relation_dao_insert_optional_null(); + test_relation_dao_insert_duplicate_identifier(); + test_relation_dao_insert_missing_source(); + test_relation_dao_insert_missing_target(); + test_relation_dao_insert_duplicate_triple(); + test_relation_dao_orientation_and_multiple_types(); + test_relation_dao_find_existing(); + test_relation_dao_find_missing(); + test_relation_dao_find_invalid_identifier(); + test_relation_dao_list_empty(); + test_relation_dao_list_order(); + test_relation_dao_count_empty(); + test_relation_dao_count_records(); + test_relation_dao_invalid_arguments(); + test_relation_dao_missing_table(); + test_relation_dao_reflexive_schema_constraint(); + test_relation_dao_foreign_key_restrict(); + + printf( + "RelationDao : tous les tests sont valides.\n" + ); + + return 0; +} diff --git a/tests/test_relation_record.c b/tests/test_relation_record.c new file mode 100644 index 0000000..9072a47 --- /dev/null +++ b/tests/test_relation_record.c @@ -0,0 +1,665 @@ +/****************************************************************************** + * @file test_relation_record.c + * @brief Tests unitaires du modèle RelationRecord. + ******************************************************************************/ + +#include "models/relation_record.h" + +#include + +#define TEST_RELATION_IDENTIFIER \ + "30000000-0000-4000-8000-000000000001" + +#define TEST_SOURCE_IDENTIFIER \ + "10000000-0000-4000-8000-000000000001" + +#define TEST_TARGET_IDENTIFIER \ + "20000000-0000-4000-8000-000000000001" + +typedef struct +{ + const char *identifier; + const char *source_entity_identifier; + const char *target_entity_identifier; + const char *relation_type; + const char *label; + const char *justification; + + gint confidence; + + const char *created_at; + const char *updated_at; + + RelationStatus status; +} TestRelationRecordInput; + +static TestRelationRecordInput test_relation_record_valid_input(void) +{ + return (TestRelationRecordInput) + { + .identifier = TEST_RELATION_IDENTIFIER, + .source_entity_identifier = TEST_SOURCE_IDENTIFIER, + .target_entity_identifier = TEST_TARGET_IDENTIFIER, + .relation_type = "uses", + .label = "Utilise cette adresse", + .justification = "Association observée dans la conversation.", + .confidence = 80, + .created_at = "2026-07-20T20:00:00Z", + .updated_at = "2026-07-20T20:00:00Z", + .status = RELATION_STATUS_ACTIVE + }; +} + +static RelationRecord *test_relation_record_create( + const TestRelationRecordInput *input, + GError **error +) +{ + g_assert_nonnull( + input + ); + + return relation_record_new( + input->identifier, + input->source_entity_identifier, + input->target_entity_identifier, + input->relation_type, + input->label, + input->justification, + input->confidence, + input->created_at, + input->updated_at, + input->status, + error + ); +} + +static void test_relation_record_assert_invalid( + const TestRelationRecordInput *input, + RelationRecordError expected_error +) +{ + RelationRecord *relation_record = + NULL; + + GError *error = + NULL; + + relation_record = + test_relation_record_create( + input, + &error + ); + + g_assert_null( + relation_record + ); + + g_assert_error( + error, + RELATION_RECORD_ERROR, + (gint) expected_error + ); + + g_clear_error( + &error + ); +} + +static void test_relation_record_valid_full(void) +{ + TestRelationRecordInput input = + test_relation_record_valid_input(); + + RelationRecord *relation_record = + NULL; + + GError *error = + NULL; + + relation_record = + test_relation_record_create( + &input, + &error + ); + + g_assert_no_error( + error + ); + + g_assert_nonnull( + relation_record + ); + + g_assert_cmpstr( + relation_record_get_identifier(relation_record), + ==, + TEST_RELATION_IDENTIFIER + ); + + g_assert_cmpstr( + relation_record_get_source_entity_identifier(relation_record), + ==, + TEST_SOURCE_IDENTIFIER + ); + + g_assert_cmpstr( + relation_record_get_target_entity_identifier(relation_record), + ==, + TEST_TARGET_IDENTIFIER + ); + + g_assert_cmpstr( + relation_record_get_relation_type(relation_record), + ==, + "uses" + ); + + g_assert_cmpstr( + relation_record_get_label(relation_record), + ==, + "Utilise cette adresse" + ); + + g_assert_cmpstr( + relation_record_get_justification(relation_record), + ==, + "Association observée dans la conversation." + ); + + g_assert_cmpint( + relation_record_get_confidence(relation_record), + ==, + 80 + ); + + g_assert_cmpstr( + relation_record_get_created_at(relation_record), + ==, + "2026-07-20T20:00:00Z" + ); + + g_assert_cmpstr( + relation_record_get_updated_at(relation_record), + ==, + "2026-07-20T20:00:00Z" + ); + + g_assert_cmpint( + relation_record_get_status(relation_record), + ==, + RELATION_STATUS_ACTIVE + ); + + relation_record_free( + relation_record + ); +} + +static void test_relation_record_optional_fields(void) +{ + TestRelationRecordInput input = + test_relation_record_valid_input(); + + RelationRecord *relation_record = + NULL; + + GError *error = + NULL; + + input.label = " "; + input.justification = NULL; + + relation_record = + test_relation_record_create( + &input, + &error + ); + + g_assert_no_error( + error + ); + + g_assert_null( + relation_record_get_label( + relation_record + ) + ); + + g_assert_null( + relation_record_get_justification( + relation_record + ) + ); + + relation_record_free( + relation_record + ); +} + +static void test_relation_record_values_are_trimmed(void) +{ + TestRelationRecordInput input = + test_relation_record_valid_input(); + + RelationRecord *relation_record = + NULL; + + GError *error = + NULL; + + input.identifier = + " 30000000-0000-4000-8000-000000000001 "; + + input.source_entity_identifier = + " 10000000-0000-4000-8000-000000000001 "; + + input.target_entity_identifier = + "\t20000000-0000-4000-8000-000000000001\t"; + + input.relation_type = + " controls "; + + input.label = + " Contrôle "; + + input.justification = + " Source documentaire. "; + + input.created_at = + " 2026-07-20T20:00:00Z "; + + input.updated_at = + "\t2026-07-20T20:00:00Z\t"; + + relation_record = + test_relation_record_create( + &input, + &error + ); + + g_assert_no_error( + error + ); + + g_assert_cmpstr( + relation_record_get_relation_type(relation_record), + ==, + "controls" + ); + + g_assert_cmpstr( + relation_record_get_label(relation_record), + ==, + "Contrôle" + ); + + g_assert_cmpstr( + relation_record_get_justification(relation_record), + ==, + "Source documentaire." + ); + + relation_record_free( + relation_record + ); +} + +static void test_relation_record_invalid_identifier(void) +{ + TestRelationRecordInput input = + test_relation_record_valid_input(); + + input.identifier = "relation-invalide"; + + test_relation_record_assert_invalid( + &input, + RELATION_RECORD_ERROR_INVALID_IDENTIFIER + ); +} + +static void test_relation_record_invalid_source_identifier(void) +{ + TestRelationRecordInput input = + test_relation_record_valid_input(); + + input.source_entity_identifier = "source-invalide"; + + test_relation_record_assert_invalid( + &input, + RELATION_RECORD_ERROR_INVALID_SOURCE_IDENTIFIER + ); +} + +static void test_relation_record_invalid_target_identifier(void) +{ + TestRelationRecordInput input = + test_relation_record_valid_input(); + + input.target_entity_identifier = "cible-invalide"; + + test_relation_record_assert_invalid( + &input, + RELATION_RECORD_ERROR_INVALID_TARGET_IDENTIFIER + ); +} + +static void test_relation_record_identical_entities(void) +{ + TestRelationRecordInput input = + test_relation_record_valid_input(); + + input.target_entity_identifier = + TEST_SOURCE_IDENTIFIER; + + test_relation_record_assert_invalid( + &input, + RELATION_RECORD_ERROR_IDENTICAL_ENTITIES + ); +} + +static void test_relation_record_empty_type(void) +{ + TestRelationRecordInput input = + test_relation_record_valid_input(); + + input.relation_type = " \t "; + + test_relation_record_assert_invalid( + &input, + RELATION_RECORD_ERROR_INVALID_TYPE + ); +} + +static void test_relation_record_confidence_below_range(void) +{ + TestRelationRecordInput input = + test_relation_record_valid_input(); + + input.confidence = -1; + + test_relation_record_assert_invalid( + &input, + RELATION_RECORD_ERROR_INVALID_CONFIDENCE + ); +} + +static void test_relation_record_confidence_above_range(void) +{ + TestRelationRecordInput input = + test_relation_record_valid_input(); + + input.confidence = 101; + + test_relation_record_assert_invalid( + &input, + RELATION_RECORD_ERROR_INVALID_CONFIDENCE + ); +} + +static void test_relation_record_invalid_created_at(void) +{ + TestRelationRecordInput input = + test_relation_record_valid_input(); + + input.created_at = "2026-02-30T20:00:00Z"; + + test_relation_record_assert_invalid( + &input, + RELATION_RECORD_ERROR_INVALID_DATE + ); +} + +static void test_relation_record_invalid_updated_at(void) +{ + TestRelationRecordInput input = + test_relation_record_valid_input(); + + input.updated_at = "2026-07-20 20:00:00Z"; + + test_relation_record_assert_invalid( + &input, + RELATION_RECORD_ERROR_INVALID_DATE + ); +} + +static void test_relation_record_valid_statuses(void) +{ + RelationStatus statuses[] = + { + RELATION_STATUS_ACTIVE, + RELATION_STATUS_ARCHIVED, + RELATION_STATUS_DELETED, + RELATION_STATUS_DISPUTED + }; + + guint status_index = + 0; + + for (status_index = 0; + status_index < G_N_ELEMENTS(statuses); + status_index++) + { + TestRelationRecordInput input = + test_relation_record_valid_input(); + + RelationRecord *relation_record = + NULL; + + GError *error = + NULL; + + input.status = + statuses[status_index]; + + relation_record = + test_relation_record_create( + &input, + &error + ); + + g_assert_no_error( + error + ); + + g_assert_nonnull( + relation_record + ); + + g_assert_cmpint( + relation_record_get_status(relation_record), + ==, + statuses[status_index] + ); + + relation_record_free( + relation_record + ); + } +} + +static void test_relation_record_invalid_status(void) +{ + TestRelationRecordInput input = + test_relation_record_valid_input(); + + input.status = RELATION_STATUS_UNKNOWN; + + test_relation_record_assert_invalid( + &input, + RELATION_RECORD_ERROR_INVALID_STATUS + ); + + input.status = (RelationStatus) 999; + + test_relation_record_assert_invalid( + &input, + RELATION_RECORD_ERROR_INVALID_STATUS + ); +} + +static void test_relation_record_null_accessors(void) +{ + g_assert_null( + relation_record_get_identifier(NULL) + ); + + g_assert_null( + relation_record_get_source_entity_identifier(NULL) + ); + + g_assert_null( + relation_record_get_target_entity_identifier(NULL) + ); + + g_assert_null( + relation_record_get_relation_type(NULL) + ); + + g_assert_null( + relation_record_get_label(NULL) + ); + + g_assert_null( + relation_record_get_justification(NULL) + ); + + g_assert_cmpint( + relation_record_get_confidence(NULL), + ==, + 0 + ); + + g_assert_null( + relation_record_get_created_at(NULL) + ); + + g_assert_null( + relation_record_get_updated_at(NULL) + ); + + g_assert_cmpint( + relation_record_get_status(NULL), + ==, + RELATION_STATUS_UNKNOWN + ); + + relation_record_free( + NULL + ); +} + +static void test_relation_record_optional_error(void) +{ + TestRelationRecordInput input = + test_relation_record_valid_input(); + + RelationRecord *relation_record = + NULL; + + input.identifier = "invalide"; + + relation_record = + test_relation_record_create( + &input, + NULL + ); + + g_assert_null( + relation_record + ); +} + +int main( + int argc, + char **argv +) +{ + g_test_init( + &argc, + &argv, + NULL + ); + + g_test_add_func( + "/relation-record/valid-full", + test_relation_record_valid_full + ); + + g_test_add_func( + "/relation-record/optional-fields", + test_relation_record_optional_fields + ); + + g_test_add_func( + "/relation-record/values-are-trimmed", + test_relation_record_values_are_trimmed + ); + + g_test_add_func( + "/relation-record/invalid-identifier", + test_relation_record_invalid_identifier + ); + + g_test_add_func( + "/relation-record/invalid-source-identifier", + test_relation_record_invalid_source_identifier + ); + + g_test_add_func( + "/relation-record/invalid-target-identifier", + test_relation_record_invalid_target_identifier + ); + + g_test_add_func( + "/relation-record/identical-entities", + test_relation_record_identical_entities + ); + + g_test_add_func( + "/relation-record/empty-type", + test_relation_record_empty_type + ); + + g_test_add_func( + "/relation-record/confidence-below-range", + test_relation_record_confidence_below_range + ); + + g_test_add_func( + "/relation-record/confidence-above-range", + test_relation_record_confidence_above_range + ); + + g_test_add_func( + "/relation-record/invalid-created-at", + test_relation_record_invalid_created_at + ); + + g_test_add_func( + "/relation-record/invalid-updated-at", + test_relation_record_invalid_updated_at + ); + + g_test_add_func( + "/relation-record/valid-statuses", + test_relation_record_valid_statuses + ); + + g_test_add_func( + "/relation-record/invalid-status", + test_relation_record_invalid_status + ); + + g_test_add_func( + "/relation-record/null-accessors", + test_relation_record_null_accessors + ); + + g_test_add_func( + "/relation-record/optional-error", + test_relation_record_optional_error + ); + + return g_test_run(); +}