test(programs): close migration and sync gates
This commit is contained in:
parent
20fc427c2f
commit
f431b6ec9c
7 changed files with 961 additions and 35 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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<String, java.io.File> {
|
||||
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"
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -1,47 +1,222 @@
|
|||
/* Test-only producer fixture through the public desktop persistence API. */
|
||||
#include <sqlite3.h>
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
#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",
|
||||
(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",
|
||||
(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",
|
||||
(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.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;
|
||||
}
|
||||
|
|
|
|||
488
tui/tests/test_schema_v26_migration.c
Normal file
488
tui/tests/test_schema_v26_migration.c
Normal file
|
|
@ -0,0 +1,488 @@
|
|||
/**
|
||||
* @file test_schema_v26_migration.c
|
||||
* @brief Lossless populated v25 to v26 migration evidence.
|
||||
*/
|
||||
#define _POSIX_C_SOURCE 200809L
|
||||
|
||||
#include <sqlite3.h>
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#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();
|
||||
}
|
||||
Loading…
Reference in a new issue