From f431b6ec9cab68e0d41d04815ba41bac55787485 Mon Sep 17 00:00:00 2001 From: fy59 Date: Fri, 18 Sep 2026 20:01:42 +0200 Subject: [PATCH] test(programs): close migration and sync gates --- AGENTS.md | 9 +- .../data/SyncGenerationServiceTest.kt | 218 ++++++++ ...presentation_android_delete_v1_evidence.md | 32 +- docs/tests.md | 17 +- tui/meson.build | 9 + tui/tests/sync_generation_fixture.c | 223 +++++++- tui/tests/test_schema_v26_migration.c | 488 ++++++++++++++++++ 7 files changed, 961 insertions(+), 35 deletions(-) create mode 100644 tui/tests/test_schema_v26_migration.c diff --git a/AGENTS.md b/AGENTS.md index 32af629..d02fd8f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -170,7 +170,7 @@ It is not the canonical analytics store. The Android UI is driven by exercise metadata, never by exercise-name heuristics. -Android local SQLite schema v23 retains the exactly-one durable active-session +Android local SQLite schema v24 retains the exactly-one durable active-session draft introduced in v4. Every meaningful draft/form mutation is persisted by the repository. Back, backgrounding and process death never delete the draft. Home offers explicit @@ -184,7 +184,7 @@ from actual occurrence data after the additive v10 -> v11 migration. Existing rows retain `load_mode=none`, zero rest and NULL targets. Android v12 adds the durable flattened exercise-alias mapping used to resolve retired creator IDs. Desktop schema v12 owns the same alias compatibility boundary. Later additive -desktop migrations reach v24 and Android migrations reach v23 without changing +desktop migrations reach v26 and Android migrations reach v24 without changing that identity contract. The active completed-session exchange is the separate strict `trainlog-mobile-export` V3; V1/V2 remain readable and `TRAINLOG_FORMAT_V1` @@ -241,13 +241,14 @@ Desktop SQLite schema is versioned with: PRAGMA user_version; ``` -The current desktop schema is v24. Schema v9 added an occurrence-owned explicit +The current desktop schema is v26. Schema v9 added an occurrence-owned explicit maximum result; v10/v11 added body-zone and planning metadata; v12 added durable flattened exercise aliases; v13-v18 add machine metadata, feedback and revisions, occurrence tracking snapshots, causal profile state, and AI proposal storage/publication state; v19-v21 add lifecycle, causal deletion, and staged generation/ACK ledgers; v22-v24 add generation archival, manual preparations, -and durable preparation withdrawal. None changes `TRAINLOG_FORMAT_V1`. +and durable preparation withdrawal; v25 adds Programs; and v26 additively adds +logical Program deletion. None changes `TRAINLOG_FORMAT_V1`. Every incompatible schema evolution requires an explicit migration and regression coverage. diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/SyncGenerationServiceTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/SyncGenerationServiceTest.kt index aa7e3cc..01241eb 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/SyncGenerationServiceTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/SyncGenerationServiceTest.kt @@ -26,6 +26,224 @@ import org.robolectric.annotation.Config class SyncGenerationServiceTest { private val context: Context = ApplicationProvider.getApplicationContext() + @Test + fun desktopProgramDeletionGenerationIsAckedAndCannotResurrect() { + val repositoryRoot = + generateSequence(java.io.File(checkNotNull(System.getProperty("user.dir")))) { + it.parentFile + } + .first { java.io.File(it, "tools/sync_generation_exchange.py").isFile } + val fixture = java.io.File(repositoryRoot, "build/tui/sync-generation-fixture") + val generationTool = + java.io.File(repositoryRoot, "tools/sync_generation_exchange.py").absolutePath + val root = Files.createTempDirectory("trainlog-program-deletion-generation-").toFile() + val desktopDb = java.io.File(root, "desktop.sqlite") + val desktopOwned = java.io.File(root, "desktop-owned") + val desktopObjects = java.io.File(root, "desktop-objects") + val androidName = "program-deletion-generation-${UUID.randomUUID()}.db" + val programId = "pg_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" + val deletionId = "program-delete-generation-regression" + var android = TrainlogRepository(context, androidName) + + fun run(vararg command: String): String { + val process = + ProcessBuilder(*command) + .directory(repositoryRoot) + .redirectErrorStream(true) + .start() + val output = process.inputStream.bufferedReader().readText() + assertEquals( + "command failed: ${command.joinToString(" ")}\n$output", + 0, + process.waitFor(), + ) + return output + } + + fun captureAndPublish(service: SyncGenerationService): Pair { + val captured = + run( + "python3", + generationTool, + "capture-desktop", + "--database", + desktopDb.absolutePath, + "--owned-root", + desktopOwned.absolutePath, + "--consumer-peer", + service.peerId(), + ) + val generationId = + checkNotNull(Regex("generation_id=(gen_[^ ]+)").find(captured)).groupValues[1] + run( + "python3", + generationTool, + "publish", + "--database", + desktopDb.absolutePath, + "--generation-id", + generationId, + "--object-root", + desktopObjects.absolutePath, + ) + return generationId to java.io.File(desktopObjects, "generations/$generationId") + } + + fun acceptAndroidAck(generationId: String, acknowledgement: String): String { + val acknowledgementFile = + java.io.File(root, "$generationId-ack.json").apply { + writeText(acknowledgement) + } + return run( + "python3", + generationTool, + "accept-ack", + acknowledgementFile.absolutePath, + "--database", + desktopDb.absolutePath, + ) + } + + fun androidDeletionCount(): Int = + SQLiteDatabase.openDatabase( + context.getDatabasePath(androidName).absolutePath, + null, + SQLiteDatabase.OPEN_READONLY, + ) + .use { db -> + db.rawQuery( + "SELECT COUNT(*) FROM synced_program_deletions WHERE program_id=?", + arrayOf(programId), + ) + .use { + assertTrue(it.moveToFirst()) + it.getInt(0) + } + } + + try { + val created = run(fixture.absolutePath, desktopDb.absolutePath, "program-create") + val createdProgram = JSONObject(created.substringAfter("PROGRAM_CREATE=").trim()) + val initialRevision = createdProgram.getString("revision_id") + assertEquals(programId, createdProgram.getString("program_id")) + + var service = SyncGenerationService(android) + val (liveGenerationId, livePublished) = captureAndPublish(service) + val desktopPeer = + JSONObject(java.io.File(livePublished, "manifest.json").readText()) + .getJSONObject("producer") + .getString("peer_id") + val livePrograms = + JSONObject(java.io.File(livePublished, "programs-v1.json").readText()) + assertEquals( + programId, + livePrograms + .getJSONArray("programs") + .getJSONObject(0) + .getString("program_id"), + ) + assertEquals(0, livePrograms.getJSONArray("deletions").length()) + val liveAck = service.consume(livePublished) + assertEquals(programId, android.listSyncedPrograms().single().programId) + assertTrue( + acceptAndroidAck(liveGenerationId, liveAck).contains("ACK_ACCEPT=acknowledged") + ) + + val deleted = + run( + fixture.absolutePath, + desktopDb.absolutePath, + "program-delete", + initialRevision, + deletionId, + ) + val replayedDelete = + run( + fixture.absolutePath, + desktopDb.absolutePath, + "program-delete", + initialRevision, + deletionId, + ) + assertEquals(deleted, replayedDelete) + val deletionRevision = + JSONObject(deleted.substringAfter("PROGRAM_DELETE=").trim()) + .getString("revision_id") + + val (deletionGenerationId, deletionPublished) = captureAndPublish(service) + val deletedPrograms = + JSONObject(java.io.File(deletionPublished, "programs-v1.json").readText()) + assertEquals(0, deletedPrograms.getJSONArray("programs").length()) + assertEquals(1, deletedPrograms.getJSONArray("deletions").length()) + val exportedDeletion = deletedPrograms.getJSONArray("deletions").getJSONObject(0) + assertEquals(deletionId, exportedDeletion.getString("deletion_id")) + assertEquals(initialRevision, exportedDeletion.getString("predecessor_revision_id")) + assertEquals(deletionRevision, exportedDeletion.getString("revision_id")) + val deletionAck = service.consume(deletionPublished) + assertTrue(android.listSyncedPrograms().isEmpty()) + assertTrue( + acceptAndroidAck(deletionGenerationId, deletionAck) + .contains("ACK_ACCEPT=acknowledged") + ) + val acknowledgedDeletion = + run( + "sqlite3", + desktopDb.absolutePath, + "SELECT generation_id || '|' || (acknowledged_at IS NOT NULL) " + + "FROM program_deletions WHERE request_id='$deletionId';", + ) + .trim() + assertEquals("$deletionGenerationId|1", acknowledgedDeletion) + + android.close() + android = TrainlogRepository(context, androidName) + service = SyncGenerationService(android) + val reopenedDesktopPeer = + checkNotNull( + Regex("PEER_ID=(peer_[^\\s]+)") + .find( + run( + "python3", + generationTool, + "peer-id", + "--database", + desktopDb.absolutePath, + ) + ) + ) + .groupValues[1] + assertEquals(desktopPeer, reopenedDesktopPeer) + assertEquals(liveAck, service.consume(livePublished)) + assertTrue(android.listSyncedPrograms().isEmpty()) + assertEquals(1, androidDeletionCount()) + + val (cleanGenerationId, cleanPublished) = captureAndPublish(service) + val cleanPrograms = + JSONObject(java.io.File(cleanPublished, "programs-v1.json").readText()) + assertEquals(0, cleanPrograms.getJSONArray("programs").length()) + assertEquals(0, cleanPrograms.getJSONArray("deletions").length()) + val cleanAck = service.consume(cleanPublished) + assertTrue(android.listSyncedPrograms().isEmpty()) + assertEquals(1, androidDeletionCount()) + assertTrue( + acceptAndroidAck(cleanGenerationId, cleanAck).contains("ACK_ACCEPT=acknowledged") + ) + assertEquals( + "1", + run( + "sqlite3", + desktopDb.absolutePath, + "SELECT COUNT(*) FROM program_deletions WHERE program_id='$programId';", + ) + .trim(), + ) + } finally { + android.close() + context.deleteDatabase(androidName) + root.deleteRecursively() + } + } + @Test fun acknowledgedOutboundRemainsResumableUntilInboundIsTerminal() { val sourceName = "generation-resume-source-${UUID.randomUUID()}.db" diff --git a/docs/reviews/programs_presentation_android_delete_v1_evidence.md b/docs/reviews/programs_presentation_android_delete_v1_evidence.md index e66c970..38c4db6 100644 --- a/docs/reviews/programs_presentation_android_delete_v1_evidence.md +++ b/docs/reviews/programs_presentation_android_delete_v1_evidence.md @@ -33,17 +33,45 @@ not assign `PASS` or `FROZEN` status. ## Validation recorded -- Full Meson suite: **88/88**. +- Full Meson suite: **89/89**. - Focused ASan/UBSan suite: **3/3**. - Web Vitest: **94** tests. - Web production build completed. -- Android unit suite: **229** tests, **5 skipped**; `assembleDebug` completed. +- Android unit suite: **230** tests, **5 skipped**; `assembleDebug` completed. - Python Programs and synchronization suites completed. - Format and diff validators completed, including `git diff --check`. - Real Firefox Programs screenshots passed and are retained as [desktop cards, 1440x1000](evidence/web-programs-v1-card-desktop.png) and [mobile deletion dialog, exact 390x844](evidence/web-programs-v1-delete-dialog-mobile-390x844.png). +### Pre-switch migration and exchange gates + +`schema_v26_migration` deterministically migrates a populated v25 fixture with +24 sessions, 48 entries, preparation provenance, and historical evidence. It +asserts the exact typed post-migration projections rather than only table or +column presence. + +The integrated Android `SyncGenerationServiceTest` executes the real production +chain from a live Program through capture and publication, Android consumption, +Program deletion and tombstone publication, the real correlated ACK, desktop +acceptance and reopen. An exact old generation is idempotently recognized and +ACKed while the durable tombstone prevents Program resurrection; a second +exchange adds no duplicate data. + +### Private real-copy v25 to v26 migration evidence + +A private real-copy migration gate preserved user-data confidentiality while +validating the source backup at v25 and its post-migration v26 result. The +post-migration integrity check passed, foreign-key violations numbered **0**, +and the business-data SHA-256 was identical before and after migration: +`b264dd1b5fd7cdc599b2f6e2a20c01af794949e7871b8e3107ff3b29a06c6e11`. + +The program identity hash was `1b14...`; the 24 sessions hash was `593f...`; +the 164 entries hash was `b991...`; and the two preparations hash was +`2e273...`. The copy contained no `deleted_at` values and no +`program_deletions` rows. All 45 unchanged business tables matched their +canonical field-level hashes. + These results are implementation and presentation-validation evidence. They do not substitute for separate deployment validation. diff --git a/docs/tests.md b/docs/tests.md index 310d567..3b7f88b 100644 --- a/docs/tests.md +++ b/docs/tests.md @@ -54,7 +54,10 @@ replay. import, exact replay, responsive detail serialization, archive replay, revision-guarded terminal deletion, response replay, preparation creation, usage/provenance and absence of delivery. Schema migration coverage checks all -v26 tables and columns. Programs Vitest covers preview before the only importing +v26 tables and columns. `schema_v26_migration` additionally migrates a +deterministically populated v25 fixture containing 24 sessions and 48 entries, +with preparation provenance and history, and verifies exact typed projections +after migration. Programs Vitest covers preview before the only importing mutation, explicit preparation creation/navigation, discrete trash confirmation, focus restoration, and Escape/Cancel zero-mutation behavior. The full HTTP, pagination, strict-parser and Core suites retain malformed, duplicate-key, @@ -63,10 +66,14 @@ bounds, catalog/profile/equipment, conflict and rollback coverage. Android Programs projection tests cover schema-v23 to v24 migration, full nondeleted `trainlog-programs` V1 snapshot import, durable deletion tombstones, read-only Sessions list/detail data, exact replay, capability gating, and -correlated ACK behavior. Python Programs/synchronization suites cover the -staged companion's full snapshot, unacknowledged tombstone publication, and -ACK-driven acknowledgment without changing mobile V3, catalog, or preparation -artifacts. +correlated ACK behavior. The integrated `SyncGenerationServiceTest` invokes the +real production chain from a live Program through capture, publication, Android +consumption, deletion/tombstone publication, real ACK, desktop acceptance and +reopen; an exact old generation is idempotently recognized and ACKed while the +durable tombstone prevents Program resurrection, and a second exchange creates +no duplication. Python Programs/synchronization suites cover the staged +companion's full snapshot, unacknowledged tombstone publication, and ACK-driven +acknowledgment without changing mobile V3, catalog, or preparation artifacts. `test_web_sessions_browser.py` runs headless Firefox against the embedded production assets and C HTTP server with private temporary XDG roots. It creates diff --git a/tui/meson.build b/tui/meson.build index e4778a6..08fbb15 100644 --- a/tui/meson.build +++ b/tui/meson.build @@ -747,6 +747,15 @@ test_schema_v9_migration = executable( test('schema_v9_migration', test_schema_v9_migration) +test_schema_v26_migration = executable( + 'test_schema_v26_migration', + 'tests/test_schema_v26_migration.c', + dependencies: trainlog_core_dep, + c_args: strict_c_args, +) + +test('schema_v26_migration', test_schema_v26_migration) + test_schema_v7_migration = executable( 'test_schema_v7_migration', 'tests/test_schema_v7_migration.c', diff --git a/tui/tests/sync_generation_fixture.c b/tui/tests/sync_generation_fixture.c index 6855c9f..28bc7c9 100644 --- a/tui/tests/sync_generation_fixture.c +++ b/tui/tests/sync_generation_fixture.c @@ -1,47 +1,222 @@ /* Test-only producer fixture through the public desktop persistence API. */ +#include + #include +#include #include #include "trainlog/database.h" +#include "trainlog/web_programs.h" -int main(int argc, char **argv) -{ - TrainlogDatabase *database = NULL; - TrainlogSetInput set = { .reps = 9, .has_weight = true, .weight_kg = 32.5 }; +static const char PROGRAM_JSON[] = + "{\"format\":\"trainlog-program\",\"version\":1,\"program\":{" + "\"program_id\":\"pg_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa\"," + "\"title\":\"Generation program\",\"note\":null,\"state\":\"active\"," + "\"start_date\":\"2026-09-21\",\"end_date\":null,\"sessions\":[{" + "\"program_session_id\":\"pgs_bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb\"," + "\"title\":\"Session A\",\"session_type\":\"training\"," + "\"planned_for\":null,\"note\":null,\"occurrences\":[{" + "\"entry_id\":\"pge_cccccccc-cccc-4ccc-8ccc-cccccccccccc\"," + "\"exercise_id\":\"ex_dddddddd-dddd-4ddd-8ddd-dddddddddddd\"," + "\"equipment_id\":null,\"load_mode\":\"external\",\"rest_seconds\":90," + "\"target_sets\":3,\"target_reps\":8,\"target_duration_seconds\":null," + "\"target_weight_kg\":40.0,\"notes\":null}]}]}}"; + +static int create_program(TrainlogDatabase *database) { + char *response = NULL; + size_t response_size = 0U; + TrainlogStatus status; + + status = trainlog_database_insert_exercise_profiled(database, + "ex_dddddddd-dddd-4ddd-8ddd-dddddddddddd", + "Program exercise fixture", + "program exercise fixture", + TRAINLOG_TRACKING_REPS, + TRAINLOG_RECORDING_SETS, + 0U); + if (status != TRAINLOG_STATUS_OK) { + return 3; + } + status = trainlog_web_programs_import_json( + database, PROGRAM_JSON, strlen(PROGRAM_JSON), true, &response, &response_size); + free(response); + response = NULL; + if (status != TRAINLOG_STATUS_OK || + trainlog_web_programs_detail_json( + database, "pg_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", &response, &response_size) != + TRAINLOG_STATUS_OK) { + free(response); + return 4; + } + (void)printf("PROGRAM_CREATE=%s\n", response); + free(response); + return 0; +} + +static int +delete_program(TrainlogDatabase *database, const char *expected_revision, const char *request_id) { + char *response = NULL; + size_t response_size = 0U; + TrainlogStatus status = + trainlog_web_programs_delete_json(database, + "pg_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + expected_revision, + request_id, + &response, + &response_size); + + if (status != TRAINLOG_STATUS_OK) { + free(response); + return 5; + } + (void)printf("PROGRAM_DELETE=%s\n", response); + free(response); + return 0; +} + +static int create_session_fixture(TrainlogDatabase *database) { + TrainlogSetInput set = {.reps = 9, .has_weight = true, .weight_kg = 32.5}; TrainlogSessionExerciseInput exercise; TrainlogSessionInput session; - if (argc != 2 || trainlog_database_open(argv[1], &database) != TRAINLOG_STATUS_OK) { - return 2; - } - if (trainlog_database_insert_exercise_profiled( - database, "ex_77777777-7777-4777-8777-777777777777", - "Desktop generation fixture", "desktop generation fixture", - TRAINLOG_TRACKING_REPS, TRAINLOG_RECORDING_SETS, 0U) != TRAINLOG_STATUS_OK) { - trainlog_database_close(database); return 3; + if (trainlog_database_insert_exercise_profiled(database, + "ex_77777777-7777-4777-8777-777777777777", + "Desktop generation fixture", + "desktop generation fixture", + TRAINLOG_TRACKING_REPS, + TRAINLOG_RECORDING_SETS, + 0U) != TRAINLOG_STATUS_OK) { + return 3; } (void)memset(&exercise, 0, sizeof(exercise)); - (void)snprintf(exercise.entry_id, sizeof(exercise.entry_id), "%s", - "sxe_88888888-8888-4888-8888-888888888888"); - (void)snprintf(exercise.exercise_id, sizeof(exercise.exercise_id), "%s", - "ex_77777777-7777-4777-8777-777777777777"); + (void)snprintf(exercise.entry_id, + sizeof(exercise.entry_id), + "%s", + "sxe_88888888-8888-4888-8888-888888888888"); + (void)snprintf(exercise.exercise_id, + sizeof(exercise.exercise_id), + "%s", + "ex_77777777-7777-4777-8777-777777777777"); exercise.recording_mode = TRAINLOG_RECORDING_SETS; exercise.tracking_mode = TRAINLOG_TRACKING_REPS; exercise.sets = &set; exercise.set_count = 1U; (void)memset(&session, 0, sizeof(session)); - (void)snprintf(session.session_id, sizeof(session.session_id), "%s", - "se_99999999-9999-4999-8999-999999999999"); - (void)snprintf(session.started_at, sizeof(session.started_at), "%s", - "2026-09-17T10:00:00+02:00"); - (void)snprintf(session.ended_at, sizeof(session.ended_at), "%s", - "2026-09-17T10:30:00+02:00"); + (void)snprintf(session.session_id, + sizeof(session.session_id), + "%s", + "se_99999999-9999-4999-8999-999999999999"); + (void)snprintf( + session.started_at, sizeof(session.started_at), "%s", "2026-09-17T10:00:00+02:00"); + (void)snprintf(session.ended_at, sizeof(session.ended_at), "%s", "2026-09-17T10:30:00+02:00"); session.exercises = &exercise; session.exercise_count = 1U; if (trainlog_database_insert_session(database, &session) != TRAINLOG_STATUS_OK) { - trainlog_database_close(database); return 4; + return 4; } - trainlog_database_close(database); (void)puts("SYNC_GENERATION_FIXTURE=PASS"); return 0; } + +static int +check_integrity(sqlite3 *connection, bool *output_integrity_ok, bool *output_foreign_keys_ok) { + sqlite3_stmt *statement = NULL; + const unsigned char *integrity_result; + int rc; + + *output_integrity_ok = false; + *output_foreign_keys_ok = false; + rc = sqlite3_prepare_v2(connection, "PRAGMA integrity_check;", -1, &statement, NULL); + if (rc != SQLITE_OK) { + return 1; + } + rc = sqlite3_step(statement); + integrity_result = rc == SQLITE_ROW ? sqlite3_column_text(statement, 0) : NULL; + *output_integrity_ok = + integrity_result != NULL && strcmp((const char *)integrity_result, "ok") == 0; + if (sqlite3_finalize(statement) != SQLITE_OK || !*output_integrity_ok) { + return 1; + } + + rc = sqlite3_prepare_v2(connection, "PRAGMA foreign_key_check;", -1, &statement, NULL); + if (rc != SQLITE_OK) { + return 1; + } + rc = sqlite3_step(statement); + *output_foreign_keys_ok = rc == SQLITE_DONE; + if (sqlite3_finalize(statement) != SQLITE_OK || !*output_foreign_keys_ok) { + return 1; + } + return 0; +} + +static int migrate_only(const char *path) { + TrainlogDatabase *database = NULL; + char diagnostic[256]; + sqlite3 *inspection = NULL; + bool integrity_ok; + bool foreign_key_check_ok; + int foreign_keys_enabled; + int schema_version; + int result = 2; + + if (trainlog_database_open_with_diagnostic(path, &database, diagnostic, sizeof(diagnostic)) != + TRAINLOG_STATUS_OK) { + return result; + } + if (trainlog_database_schema_version(database, &schema_version) != TRAINLOG_STATUS_OK || + trainlog_database_foreign_keys_enabled(database, &foreign_keys_enabled) != + TRAINLOG_STATUS_OK) { + goto cleanup; + } + trainlog_database_close(database); + database = NULL; + + if (sqlite3_open_v2(path, &inspection, SQLITE_OPEN_READONLY | SQLITE_OPEN_FULLMUTEX, NULL) != + SQLITE_OK) { + goto cleanup; + } + if (check_integrity(inspection, &integrity_ok, &foreign_key_check_ok) != 0) { + goto cleanup; + } + (void)printf( + "MIGRATE_ONLY schema_version=%d integrity=%s foreign_keys=%s foreign_key_check=%s\n", + schema_version, + integrity_ok ? "ok" : "failed", + foreign_keys_enabled != 0 ? "enabled" : "disabled", + foreign_key_check_ok ? "ok" : "failed"); + result = 0; + +cleanup: + if (inspection != NULL) { + (void)sqlite3_close(inspection); + } + if (database != NULL) { + trainlog_database_close(database); + } + return result; +} + +int main(int argc, char **argv) { + TrainlogDatabase *database = NULL; + int result; + + if (argc == 3 && strcmp(argv[2], "migrate-only") == 0) { + return migrate_only(argv[1]); + } + if ((argc != 2 && argc != 3 && argc != 5) || + trainlog_database_open(argv[1], &database) != TRAINLOG_STATUS_OK) { + return 2; + } + if (argc == 2) { + result = create_session_fixture(database); + } else if (argc == 3 && strcmp(argv[2], "program-create") == 0) { + result = create_program(database); + } else if (argc == 5 && strcmp(argv[2], "program-delete") == 0) { + result = delete_program(database, argv[3], argv[4]); + } else { + result = 2; + } + trainlog_database_close(database); + return result; +} diff --git a/tui/tests/test_schema_v26_migration.c b/tui/tests/test_schema_v26_migration.c new file mode 100644 index 0000000..2c1b8a9 --- /dev/null +++ b/tui/tests/test_schema_v26_migration.c @@ -0,0 +1,488 @@ +/** + * @file test_schema_v26_migration.c + * @brief Lossless populated v25 to v26 migration evidence. + */ +#define _POSIX_C_SOURCE 200809L + +#include + +#include +#include +#include +#include +#include + +#include "trainlog/database.h" + +#define CHECK(expression) \ + do { \ + if (!(expression)) { \ + (void)fprintf(stderr, "CHECK failed at %s:%d: %s\n", __FILE__, __LINE__, #expression); \ + goto cleanup; \ + } \ + } while (0) + +typedef struct Projection { + char *text; + size_t length; + size_t capacity; +} Projection; + +typedef struct ProjectionSpec { + const char *label; + const char *sql; +} ProjectionSpec; + +static bool projection_append(Projection *projection, const char *value, size_t length) { + size_t required; + size_t capacity; + char *resized; + + if (length > SIZE_MAX - projection->length - 1U) { + return false; + } + required = projection->length + length + 1U; + if (required > projection->capacity) { + capacity = projection->capacity == 0U ? 4096U : projection->capacity; + while (capacity < required) { + if (capacity > SIZE_MAX / 2U) { + return false; + } + capacity *= 2U; + } + resized = realloc(projection->text, capacity); + if (resized == NULL) { + return false; + } + projection->text = resized; + projection->capacity = capacity; + } + (void)memcpy(projection->text + projection->length, value, length); + projection->length += length; + projection->text[projection->length] = '\0'; + return true; +} + +static bool projection_append_value(Projection *projection, sqlite3_stmt *statement, int column) { + char number[128]; + const unsigned char *text; + int length; + int written; + + switch (sqlite3_column_type(statement, column)) { + case SQLITE_NULL: + return projection_append(projection, "N;", 2U); + case SQLITE_INTEGER: + written = snprintf( + number, sizeof(number), "I%lld;", (long long)sqlite3_column_int64(statement, column)); + return written > 0 && (size_t)written < sizeof(number) && + projection_append(projection, number, (size_t)written); + case SQLITE_FLOAT: + written = + snprintf(number, sizeof(number), "F%.17g;", sqlite3_column_double(statement, column)); + return written > 0 && (size_t)written < sizeof(number) && + projection_append(projection, number, (size_t)written); + case SQLITE_TEXT: + text = sqlite3_column_text(statement, column); + length = sqlite3_column_bytes(statement, column); + written = snprintf(number, sizeof(number), "T%d:", length); + return text != NULL && length >= 0 && written > 0 && (size_t)written < sizeof(number) && + projection_append(projection, number, (size_t)written) && + projection_append(projection, (const char *)text, (size_t)length) && + projection_append(projection, ";", 1U); + default: + return false; + } +} + +/* WHY: row counts cannot prove that nullable targets, floating-point loads, or + * ordered identities survived. CONTRACT: this helper records SQLite types, + * byte lengths, values, columns, and row boundaries for an explicitly ordered + * business query. INVARIANT: equal projections imply field-for-field equality. */ +static bool capture_projection(sqlite3 *database, + const ProjectionSpec *specifications, + size_t specification_count, + Projection *projection) { + sqlite3_stmt *statement = NULL; + size_t index; + int column; + int result; + + for (index = 0U; index < specification_count; ++index) { + if (!projection_append( + projection, specifications[index].label, strlen(specifications[index].label)) || + !projection_append(projection, "\n", 1U) || + sqlite3_prepare_v2(database, specifications[index].sql, -1, &statement, NULL) != + SQLITE_OK) { + (void)sqlite3_finalize(statement); + return false; + } + while ((result = sqlite3_step(statement)) == SQLITE_ROW) { + for (column = 0; column < sqlite3_column_count(statement); ++column) { + if (!projection_append_value(projection, statement, column)) { + (void)sqlite3_finalize(statement); + return false; + } + } + if (!projection_append(projection, "\n", 1U)) { + (void)sqlite3_finalize(statement); + return false; + } + } + if (result != SQLITE_DONE || sqlite3_finalize(statement) != SQLITE_OK) { + statement = NULL; + return false; + } + statement = NULL; + } + return true; +} + +static bool execute(sqlite3 *database, const char *sql) { + char *error = NULL; + int result = sqlite3_exec(database, sql, NULL, NULL, &error); + + if (result != SQLITE_OK) { + (void)fprintf(stderr, "fixture SQL failed: %s\n", error == NULL ? "no diagnostic" : error); + } + sqlite3_free(error); + return result == SQLITE_OK; +} + +static int scalar(sqlite3 *database, const char *sql) { + sqlite3_stmt *statement = NULL; + int value = -1; + + if (sqlite3_prepare_v2(database, sql, -1, &statement, NULL) == SQLITE_OK && + sqlite3_step(statement) == SQLITE_ROW) { + value = sqlite3_column_int(statement, 0); + } + (void)sqlite3_finalize(statement); + return value; +} + +static bool insert_program_sessions(sqlite3 *database) { + static const char INSERT_SESSION[] = + "INSERT INTO program_sessions(program_session_id,program_id,position,title,session_type," + "planned_for,note) VALUES(?1,'pg_fixture',?2,?3,?4,?5,?6)"; + static const char INSERT_ENTRY[] = + "INSERT INTO program_session_entries(program_session_id,entry_id,position,exercise_id," + "equipment_id,recording_mode,tracking_mode,data_fields,load_mode,rest_seconds,target_sets," + "target_reps,target_duration_seconds,target_weight_kg,notes)" + " VALUES(?1,?2,?3,?4,?5,'sets',?6,?7,?8,?9,?10,?11,?12,?13,?14)"; + sqlite3_stmt *session = NULL; + sqlite3_stmt *entry = NULL; + char session_id[32]; + char entry_id[32]; + char title[64]; + char planned_for[32]; + char note[96]; + int position; + int occurrence; + bool ok = false; + + if (sqlite3_prepare_v2(database, INSERT_SESSION, -1, &session, NULL) != SQLITE_OK || + sqlite3_prepare_v2(database, INSERT_ENTRY, -1, &entry, NULL) != SQLITE_OK) { + goto cleanup; + } + for (position = 0; position < 24; ++position) { + (void)snprintf(session_id, sizeof(session_id), "pgs_fixture_%02d", position + 1); + (void)snprintf( + title, sizeof(title), "Week %d · Session %d", position / 3 + 1, position + 1); + (void)snprintf(planned_for, sizeof(planned_for), "2026-10-%02dT18:00:00Z", position + 1); + (void)snprintf( + note, sizeof(note), "Progression block %d, session %d", position / 6 + 1, position + 1); + (void)sqlite3_bind_text(session, 1, session_id, -1, SQLITE_TRANSIENT); + (void)sqlite3_bind_int(session, 2, position); + (void)sqlite3_bind_text(session, 3, title, -1, SQLITE_TRANSIENT); + (void)sqlite3_bind_text( + session, 4, position == 23 ? "max_test" : "training", -1, SQLITE_STATIC); + (void)sqlite3_bind_text(session, 5, planned_for, -1, SQLITE_TRANSIENT); + (void)sqlite3_bind_text(session, 6, note, -1, SQLITE_TRANSIENT); + if (sqlite3_step(session) != SQLITE_DONE || sqlite3_reset(session) != SQLITE_OK || + sqlite3_clear_bindings(session) != SQLITE_OK) { + goto cleanup; + } + for (occurrence = 0; occurrence < 2; ++occurrence) { + (void)snprintf(entry_id, sizeof(entry_id), "pge_%02d_%d", position + 1, occurrence + 1); + (void)snprintf(note, + sizeof(note), + occurrence == 0 ? "Controlled eccentric, RPE %d" : "Timed brace, RPE %d", + 7 + position % 3); + (void)sqlite3_bind_text(entry, 1, session_id, -1, SQLITE_TRANSIENT); + (void)sqlite3_bind_text(entry, 2, entry_id, -1, SQLITE_TRANSIENT); + (void)sqlite3_bind_int(entry, 3, occurrence); + (void)sqlite3_bind_text(entry, + 4, + occurrence == 0 ? "ex_fixture_press" : "ex_fixture_hold", + -1, + SQLITE_STATIC); + (void)sqlite3_bind_text(entry, + 5, + occurrence == 0 ? "eq_fixture_stack" : "eq_fixture_mat", + -1, + SQLITE_STATIC); + (void)sqlite3_bind_text( + entry, 6, occurrence == 0 ? "reps" : "duration", -1, SQLITE_STATIC); + (void)sqlite3_bind_int(entry, 7, occurrence == 0 ? 1 : 2); + (void)sqlite3_bind_text( + entry, 8, occurrence == 0 ? "external" : "none", -1, SQLITE_STATIC); + (void)sqlite3_bind_int(entry, 9, occurrence == 0 ? 90 + position : 45 + position); + (void)sqlite3_bind_int(entry, 10, occurrence == 0 ? 4 : 3); + if (occurrence == 0) { + (void)sqlite3_bind_int(entry, 11, 8 + position % 5); + (void)sqlite3_bind_null(entry, 12); + (void)sqlite3_bind_double(entry, 13, 42.5 + (double)position * 1.25); + } else { + (void)sqlite3_bind_null(entry, 11); + (void)sqlite3_bind_int(entry, 12, 30 + position); + (void)sqlite3_bind_null(entry, 13); + } + (void)sqlite3_bind_text(entry, 14, note, -1, SQLITE_TRANSIENT); + if (sqlite3_step(entry) != SQLITE_DONE || sqlite3_reset(entry) != SQLITE_OK || + sqlite3_clear_bindings(entry) != SQLITE_OK) { + goto cleanup; + } + } + } + ok = true; +cleanup: + (void)sqlite3_finalize(entry); + (void)sqlite3_finalize(session); + return ok; +} + +/* CONTRACT: construct a populated v25 database without consulting any user + * path. The current initializer supplies all older schema objects; removing + * exactly the v26 additions recreates the immediate predecessor boundary. */ +static bool build_v25_fixture(const char *path) { + TrainlogDatabase *production = NULL; + sqlite3 *database = NULL; + bool ok = false; + + if (trainlog_database_open(path, &production) != TRAINLOG_STATUS_OK) { + return false; + } + if (trainlog_database_insert_exercise_profiled(production, + "ex_fixture_press", + "Cable press", + "cable press", + TRAINLOG_TRACKING_REPS, + TRAINLOG_RECORDING_SETS, + 1U) != TRAINLOG_STATUS_OK || + trainlog_database_insert_exercise_profiled(production, + "ex_fixture_hold", + "Pallof hold", + "pallof hold", + TRAINLOG_TRACKING_DURATION, + TRAINLOG_RECORDING_SETS, + 2U) != TRAINLOG_STATUS_OK) { + trainlog_database_close(production); + return false; + } + trainlog_database_close(production); + if (sqlite3_open(path, &database) != SQLITE_OK || + !execute(database, + "PRAGMA foreign_keys=OFF;" + "DROP INDEX program_deletions_pending;" + "DROP TABLE program_deletions;" + "ALTER TABLE programs DROP COLUMN deleted_at;" + "PRAGMA user_version=25;" + "PRAGMA foreign_keys=ON;") || + !execute( + database, + "BEGIN IMMEDIATE;" + "INSERT INTO custom_equipment VALUES" + "('eq_fixture_stack','Cable stack','Stack','machine','external')," + "('eq_fixture_mat','Exercise mat','Mat','surface','none');" + "INSERT INTO sessions(id,session_id,started_at,ended_at,session_type,notes) VALUES" + "(201,'se_fixture_history','2026-09-12T17:00:00Z','2026-09-12T17:52:00Z'," + "'training','Completed history sentinel');" + "INSERT INTO session_exercises(id,entry_id,session_row_id,exercise_row_id," + "recording_mode,data_fields,position,load_mode,rest_seconds,target_sets,target_reps," + "target_duration_seconds,target_weight_kg,equipment_id,notes,tracking_mode) VALUES" + "(301,'sxe_fixture_press',201,(SELECT id FROM exercises WHERE " + "exercise_id='ex_fixture_press'),'sets',1,0,'external',105,4,10,NULL,47.5," + "'eq_fixture_stack','Historic occurrence sentinel','reps')," + "(302,'sxe_fixture_hold',201,(SELECT id FROM exercises WHERE " + "exercise_id='ex_fixture_hold'),'sets',2,1,'none',45,3,NULL,35,NULL," + "'eq_fixture_mat','Historic duration sentinel','duration');" + "INSERT INTO performed_sets(id,session_exercise_row_id,position,reps,duration_seconds," + "weight_kg) VALUES(401,301,0,10,NULL,47.5),(402,301,1,9,NULL,47.5)," + "(403,302,0,NULL,35,NULL);" + "INSERT INTO body_observations(id,observation_id,observed_at,session_row_id," + "body_weight_kg,waist_cm,left_arm_cm,right_arm_cm,notes) VALUES" + "(501,'bo_fixture','2026-09-12T16:55:00Z',201,78.4,82.1,35.2,35.4," + "'Historical body sentinel');" + "INSERT INTO programs(program_id,title,note,state,start_date,end_date,created_at," + "updated_at,revision_id,source_format,source_version,source_payload_sha256) VALUES" + "('pg_fixture','Eight-week strength progression','Imported coach plan','active'," + "'2026-10-01','2026-11-30','2026-09-15T08:00:00Z','2026-09-16T09:30:00Z'," + "'pgr_fixture_2','trainlog-program',1," + "'0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef');") || + !insert_program_sessions(database) || + !execute( + database, + "INSERT INTO session_preparations(preparation_id,current_revision_id,created_at," + "updated_at,editing_state,delivery_state,source_proposal_id,source_payload_sha256," + "withdrawn_at,source_program_id,source_program_session_id) VALUES" + "('prep_fixture','prepr_fixture_2','2026-09-17T08:00:00Z'," + "'2026-09-17T08:15:00Z','ready','local',NULL,NULL,NULL,'pg_fixture','pgs_fixture_07');" + "INSERT INTO session_preparation_revisions(revision_id,preparation_id," + "parent_revision_id,title,session_type,planned_for,notes,created_at) VALUES" + "('prepr_fixture_1','prep_fixture',NULL,'Week 3 · Session 7','training'," + "'2026-10-07T18:00:00Z','Initial Program-derived revision','2026-09-17T08:00:00Z')," + "('prepr_fixture_2','prep_fixture','prepr_fixture_1','Week 3 · Session 7 adjusted'," + "'training','2026-10-07T18:30:00Z','Adjusted after review','2026-09-17T08:15:00Z');" + "INSERT INTO session_preparation_entries(revision_id,entry_id,position,exercise_id," + "equipment_id,recording_mode,tracking_mode,data_fields,load_mode,rest_seconds," + "target_sets,target_reps,target_duration_seconds,target_weight_kg,notes) VALUES" + "('prepr_fixture_1','prepe_fixture_1a',0,'ex_fixture_press','eq_fixture_stack'," + "'sets','reps',1,'external',96,4,10,NULL,50.0,'Program provenance press')," + "('prepr_fixture_1','prepe_fixture_1b',1,'ex_fixture_hold','eq_fixture_mat'," + "'sets','duration',2,'none',51,3,NULL,36,NULL,'Program provenance hold')," + "('prepr_fixture_2','prepe_fixture_2a',0,'ex_fixture_press','eq_fixture_stack'," + "'sets','reps',1,'external',100,5,8,NULL,52.5,'Adjusted press')," + "('prepr_fixture_2','prepe_fixture_2b',1,'ex_fixture_hold','eq_fixture_mat'," + "'sets','duration',2,'none',55,3,NULL,40,NULL,'Adjusted hold');" + "COMMIT;") || + scalar(database, "PRAGMA user_version") != 25 || + scalar(database, "SELECT COUNT(*) FROM program_sessions") != 24 || + scalar(database, "SELECT COUNT(*) FROM program_session_entries") != 48 || + scalar(database, "SELECT COUNT(*) FROM pragma_foreign_key_check") != 0) { + goto cleanup; + } + ok = true; +cleanup: + if (database != NULL && sqlite3_close(database) != SQLITE_OK) { + ok = false; + } + return ok; +} + +static int run_test(void) { + static const ProjectionSpec BUSINESS_PROJECTIONS[] = { + {"programs", + "SELECT program_id,title,note,state,start_date,end_date,created_at,updated_at,revision_id," + "source_format,source_version,source_payload_sha256 FROM programs ORDER BY program_id"}, + {"program_sessions", + "SELECT * FROM program_sessions ORDER BY program_id,position,program_session_id"}, + {"program_session_entries", + "SELECT * FROM program_session_entries ORDER BY program_session_id,position,entry_id"}, + {"session_preparations", "SELECT * FROM session_preparations ORDER BY preparation_id"}, + {"session_preparation_revisions", + "SELECT * FROM session_preparation_revisions ORDER BY " + "preparation_id,created_at,revision_id"}, + {"session_preparation_entries", + "SELECT * FROM session_preparation_entries ORDER BY revision_id,position,entry_id"}, + {"exercises", "SELECT * FROM exercises ORDER BY id"}, + {"custom_equipment", "SELECT * FROM custom_equipment ORDER BY equipment_id"}, + {"sessions", "SELECT * FROM sessions ORDER BY id"}, + {"session_exercises", "SELECT * FROM session_exercises ORDER BY id"}, + {"performed_sets", "SELECT * FROM performed_sets ORDER BY id"}, + {"body_observations", "SELECT * FROM body_observations ORDER BY id"}, + {"profile_state", "SELECT * FROM exercise_profile_state ORDER BY exercise_row_id"}, + {"profile_revisions", + "SELECT * FROM exercise_profile_revisions ORDER BY exercise_row_id,revision_id"}, + }; + static const ProjectionSpec UNCHANGED_SCHEMA[] = { + {"schema", + "SELECT type,name,tbl_name,sql FROM sqlite_master WHERE name NOT LIKE 'sqlite_%' " + "AND name NOT IN('programs','program_deletions','program_deletions_pending') " + "ORDER BY type,name"}, + }; + char path[] = "/tmp/trainlog-v26-populated-XXXXXX"; + char diagnostic[512] = {0}; + int file_descriptor = -1; + sqlite3 *raw = NULL; + TrainlogDatabase *database = NULL; + Projection business_before = {0}; + Projection business_after = {0}; + Projection schema_before = {0}; + Projection schema_after = {0}; + int version = 0; + int schema_object_count_before = 0; + bool ok = false; + + file_descriptor = mkstemp(path); + CHECK(file_descriptor >= 0); + CHECK(close(file_descriptor) == 0); + file_descriptor = -1; + CHECK(build_v25_fixture(path)); + CHECK(sqlite3_open_v2(path, &raw, SQLITE_OPEN_READONLY, NULL) == SQLITE_OK); + CHECK(scalar(raw, "PRAGMA user_version") == 25); + CHECK(scalar(raw, "SELECT COUNT(*) FROM pragma_table_info('programs')") == 12); + CHECK(scalar(raw, + "SELECT COUNT(*) FROM sqlite_master WHERE name IN('program_deletions'," + "'program_deletions_pending')") == 0); + schema_object_count_before = + scalar(raw, "SELECT COUNT(*) FROM sqlite_master WHERE name NOT LIKE 'sqlite_%'"); + CHECK(schema_object_count_before > 0); + CHECK(capture_projection(raw, + BUSINESS_PROJECTIONS, + sizeof(BUSINESS_PROJECTIONS) / sizeof(BUSINESS_PROJECTIONS[0]), + &business_before)); + CHECK(capture_projection(raw, UNCHANGED_SCHEMA, 1U, &schema_before)); + CHECK(sqlite3_close(raw) == SQLITE_OK); + raw = NULL; + + CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic, sizeof(diagnostic)) == + TRAINLOG_STATUS_OK); + CHECK(trainlog_database_schema_version(database, &version) == TRAINLOG_STATUS_OK); + CHECK(version == 26); + trainlog_database_close(database); + database = NULL; + + CHECK(sqlite3_open_v2(path, &raw, SQLITE_OPEN_READONLY, NULL) == SQLITE_OK); + CHECK(scalar(raw, "PRAGMA user_version") == 26); + CHECK(capture_projection(raw, + BUSINESS_PROJECTIONS, + sizeof(BUSINESS_PROJECTIONS) / sizeof(BUSINESS_PROJECTIONS[0]), + &business_after)); + CHECK(capture_projection(raw, UNCHANGED_SCHEMA, 1U, &schema_after)); + CHECK(business_before.length == business_after.length); + CHECK(memcmp(business_before.text, business_after.text, business_before.length) == 0); + CHECK(schema_before.length == schema_after.length); + CHECK(memcmp(schema_before.text, schema_after.text, schema_before.length) == 0); + CHECK(scalar(raw, "SELECT COUNT(*) FROM pragma_integrity_check WHERE integrity_check='ok'") == + 1); + CHECK(scalar(raw, "SELECT COUNT(*) FROM pragma_foreign_key_check") == 0); + CHECK(scalar(raw, "SELECT COUNT(*) FROM programs WHERE deleted_at IS NOT NULL") == 0); + CHECK(scalar(raw, "SELECT COUNT(*) FROM program_deletions") == 0); + CHECK(scalar(raw, + "SELECT COUNT(*) FROM pragma_table_info('programs') WHERE name='deleted_at' AND " + "type='TEXT' AND \"notnull\"=0") == 1); + CHECK(scalar(raw, + "SELECT COUNT(*) FROM sqlite_master WHERE (type='table' AND " + "name='program_deletions') OR (type='index' AND " + "name='program_deletions_pending')") == 2); + CHECK(scalar(raw, "SELECT COUNT(*) FROM pragma_table_info('programs')") == 13); + CHECK(scalar(raw, "SELECT COUNT(*) FROM sqlite_master WHERE name NOT LIKE 'sqlite_%'") == + schema_object_count_before + 2); + ok = true; +cleanup: + if (!ok && diagnostic[0] != '\0') { + (void)fprintf(stderr, "migration diagnostic: %s\n", diagnostic); + } + free(schema_after.text); + free(schema_before.text); + free(business_after.text); + free(business_before.text); + if (raw != NULL) { + (void)sqlite3_close(raw); + } + if (database != NULL) { + trainlog_database_close(database); + } + if (file_descriptor >= 0) { + (void)close(file_descriptor); + } + if (unlink(path) != 0) { + ok = false; + } + return ok ? 0 : 1; +} + +int main(void) { + return run_test(); +}