/** * @file test_database.c * @brief Black-box tests for Trainlog persistence. */ #include #include #include #include #include "trainlog/database.h" #include "trainlog/id.h" #define CHECK(condition) \ do { \ if (!(condition)) { \ (void)fprintf( \ stderr, \ "CHECK failed at %s:%d: %s\n", \ __FILE__, \ __LINE__, \ #condition \ ); \ return false; \ } \ } while (0) static bool test_database_open_and_schema(void) { TrainlogDatabase *database = NULL; int schema_version = 0; int foreign_keys = 0; CHECK( trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK ); CHECK( trainlog_database_schema_version(database, &schema_version) == TRAINLOG_STATUS_OK ); CHECK(schema_version == TRAINLOG_DATABASE_SCHEMA_VERSION); CHECK( trainlog_database_foreign_keys_enabled(database, &foreign_keys) == TRAINLOG_STATUS_OK ); CHECK(foreign_keys == 1); trainlog_database_close(database); return true; } static bool test_database_open_diagnostic(void) { TrainlogDatabase *database = NULL; char diagnostic[256]; /* CONTRACT: a launcher must retain the stable status code while showing * the SQLite operation that blocked access to the user's real database. */ CHECK( trainlog_database_open_with_diagnostic( "/", &database, diagnostic, sizeof(diagnostic) ) == TRAINLOG_STATUS_DATABASE_ERROR ); CHECK(database == NULL); CHECK(strstr(diagnostic, "open database:") != NULL); return true; } static bool test_generated_ids(void) { char first[TRAINLOG_GENERATED_ID_CAPACITY]; char second[TRAINLOG_GENERATED_ID_CAPACITY]; char sync_id[TRAINLOG_GENERATED_ID_CAPACITY]; char entry_id[TRAINLOG_GENERATED_ID_CAPACITY]; char v1_exact_capacity[2U + 1U + TRAINLOG_UUID_TEXT_LENGTH + 1U]; CHECK( trainlog_id_generate("ex", first, sizeof(first)) == TRAINLOG_STATUS_OK ); CHECK( trainlog_id_generate("ex", second, sizeof(second)) == TRAINLOG_STATUS_OK ); CHECK(strncmp(first, "ex_", 3U) == 0); CHECK(strlen(first) == 2U + 1U + TRAINLOG_UUID_TEXT_LENGTH); CHECK(strcmp(first, second) != 0); CHECK(first[3U + 14U] == '4'); /* ABI behavior: the new longer occurrence prefix must not invalidate the * exact buffer size historically sufficient for two-character prefixes. */ CHECK( trainlog_id_generate( "se", v1_exact_capacity, sizeof(v1_exact_capacity) ) == TRAINLOG_STATUS_OK ); /* TRAINLOG_SYNC_ID_PREFIX_TEST */ CHECK( trainlog_id_generate( "sy", sync_id, sizeof(sync_id) ) == TRAINLOG_STATUS_OK ); CHECK( strncmp( sync_id, "sy_", 3U ) == 0 ); CHECK( sync_id[3U + 14U] == '4' ); CHECK( trainlog_id_generate( "sxe", entry_id, sizeof(entry_id) ) == TRAINLOG_STATUS_OK ); CHECK(strncmp(entry_id, "sxe_", 4U) == 0); CHECK(strlen(entry_id) == TRAINLOG_GENERATED_ID_CAPACITY - 1U); CHECK(entry_id[4U + 14U] == '4'); return true; } static bool seed_exercise(TrainlogDatabase *database) { return trainlog_database_insert_exercise( database, "ex_test", "Presse à cuisses", "presse à cuisses", TRAINLOG_TRACKING_REPS ) == TRAINLOG_STATUS_OK; } static bool test_session_insert(void) { TrainlogDatabase *database = NULL; TrainlogSetInput sets[3]; TrainlogSessionExerciseInput exercise; TrainlogSessionInput session; TrainlogSessionSummary persisted_session; TrainlogPersistedExerciseDetail persisted_exercises[1]; size_t count = 0U; CHECK( trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK ); CHECK(seed_exercise(database)); (void)memset(sets, 0, sizeof(sets)); sets[0].reps = 5; sets[0].has_weight = true; sets[0].weight_kg = 32.5; sets[1] = sets[0]; sets[1].weight_kg = 0.0; sets[2] = sets[0]; sets[2].has_weight = false; sets[2].weight_kg = 0.0; (void)memset(&exercise, 0, sizeof(exercise)); (void)snprintf( exercise.exercise_id, sizeof(exercise.exercise_id), "%s", "ex_test" ); exercise.load_mode = TRAINLOG_LOAD_EXTERNAL; exercise.rest_seconds = 60; exercise.target_sets = 3; exercise.target_reps = 5; exercise.target_has_weight = true; exercise.target_weight_kg = 80.0; exercise.sets = sets; exercise.set_count = 3U; (void)memset(&session, 0, sizeof(session)); (void)snprintf( session.session_id, sizeof(session.session_id), "%s", "se_test" ); (void)snprintf( session.started_at, sizeof(session.started_at), "%s", "2026-09-05T18:00:00+02:00" ); (void)snprintf( session.ended_at, sizeof(session.ended_at), "%s", "2026-09-05T19:00:00+02:00" ); session.exercises = &exercise; session.exercise_count = 1U; CHECK( trainlog_database_insert_session(database, &session) == TRAINLOG_STATUS_OK ); CHECK( trainlog_database_insert_session(database, &session) == TRAINLOG_STATUS_CONFLICT ); CHECK( trainlog_database_session_count(database, &count) == TRAINLOG_STATUS_OK ); CHECK(count == 1U); CHECK( trainlog_database_get_session_details( database, "se_test", &persisted_session, persisted_exercises, 1U, &count ) == TRAINLOG_STATUS_OK ); CHECK(count == 1U); /* Regression: local occurrences are `sxe`, never synchronization runs. */ CHECK(strncmp(persisted_exercises[0].entry_id, "sxe_", 4U) == 0); CHECK(persisted_exercises[0].actual_set_count == 3U); CHECK(persisted_exercises[0].actual_sets[0].has_weight); CHECK(persisted_exercises[0].actual_sets[0].weight_kg == 32.5); CHECK(persisted_exercises[0].actual_sets[1].has_weight); CHECK(persisted_exercises[0].actual_sets[1].weight_kg == 0.0); CHECK(!persisted_exercises[0].actual_sets[2].has_weight); trainlog_database_free_session_details(persisted_exercises, count); (void)snprintf(session.session_id, sizeof(session.session_id), "%s", "se_negative_weight"); sets[0].weight_kg = -0.5; CHECK(trainlog_database_insert_session(database, &session) == TRAINLOG_STATUS_INVALID_ARGUMENT); (void)snprintf(session.session_id, sizeof(session.session_id), "%s", "se_nonfinite_weight"); sets[0].weight_kg = INFINITY; CHECK(trainlog_database_insert_session(database, &session) == TRAINLOG_STATUS_INVALID_ARGUMENT); CHECK(trainlog_database_session_count(database, &count) == TRAINLOG_STATUS_OK); CHECK(count == 1U); trainlog_database_close(database); return true; } static bool test_body_weight_history(void) { TrainlogDatabase *database = NULL; TrainlogBodyObservationInput observation; TrainlogWeightPoint points[4]; size_t count = 0U; CHECK( trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK ); (void)memset(&observation, 0, sizeof(observation)); (void)snprintf( observation.observation_id, sizeof(observation.observation_id), "%s", "bo_test" ); (void)snprintf( observation.observed_at, sizeof(observation.observed_at), "%s", "2026-09-05T07:00:00+02:00" ); observation.has_body_weight = true; observation.body_weight_kg = 82.4; CHECK( trainlog_database_insert_body_observation( database, &observation ) == TRAINLOG_STATUS_OK ); CHECK( trainlog_database_list_weight_points( database, points, 4U, &count ) == TRAINLOG_STATUS_OK ); CHECK(count == 1U); CHECK(points[0].body_weight_kg > 82.39); CHECK(points[0].body_weight_kg < 82.41); trainlog_database_close(database); return true; } static bool test_transaction_rollback(void) { TrainlogDatabase *database = NULL; size_t count = 0U; CHECK( trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK ); CHECK(trainlog_database_begin(database) == TRAINLOG_STATUS_OK); CHECK(seed_exercise(database)); CHECK(trainlog_database_rollback(database) == TRAINLOG_STATUS_OK); CHECK( trainlog_database_exercise_count(database, &count) == TRAINLOG_STATUS_OK ); CHECK(count == 0U); trainlog_database_close(database); return true; } static bool test_exercise_merge_aliases_and_conflicts(void) { TrainlogDatabase *database = NULL; TrainlogExerciseBodyZone zones[4]; const char *source_secondary[] = {"shoulders"}; const char *target_secondary[] = {"chest"}; char canonical[TRAINLOG_ID_MAX + 1U]; TrainlogExerciseMergePreview preview; size_t count = 0U; CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK); CHECK(trainlog_database_insert_exercise(database, "ex_source", "Curl A", "curl a", TRAINLOG_TRACKING_REPS) == TRAINLOG_STATUS_OK); CHECK(trainlog_database_insert_exercise(database, "ex_target", "Curl B", "curl b", TRAINLOG_TRACKING_REPS) == TRAINLOG_STATUS_OK); CHECK(trainlog_database_insert_exercise(database, "ex_final", "Curl C", "curl c", TRAINLOG_TRACKING_REPS) == TRAINLOG_STATUS_OK); CHECK(trainlog_database_insert_exercise(database, "ex_duration", "Hold", "hold", TRAINLOG_TRACKING_DURATION) == TRAINLOG_STATUS_OK); CHECK(trainlog_database_replace_exercise_body_zones(database, "ex_source", "arms", source_secondary, 1U) == TRAINLOG_STATUS_OK); CHECK(trainlog_database_replace_exercise_body_zones(database, "ex_target", "arms", target_secondary, 1U) == TRAINLOG_STATUS_OK); CHECK(trainlog_database_preview_exercise_merge(database, "ex_source", &preview) == TRAINLOG_STATUS_OK); CHECK(preview.occurrences == 0U && preview.performed_sets == 0U && preview.continuous_activities == 0U && preview.max_results == 0U && preview.associated_equipment == 0U && preview.body_zones == 2U); CHECK(trainlog_database_merge_exercises(database, "ex_source", "ex_target") == TRAINLOG_STATUS_OK); CHECK(trainlog_database_resolve_exercise_id(database, "ex_source", canonical, sizeof(canonical)) == TRAINLOG_STATUS_OK); CHECK(strcmp(canonical, "ex_target") == 0); CHECK(trainlog_database_list_exercise_body_zones(database, "ex_target", zones, 4U, &count) == TRAINLOG_STATUS_OK); CHECK(count == 3U); CHECK(strcmp(zones[0].zone_id, "arms") == 0 && zones[0].role == TRAINLOG_BODY_ZONE_PRIMARY); /* A second merge must collapse every old source directly to the newest * canonical ID; resolution never depends on an unbounded alias walk. */ CHECK(trainlog_database_merge_exercises(database, "ex_target", "ex_final") == TRAINLOG_STATUS_OK); CHECK(trainlog_database_resolve_exercise_id(database, "ex_source", canonical, sizeof(canonical)) == TRAINLOG_STATUS_OK); CHECK(strcmp(canonical, "ex_final") == 0); CHECK(trainlog_database_resolve_exercise_id(database, "ex_target", canonical, sizeof(canonical)) == TRAINLOG_STATUS_OK); CHECK(strcmp(canonical, "ex_final") == 0); CHECK(trainlog_database_merge_exercises(database, "ex_final", "ex_duration") == TRAINLOG_STATUS_CONFLICT); CHECK(trainlog_database_resolve_exercise_id(database, "ex_final", canonical, sizeof(canonical)) == TRAINLOG_STATUS_OK); CHECK(strcmp(canonical, "ex_final") == 0); CHECK(trainlog_database_exercise_count(database, &count) == TRAINLOG_STATUS_OK); CHECK(count == 2U); trainlog_database_close(database); return true; } struct TestCase { const char *name; bool (*function)(void); }; int main(void) { static const struct TestCase tests[] = { {"database_open_and_schema", test_database_open_and_schema}, {"database_open_diagnostic", test_database_open_diagnostic}, {"generated_ids", test_generated_ids}, {"session_insert", test_session_insert}, {"body_weight_history", test_body_weight_history}, {"transaction_rollback", test_transaction_rollback}, {"exercise_merge_aliases_and_conflicts", test_exercise_merge_aliases_and_conflicts}, }; size_t index; for (index = 0U; index < sizeof(tests) / sizeof(tests[0]); ++index) { if (!tests[index].function()) { (void)fprintf(stderr, "FAIL %s\n", tests[index].name); return 1; } (void)printf("PASS %s\n", tests[index].name); } return 0; }