From d8cb1f8294b115aa2600b681b4646e6ae3e0a502 Mon Sep 17 00:00:00 2001 From: Milton Barrera Date: Thu, 2 Apr 2026 15:16:58 -0600 Subject: [PATCH 1/2] new database helper tests --- .../net/osmtracker/db/DatabaseHelperTest.java | 584 ++++++++++++++++++ 1 file changed, 584 insertions(+) create mode 100644 app/src/test/java/net/osmtracker/db/DatabaseHelperTest.java diff --git a/app/src/test/java/net/osmtracker/db/DatabaseHelperTest.java b/app/src/test/java/net/osmtracker/db/DatabaseHelperTest.java new file mode 100644 index 00000000..020f5909 --- /dev/null +++ b/app/src/test/java/net/osmtracker/db/DatabaseHelperTest.java @@ -0,0 +1,584 @@ +package net.osmtracker.db; + +import android.content.ContentValues; +import android.content.Context; +import android.database.Cursor; +import android.database.sqlite.SQLiteDatabase; + +import androidx.test.core.app.ApplicationProvider; + +import net.osmtracker.db.model.Track; + +import org.junit.After; +import org.junit.Before; +import org.junit.Ignore; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +/** + * Intended-behaviour tests for {@link DatabaseHelper}. + * + *

Every test in this class describes what {@link DatabaseHelper} should do. + * Tests that are currently broken by a known bug are annotated with {@link Ignore} + * and reference the bug ID documented in {@code docs/BUGS.md}. + * + *

The companion class {@link DatabaseHelperTestBugs} contains the tests that confirm each + * bug exists by asserting the current (broken) behaviour. + * + *

How to use {@literal @}Ignore tests

+ *
    + *
  1. Fix the referenced bug in production code.
  2. + *
  3. Remove the {@literal @}Ignore annotation from the corresponding test here.
  4. + *
  5. Delete (or mark as obsolete) the matching test in {@link DatabaseHelperTestBugs}.
  6. + *
  7. Run {@code ./gradlew testDebugUnitTest} — the test must now pass.
  8. + *
+ */ +@RunWith(RobolectricTestRunner.class) +@Config(sdk = 25) +public class DatabaseHelperTest { + + private Context context; + private DatabaseHelper dbHelper; + private SQLiteDatabase db; + + @Before + public void setUp() { + context = ApplicationProvider.getApplicationContext(); + dbHelper = new DatabaseHelper(context); + db = dbHelper.getWritableDatabase(); + } + + @After + public void tearDown() { + dbHelper.close(); + } + + // ── Shared helpers ──────────────────────────────────────────────────────── + + /** + * Returns all column names for a given table using PRAGMA table_info. + */ + private List getColumnNames(SQLiteDatabase database, String table) { + List names = new ArrayList<>(); + Cursor c = database.rawQuery("PRAGMA table_info(" + table + ")", null); + try { + int nameIdx = c.getColumnIndex("name"); + while (c.moveToNext()) { + names.add(c.getString(nameIdx)); + } + } finally { + c.close(); + } + return names; + } + + /** + * Returns true if the given column has notnull=1 in PRAGMA table_info. + */ + private boolean isColumnNotNull(SQLiteDatabase database, String table, String column) { + Cursor c = database.rawQuery("PRAGMA table_info(" + table + ")", null); + try { + int nameIdx = c.getColumnIndex("name"); + int notNullIdx = c.getColumnIndex("notnull"); + while (c.moveToNext()) { + if (column.equals(c.getString(nameIdx))) { + return c.getInt(notNullIdx) == 1; + } + } + } finally { + c.close(); + } + return false; + } + + /** + * Returns the default value string for the given column, or null if none. + */ + private String getColumnDefault(SQLiteDatabase database, String table, String column) { + Cursor c = database.rawQuery("PRAGMA table_info(" + table + ")", null); + try { + int nameIdx = c.getColumnIndex("name"); + int dfltIdx = c.getColumnIndex("dflt_value"); + while (c.moveToNext()) { + if (column.equals(c.getString(nameIdx))) { + return c.getString(dfltIdx); + } + } + } finally { + c.close(); + } + return null; + } + + // ── Group I: Table existence ────────────────────────────────────────────── + + /** onCreate() must create the trackpoint table. */ + @Test + public void tableExists_trackpoint() { + Cursor c = db.rawQuery( + "SELECT name FROM sqlite_master WHERE type='table' AND name=?", + new String[]{TrackContentProvider.Schema.TBL_TRACKPOINT}); + try { + assertTrue("trackpoint table should exist", c.moveToFirst()); + } finally { + c.close(); + } + } + + /** onCreate() must create the waypoint table. */ + @Test + public void tableExists_waypoint() { + Cursor c = db.rawQuery( + "SELECT name FROM sqlite_master WHERE type='table' AND name=?", + new String[]{TrackContentProvider.Schema.TBL_WAYPOINT}); + try { + assertTrue("waypoint table should exist", c.moveToFirst()); + } finally { + c.close(); + } + } + + /** onCreate() must create the track table. */ + @Test + public void tableExists_track() { + Cursor c = db.rawQuery( + "SELECT name FROM sqlite_master WHERE type='table' AND name=?", + new String[]{TrackContentProvider.Schema.TBL_TRACK}); + try { + assertTrue("track table should exist", c.moveToFirst()); + } finally { + c.close(); + } + } + + /** onCreate() must create the note table. */ + @Test + public void tableExists_note() { + Cursor c = db.rawQuery( + "SELECT name FROM sqlite_master WHERE type='table' AND name=?", + new String[]{TrackContentProvider.Schema.TBL_NOTE}); + try { + assertTrue("note table should exist", c.moveToFirst()); + } finally { + c.close(); + } + } + + // ── Group II: Indexes ───────────────────────────────────────────────────── + + /** onCreate() must create the trackpoint_idx index. */ + @Test + public void indexExists_trackpointIdx() { + Cursor c = db.rawQuery( + "PRAGMA index_list(" + TrackContentProvider.Schema.TBL_TRACKPOINT + ")", + null); + try { + boolean found = false; + int nameIdx = c.getColumnIndex("name"); + while (c.moveToNext()) { + if ("trackpoint_idx".equals(c.getString(nameIdx))) { + found = true; + break; + } + } + assertTrue("trackpoint_idx should exist", found); + } finally { + c.close(); + } + } + + /** onCreate() must create the waypoint_idx index. */ + @Test + public void indexExists_waypointIdx() { + Cursor c = db.rawQuery( + "PRAGMA index_list(" + TrackContentProvider.Schema.TBL_WAYPOINT + ")", + null); + try { + boolean found = false; + int nameIdx = c.getColumnIndex("name"); + while (c.moveToNext()) { + if ("waypoint_idx".equals(c.getString(nameIdx))) { + found = true; + break; + } + } + assertTrue("waypoint_idx should exist", found); + } finally { + c.close(); + } + } + + // ── Group III: Column schemas ───────────────────────────────────────────── + + /** trackpoint table must have exactly 12 columns. */ + @Test + public void columnSchema_trackpoint_has12Columns() { + List cols = getColumnNames(db, TrackContentProvider.Schema.TBL_TRACKPOINT); + assertEquals("trackpoint should have 12 columns", 12, cols.size()); + } + + /** trackpoint.segment_id must enforce NOT NULL in a fresh install. */ + @Test + public void columnSchema_trackpoint_segmentId_isNotNull() { + assertTrue("segment_id must be NOT NULL on fresh install", + isColumnNotNull(db, TrackContentProvider.Schema.TBL_TRACKPOINT, + TrackContentProvider.Schema.COL_SEG_ID)); + } + + /** trackpoint.segment_id must default to 0. */ + @Test + public void columnSchema_trackpoint_segmentId_defaultsToZero() { + assertEquals("segment_id must default to 0", "0", + getColumnDefault(db, TrackContentProvider.Schema.TBL_TRACKPOINT, + TrackContentProvider.Schema.COL_SEG_ID)); + } + + /** waypoint table must have exactly 14 columns. */ + @Test + public void columnSchema_waypoint_has14Columns() { + List cols = getColumnNames(db, TrackContentProvider.Schema.TBL_WAYPOINT); + assertEquals("waypoint should have 14 columns", 14, cols.size()); + } + + /** track table must have exactly 10 columns. */ + @Test + public void columnSchema_track_has10Columns() { + List cols = getColumnNames(db, TrackContentProvider.Schema.TBL_TRACK); + assertEquals("track should have 10 columns", 10, cols.size()); + } + + /** track.active must default to 0. */ + @Test + public void columnSchema_track_active_defaultsToZero() { + assertEquals("active must default to 0", "0", + getColumnDefault(db, TrackContentProvider.Schema.TBL_TRACK, + TrackContentProvider.Schema.COL_ACTIVE)); + } + + /** track.osm_visibility must default to 'Private'. */ + @Test + public void columnSchema_track_osmVisibility_defaultsToPrivate() { + String dflt = getColumnDefault(db, TrackContentProvider.Schema.TBL_TRACK, + TrackContentProvider.Schema.COL_OSM_VISIBILITY); + assertEquals("osm_visibility must default to 'Private'", + "'" + Track.OSMVisibility.Private + "'", dflt); + } + + /** note table must have exactly 8 columns. */ + @Test + public void columnSchema_note_has8Columns() { + List cols = getColumnNames(db, TrackContentProvider.Schema.TBL_NOTE); + assertEquals("note should have 8 columns", 8, cols.size()); + } + + // ── Group IV: Insert smoke tests ────────────────────────────────────────── + + /** A minimal track row can be inserted and read back. */ + @Test + public void insertSmoke_track_roundTrip() { + ContentValues values = new ContentValues(); + values.put(TrackContentProvider.Schema.COL_START_DATE, 123456789L); + + long rowId = db.insert(TrackContentProvider.Schema.TBL_TRACK, null, values); + assertTrue("track insert should return a valid row ID", rowId > 0); + + Cursor c = db.query(TrackContentProvider.Schema.TBL_TRACK, + new String[]{TrackContentProvider.Schema.COL_START_DATE}, + TrackContentProvider.Schema.COL_ID + " = ?", + new String[]{String.valueOf(rowId)}, null, null, null); + try { + assertTrue("inserted track row should be queryable", c.moveToFirst()); + assertEquals(123456789L, + c.getLong(c.getColumnIndex(TrackContentProvider.Schema.COL_START_DATE))); + } finally { + c.close(); + } + } + + /** A minimal trackpoint row can be inserted and read back. */ + @Test + public void insertSmoke_trackpoint_roundTrip() { + // Insert a parent track first + ContentValues trackValues = new ContentValues(); + trackValues.put(TrackContentProvider.Schema.COL_START_DATE, System.currentTimeMillis()); + long trackId = db.insert(TrackContentProvider.Schema.TBL_TRACK, null, trackValues); + + ContentValues values = new ContentValues(); + values.put(TrackContentProvider.Schema.COL_TRACK_ID, trackId); + values.put(TrackContentProvider.Schema.COL_LATITUDE, 48.0); + values.put(TrackContentProvider.Schema.COL_LONGITUDE, 2.0); + values.put(TrackContentProvider.Schema.COL_TIMESTAMP, System.currentTimeMillis()); + + long rowId = db.insert(TrackContentProvider.Schema.TBL_TRACKPOINT, null, values); + assertTrue("trackpoint insert should return a valid row ID", rowId > 0); + + Cursor c = db.query(TrackContentProvider.Schema.TBL_TRACKPOINT, + new String[]{TrackContentProvider.Schema.COL_LATITUDE}, + TrackContentProvider.Schema.COL_ID + " = ?", + new String[]{String.valueOf(rowId)}, null, null, null); + try { + assertTrue("inserted trackpoint row should be queryable", c.moveToFirst()); + assertEquals(48.0, + c.getDouble(c.getColumnIndex(TrackContentProvider.Schema.COL_LATITUDE)), + 0.0001); + } finally { + c.close(); + } + } + + /** A minimal waypoint row can be inserted and read back. */ + @Test + public void insertSmoke_waypoint_roundTrip() { + ContentValues trackValues = new ContentValues(); + trackValues.put(TrackContentProvider.Schema.COL_START_DATE, System.currentTimeMillis()); + long trackId = db.insert(TrackContentProvider.Schema.TBL_TRACK, null, trackValues); + + ContentValues values = new ContentValues(); + values.put(TrackContentProvider.Schema.COL_TRACK_ID, trackId); + values.put(TrackContentProvider.Schema.COL_LATITUDE, 51.5); + values.put(TrackContentProvider.Schema.COL_LONGITUDE, -0.1); + values.put(TrackContentProvider.Schema.COL_TIMESTAMP, System.currentTimeMillis()); + values.put(TrackContentProvider.Schema.COL_NBSATELLITES, 5); + + long rowId = db.insert(TrackContentProvider.Schema.TBL_WAYPOINT, null, values); + assertTrue("waypoint insert should return a valid row ID", rowId > 0); + + Cursor c = db.query(TrackContentProvider.Schema.TBL_WAYPOINT, + new String[]{TrackContentProvider.Schema.COL_LONGITUDE}, + TrackContentProvider.Schema.COL_ID + " = ?", + new String[]{String.valueOf(rowId)}, null, null, null); + try { + assertTrue("inserted waypoint row should be queryable", c.moveToFirst()); + assertEquals(-0.1, + c.getDouble(c.getColumnIndex(TrackContentProvider.Schema.COL_LONGITUDE)), + 0.0001); + } finally { + c.close(); + } + } + + /** A minimal note row can be inserted and read back. */ + @Test + public void insertSmoke_note_roundTrip() { + ContentValues trackValues = new ContentValues(); + trackValues.put(TrackContentProvider.Schema.COL_START_DATE, System.currentTimeMillis()); + long trackId = db.insert(TrackContentProvider.Schema.TBL_TRACK, null, trackValues); + + ContentValues values = new ContentValues(); + values.put(TrackContentProvider.Schema.COL_TRACK_ID, trackId); + values.put(TrackContentProvider.Schema.COL_LATITUDE, 40.7); + values.put(TrackContentProvider.Schema.COL_LONGITUDE, -74.0); + values.put(TrackContentProvider.Schema.COL_TIMESTAMP, System.currentTimeMillis()); + values.put(TrackContentProvider.Schema.COL_NAME, "Test note"); + + long rowId = db.insert(TrackContentProvider.Schema.TBL_NOTE, null, values); + assertTrue("note insert should return a valid row ID", rowId > 0); + + Cursor c = db.query(TrackContentProvider.Schema.TBL_NOTE, + new String[]{TrackContentProvider.Schema.COL_NAME}, + TrackContentProvider.Schema.COL_ID + " = ?", + new String[]{String.valueOf(rowId)}, null, null, null); + try { + assertTrue("inserted note row should be queryable", c.moveToFirst()); + assertEquals("Test note", + c.getString(c.getColumnIndex(TrackContentProvider.Schema.COL_NAME))); + } finally { + c.close(); + } + } + + // ── Group V: onUpgrade() paths ──────────────────────────────────────────── + + /** + * Upgrading from v18 (which lacks segment_id) to v19 must add the segment_id column + * to the trackpoint table. + */ + @Test + public void onUpgrade_from18to19_addsSegmentIdColumn() { + SQLiteDatabase rawDb = SQLiteDatabase.create(null); + try { + // Simulate v18 trackpoint schema (no segment_id) + rawDb.execSQL("create table trackpoint (" + + "_id integer primary key autoincrement," + + "track_id integer not null," + + "latitude double not null," + + "longitude double not null," + + "speed double null," + + "elevation double null," + + "accuracy double null," + + "point_timestamp long not null," + + "compass_heading double null," + + "compass_accuracy integer null," + + "atmospheric_pressure double null)"); + + dbHelper.onUpgrade(rawDb, 18, 19); + + List cols = getColumnNames(rawDb, TrackContentProvider.Schema.TBL_TRACKPOINT); + assertTrue("segment_id column must exist after upgrade from v18", + cols.contains(TrackContentProvider.Schema.COL_SEG_ID)); + } finally { + rawDb.close(); + } + } + + /** + * Upgrading from v12 to v19 must add the osm_upload_date, description, tags, and + * osm_visibility columns to the track table, as well as the speed column to trackpoint, + * and create the note table. + */ + @Test + public void onUpgrade_from12to19_addsExpectedTrackColumns() { + SQLiteDatabase rawDb = SQLiteDatabase.create(null); + try { + // v12 track schema + rawDb.execSQL("create table track (" + + "_id integer primary key autoincrement," + + "name text," + + "start_date long not null," + + "directory text," + + "active integer not null default 0," + + "export_date long)"); + + // v12 trackpoint schema (no speed/compass/atmospheric/segment_id) + rawDb.execSQL("create table trackpoint (" + + "_id integer primary key autoincrement," + + "track_id integer not null," + + "latitude double not null," + + "longitude double not null," + + "elevation double null," + + "accuracy double null," + + "point_timestamp long not null)"); + + // v12 waypoint schema (no compass/atmospheric) + rawDb.execSQL("create table waypoint (" + + "_id integer primary key autoincrement," + + "track_id integer not null," + + "uuid text," + + "latitude double not null," + + "longitude double not null," + + "elevation double null," + + "accuracy double null," + + "point_timestamp long not null," + + "name text," + + "link text," + + "nb_satellites integer not null)"); + + dbHelper.onUpgrade(rawDb, 12, 19); + + List trackCols = getColumnNames(rawDb, TrackContentProvider.Schema.TBL_TRACK); + assertTrue("osm_upload_date must exist after v12 upgrade", + trackCols.contains(TrackContentProvider.Schema.COL_OSM_UPLOAD_DATE)); + assertTrue("description must exist after v12 upgrade", + trackCols.contains(TrackContentProvider.Schema.COL_DESCRIPTION)); + assertTrue("tags must exist after v12 upgrade", + trackCols.contains(TrackContentProvider.Schema.COL_TAGS)); + assertTrue("osm_visibility must exist after v12 upgrade", + trackCols.contains(TrackContentProvider.Schema.COL_OSM_VISIBILITY)); + + List tpCols = getColumnNames(rawDb, TrackContentProvider.Schema.TBL_TRACKPOINT); + assertTrue("speed must exist after v12 upgrade", + tpCols.contains(TrackContentProvider.Schema.COL_SPEED)); + assertTrue("segment_id must exist after v12 upgrade", + tpCols.contains(TrackContentProvider.Schema.COL_SEG_ID)); + + // note table must have been created by the upgrade + Cursor c = rawDb.rawQuery( + "SELECT name FROM sqlite_master WHERE type='table' AND name='note'", null); + try { + assertTrue("note table must exist after v12 upgrade", c.moveToFirst()); + } finally { + c.close(); + } + } finally { + rawDb.close(); + } + } + + /** + * Upgrading from any pre-v12 version (e.g., v11) calls onCreate() and must + * result in all four tables being present. + */ + @Test + public void onUpgrade_preV12_callsOnCreateAndCreatesAllTables() { + SQLiteDatabase rawDb = SQLiteDatabase.create(null); + try { + dbHelper.onUpgrade(rawDb, 11, 19); + + for (String table : new String[]{ + TrackContentProvider.Schema.TBL_TRACKPOINT, + TrackContentProvider.Schema.TBL_WAYPOINT, + TrackContentProvider.Schema.TBL_TRACK, + TrackContentProvider.Schema.TBL_NOTE}) { + Cursor c = rawDb.rawQuery( + "SELECT name FROM sqlite_master WHERE type='table' AND name=?", + new String[]{table}); + try { + assertTrue("table '" + table + "' must exist after pre-v12 upgrade", + c.moveToFirst()); + } finally { + c.close(); + } + } + } finally { + rawDb.close(); + } + } + + // ── Group VI: Bug B10 intended behavior ─────────────────────────────────── + + /** + * After upgrading from v18 to v19, the segment_id column should enforce NOT NULL + * (consistent with a fresh install via onCreate). + * + *

Currently fails — Bug B10: the ALTER TABLE in onUpgrade() case 18 uses + * {@code integer default 0} without {@code not null}, so after an upgrade the column + * permits NULL, diverging from the fresh-install schema. + * Remove {@literal @}Ignore and delete + * {@code DatabaseHelperTestBugs#bug_B10_segmentId_missingNotNull_afterUpgradeFrom18} + * once the bug is fixed. + */ + @Ignore("Bug B10 — segment_id is nullable after upgrade from v18. See docs/BUGS_DatabaseHelper.md") + @Test + public void onUpgrade_from18to19_segmentId_shouldBeNotNull() { + SQLiteDatabase rawDb = SQLiteDatabase.create(null); + try { + rawDb.execSQL("create table trackpoint (" + + "_id integer primary key autoincrement," + + "track_id integer not null," + + "latitude double not null," + + "longitude double not null," + + "speed double null," + + "elevation double null," + + "accuracy double null," + + "point_timestamp long not null," + + "compass_heading double null," + + "compass_accuracy integer null," + + "atmospheric_pressure double null)"); + + dbHelper.onUpgrade(rawDb, 18, 19); + + assertTrue("segment_id must be NOT NULL after upgrade from v18 (Bug B10)", + isColumnNotNull(rawDb, TrackContentProvider.Schema.TBL_TRACKPOINT, + TrackContentProvider.Schema.COL_SEG_ID)); + } finally { + rawDb.close(); + } + } + + // ── Group VII: Version ──────────────────────────────────────────────────── + + /** The database version must be 19. */ + @Test + public void dbVersion_is19() { + assertEquals("DB_VERSION must be 19", 19, db.getVersion()); + } +} From 26546c1caba4335f44aec9e747527ae8196ecf9b Mon Sep 17 00:00:00 2001 From: Milton Barrera Date: Thu, 2 Apr 2026 15:18:57 -0600 Subject: [PATCH 2/2] test: add confirming bug tests for database helper --- .../osmtracker/db/DatabaseHelperTestBugs.java | 211 ++++++++++++++++++ 1 file changed, 211 insertions(+) create mode 100644 app/src/test/java/net/osmtracker/db/DatabaseHelperTestBugs.java diff --git a/app/src/test/java/net/osmtracker/db/DatabaseHelperTestBugs.java b/app/src/test/java/net/osmtracker/db/DatabaseHelperTestBugs.java new file mode 100644 index 00000000..963769d6 --- /dev/null +++ b/app/src/test/java/net/osmtracker/db/DatabaseHelperTestBugs.java @@ -0,0 +1,211 @@ +package net.osmtracker.db; + +import android.content.ContentValues; +import android.content.Context; +import android.database.Cursor; +import android.database.sqlite.SQLiteDatabase; + +import androidx.test.core.app.ApplicationProvider; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +/** + * Bug-confirming tests for {@link DatabaseHelper}. + * + *

Every test in this class documents a known bug by asserting the current + * broken behaviour. Each test passes because the bug exists — the tests are expected + * to fail once the corresponding bug is fixed. + * + *

When a bug is fixed: + *

    + *
  1. Remove the {@literal @}Ignore annotation from the matching test in + * {@link DatabaseHelperTest} (the intended-behaviour companion).
  2. + *
  3. Delete (or permanently skip) the test in this class — it no longer represents + * correct expected behaviour.
  4. + *
  5. Run {@code ./gradlew testDebugUnitTest} — the formerly-{@literal @}Ignored test in + * {@link DatabaseHelperTest} must now pass.
  6. + *
+ * + *

See {@code docs/BUGS_DatabaseHelper.md} for the full description of each bug. + */ +@RunWith(RobolectricTestRunner.class) +@Config(sdk = 25) +public class DatabaseHelperTestBugs { + + private DatabaseHelper dbHelper; + + @Before + public void setUp() { + Context context = ApplicationProvider.getApplicationContext(); + dbHelper = new DatabaseHelper(context); + } + + @After + public void tearDown() { + dbHelper.close(); + } + + // ── Shared helpers ──────────────────────────────────────────────────────── + + /** + * Returns true if the given column has notnull=1 in PRAGMA table_info. + */ + private boolean isColumnNotNull(SQLiteDatabase database, String table, String column) { + Cursor c = database.rawQuery("PRAGMA table_info(" + table + ")", null); + try { + int nameIdx = c.getColumnIndex("name"); + int notNullIdx = c.getColumnIndex("notnull"); + while (c.moveToNext()) { + if (column.equals(c.getString(nameIdx))) { + return c.getInt(notNullIdx) == 1; + } + } + } finally { + c.close(); + } + return false; + } + + // ── Bug B10: segment_id missing NOT NULL after upgrade from v18 ────────── + + /** + * Bug B10 — After upgrading from v18 to v19, {@code segment_id} is nullable. + * + *

The {@code ALTER TABLE} in {@code onUpgrade()} case 18 uses + * {@code "integer default 0"} without {@code "not null"}, so after an upgrade + * the column permits NULL values. In contrast, a fresh install via + * {@code onCreate()} defines the column as {@code "integer not null default 0"}. + * + *

This test passes because the bug exists (notnull is 0 after upgrade). + * When Bug B10 is fixed, this test will fail and should be deleted. + * Remove {@literal @}Ignore from + * {@code DatabaseHelperTest#onUpgrade_from18to19_segmentId_shouldBeNotNull}. + * + * @see DatabaseHelperTest#onUpgrade_from18to19_segmentId_shouldBeNotNull + */ + @Test + public void bug_B10_segmentId_missingNotNull_afterUpgradeFrom18() { + SQLiteDatabase rawDb = SQLiteDatabase.create(null); + try { + // Simulate v18 trackpoint schema (no segment_id) + rawDb.execSQL("create table trackpoint (" + + "_id integer primary key autoincrement," + + "track_id integer not null," + + "latitude double not null," + + "longitude double not null," + + "speed double null," + + "elevation double null," + + "accuracy double null," + + "point_timestamp long not null," + + "compass_heading double null," + + "compass_accuracy integer null," + + "atmospheric_pressure double null)"); + + dbHelper.onUpgrade(rawDb, 18, 19); + + assertFalse("Bug B10: segment_id should be NOT NULL but is nullable after upgrade", + isColumnNotNull(rawDb, TrackContentProvider.Schema.TBL_TRACKPOINT, + TrackContentProvider.Schema.COL_SEG_ID)); + } finally { + rawDb.close(); + } + } + + /** + * Bug B10 — NULL can be inserted into segment_id after an upgrade from v18. + * + *

Because the ALTER TABLE omits NOT NULL, a NULL value can be successfully + * inserted into segment_id on an upgraded database. This would never happen + * on a fresh install where NOT NULL is enforced. + */ + @Test + public void bug_B10_segmentId_allowsNullInsert_afterUpgrade() { + SQLiteDatabase rawDb = SQLiteDatabase.create(null); + try { + rawDb.execSQL("create table trackpoint (" + + "_id integer primary key autoincrement," + + "track_id integer not null," + + "latitude double not null," + + "longitude double not null," + + "speed double null," + + "elevation double null," + + "accuracy double null," + + "point_timestamp long not null," + + "compass_heading double null," + + "compass_accuracy integer null," + + "atmospheric_pressure double null)"); + + dbHelper.onUpgrade(rawDb, 18, 19); + + // Insert with explicit NULL for segment_id + ContentValues values = new ContentValues(); + values.put(TrackContentProvider.Schema.COL_TRACK_ID, 1); + values.put(TrackContentProvider.Schema.COL_LATITUDE, 48.0); + values.put(TrackContentProvider.Schema.COL_LONGITUDE, 2.0); + values.put(TrackContentProvider.Schema.COL_TIMESTAMP, System.currentTimeMillis()); + values.putNull(TrackContentProvider.Schema.COL_SEG_ID); + + long rowId = rawDb.insert(TrackContentProvider.Schema.TBL_TRACKPOINT, null, values); + assertTrue("Bug B10: NULL insert into segment_id should succeed on upgraded DB", + rowId > 0); + + // Verify the value is actually NULL + Cursor c = rawDb.query(TrackContentProvider.Schema.TBL_TRACKPOINT, + new String[]{TrackContentProvider.Schema.COL_SEG_ID}, + "_id = ?", new String[]{String.valueOf(rowId)}, + null, null, null); + try { + assertTrue(c.moveToFirst()); + assertTrue("Bug B10: segment_id should be NULL", c.isNull(0)); + } finally { + c.close(); + } + } finally { + rawDb.close(); + } + } + + /** + * Bug B10 baseline — on a fresh install, segment_id enforces NOT NULL + * (or at least defaults to 0 when NULL is attempted). + * + *

This establishes the correct baseline against which the upgrade path diverges. + * On fresh installs, SQLite's NOT NULL with DEFAULT 0 causes a NULL insert to + * use the default value of 0 instead. + */ + @Test + public void bug_B10_segmentId_freshInstall_defaultsToZeroWhenNullInserted() { + SQLiteDatabase db = dbHelper.getWritableDatabase(); + + ContentValues values = new ContentValues(); + values.put(TrackContentProvider.Schema.COL_TRACK_ID, 1); + values.put(TrackContentProvider.Schema.COL_LATITUDE, 48.0); + values.put(TrackContentProvider.Schema.COL_LONGITUDE, 2.0); + values.put(TrackContentProvider.Schema.COL_TIMESTAMP, System.currentTimeMillis()); + // Don't set segment_id — should default to 0 + + long rowId = db.insert(TrackContentProvider.Schema.TBL_TRACKPOINT, null, values); + assertTrue("insert should succeed on fresh DB", rowId > 0); + + Cursor c = db.query(TrackContentProvider.Schema.TBL_TRACKPOINT, + new String[]{TrackContentProvider.Schema.COL_SEG_ID}, + "_id = ?", new String[]{String.valueOf(rowId)}, + null, null, null); + try { + assertTrue(c.moveToFirst()); + assertEquals("segment_id should default to 0 on fresh install", + 0, c.getInt(0)); + } finally { + c.close(); + } + } +}