diff --git a/.gitignore b/.gitignore index f4c179c0..624b026a 100644 --- a/.gitignore +++ b/.gitignore @@ -107,3 +107,6 @@ seed/questions/rebuilding_trust_BATCH1_NOTE.md # Python bytecode from the seed scripts __pycache__/ *.pyc + +# Asset-db backups must never live under assets/ — everything there ships in the APK +app/src/main/assets/database/*.bak* diff --git a/app/src/androidTest/java/app/closer/data/local/AssetDatabaseVerifyTest.kt b/app/src/androidTest/java/app/closer/data/local/AssetDatabaseVerifyTest.kt new file mode 100644 index 00000000..6fccefd5 --- /dev/null +++ b/app/src/androidTest/java/app/closer/data/local/AssetDatabaseVerifyTest.kt @@ -0,0 +1,116 @@ +package app.closer.data.local + +import androidx.room.Room +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +/** + * On-device verification that the bundled question bank in `assets/database/app.db` is one Room + * will actually load. This is the net for the class of failure that no JVM test or static check can + * catch: the asset is opened by Room at runtime via `createFromAsset` with **no migrations and no + * destructive fallback** (see `di/DatabaseModule.kt`), so a schema or identity-hash mismatch is a + * hard crash on first DB touch — for every user, on a fresh install. + * + * `build_db.py` regenerates that asset from the JSON packs in seed/questions. It previously emitted 2 of + * the 4 entity tables and no `room_master_table`, which would have crashed the app; this test exists + * so that can never ship silently again. Building the database is not enough to prove anything — + * Room opens lazily, so the test forces a real open (`openHelper.readableDatabase`), which is what + * triggers the copy and the identity check. + * + * Run on a THROWAWAY emulator: connectedDebugAndroidTest uninstalls the app-under-test, which wipes + * fixture data. Never run against the 5554/5556 fixture couple. + * adb -s emulator-5558 shell am instrument -w -e class app.closer.data.local.AssetDatabaseVerifyTest \ + * closer.app.test/androidx.test.runner.AndroidJUnitRunner + */ +@RunWith(AndroidJUnit4::class) +class AssetDatabaseVerifyTest { + + private lateinit var db: AppDatabase + + @Before + fun setUp() { + val context = InstrumentationRegistry.getInstrumentation().targetContext + // Mirrors DatabaseModule.provideAppDatabase, under a throwaway name so the real db is untouched. + db = Room.databaseBuilder(context, AppDatabase::class.java, "asset_verify.db") + .createFromAsset("database/app.db") + .build() + } + + @After + fun tearDown() { + db.close() + InstrumentationRegistry.getInstrumentation().targetContext.deleteDatabase("asset_verify.db") + } + + /** + * The critical assertion: opening is what makes Room copy the asset and validate its schema + + * identity hash against the compiled entities. If the asset were missing a table or carried the + * wrong hash, this throws rather than returning. + */ + @Test + fun assetDatabase_opensAndPassesRoomSchemaValidation() { + val open = db.openHelper.readableDatabase + assertTrue("Room could not open the bundled asset database", open.isOpen) + } + + @Test + fun assetDatabase_hasQuestionsAndCategories() { + val open = db.openHelper.readableDatabase + fun count(sql: String): Int = + open.query(sql).use { if (it.moveToFirst()) it.getInt(0) else -1 } + + assertTrue("no questions in the bundled bank", count("SELECT COUNT(*) FROM question") > 0) + assertTrue( + "no categories in the bundled bank", + count("SELECT COUNT(*) FROM question_category") > 0 + ) + // Every question must belong to a real category — the old importer's filename fallback + // silently filed packs under a bogus "unknown" category that the app never shows. + assertEquals( + "questions reference a category that does not exist", + 0, + count( + "SELECT COUNT(*) FROM question q LEFT JOIN question_category c " + + "ON q.category_id = c.id WHERE c.id IS NULL" + ) + ) + } + + /** + * Depth is read as an Int by [app.closer.data.local.entity.QuestionEntity]. String depth would be + * coerced to 0 and silently break depth routing, so assert the column really is integer-typed. + */ + @Test + fun assetDatabase_depthIsIntegerTyped() { + val open = db.openHelper.readableDatabase + val bad = open.query( + "SELECT COUNT(*) FROM question WHERE typeof(depth_level) <> 'integer'" + ).use { if (it.moveToFirst()) it.getInt(0) else -1 } + assertEquals("depth_level has non-integer values", 0, bad) + } + + /** + * Guards the answer_config contract: the mapper reads camelCase keys, so a snake_case config + * parses to blank labels and the scale UI silently renders bare numbers instead of the labels. + */ + @Test + fun assetDatabase_scaleQuestionsCarryLabelsTheMapperCanRead() { + val open = db.openHelper.readableDatabase + open.query( + "SELECT id, answer_config FROM question WHERE type = 'scale' LIMIT 1" + ).use { cursor -> + if (!cursor.moveToFirst()) return // no scale questions in the bank; nothing to assert + val raw = cursor.getString(1) + assertTrue("scale answer_config is missing the mapper's minLabel key: $raw", + raw.contains("minLabel")) + assertTrue("scale answer_config is missing the mapper's minScale key: $raw", + raw.contains("minScale")) + } + } +} diff --git a/app/src/main/assets/database/app.db b/app/src/main/assets/database/app.db index a677ee4f..818b2ab6 100644 Binary files a/app/src/main/assets/database/app.db and b/app/src/main/assets/database/app.db differ diff --git a/app/src/main/assets/database/app.db.bak_q4 b/app/src/main/assets/database/app.db.bak_q4 deleted file mode 100644 index fe7a61f1..00000000 Binary files a/app/src/main/assets/database/app.db.bak_q4 and /dev/null differ diff --git a/app/src/main/assets/database/app.db.bak_q5 b/app/src/main/assets/database/app.db.bak_q5 deleted file mode 100644 index 908bd576..00000000 Binary files a/app/src/main/assets/database/app.db.bak_q5 and /dev/null differ diff --git a/seed/build_db.py b/seed/build_db.py index a60644f0..64015c74 100644 --- a/seed/build_db.py +++ b/seed/build_db.py @@ -33,6 +33,7 @@ import shutil import sqlite3 import sys import tempfile +from datetime import datetime from pathlib import Path from typing import Any, Dict, List @@ -42,6 +43,9 @@ ROOT = Path(__file__).resolve().parents[1] JSON_DIR = ROOT / "seed" / "questions" SCHEMA_DIR = ROOT / "app" / "schemas" / "app.closer.data.local.AppDatabase" ASSET_DB = ROOT / "app" / "src" / "main" / "assets" / "database" / "app.db" +# Everything under assets/ is packaged into the APK, so backups must NOT live there — +# a stray .bak beside the db silently ships to every user. /build is gitignored. +BACKUP_DIR = ROOT / "build" / "db-backups" VALID_TYPES = {"written", "single_choice", "multi_choice", "scale", "this_or_that"} VALID_ACCESS = {"free", "premium"} @@ -333,9 +337,11 @@ def main() -> int: verify(staged, db_schema) if replacing_asset and args.out.exists(): - backup = args.out.with_suffix(".db.bak") + BACKUP_DIR.mkdir(parents=True, exist_ok=True) + stamp = datetime.now().strftime("%Y%m%d-%H%M%S") + backup = BACKUP_DIR / f"app.db.{stamp}.bak" shutil.copy2(args.out, backup) - print(f"Backed up existing asset db -> {backup.name}") + print(f"Backed up existing asset db -> {backup.relative_to(ROOT)}") args.out.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(staged, args.out) except ContentError as e: