Skip to content

Commit a1f2e5f

Browse files
authored
Stage and validate backups before restoring (#1343)
* Stage and validate backups before restoring * Document restore flow and cover legacy restore --------- Co-authored-by: ranchoiver <263007155+ranchoiver@users.noreply.github.com>
1 parent 690eb2f commit a1f2e5f

2 files changed

Lines changed: 569 additions & 74 deletions

File tree

Lines changed: 323 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,323 @@
1+
package com.health.openscale.core.usecase
2+
3+
import android.content.Context
4+
import android.content.ContextWrapper
5+
import android.database.sqlite.SQLiteDatabase
6+
import android.net.Uri
7+
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
8+
import androidx.room.Room
9+
import androidx.room.RoomDatabase
10+
import androidx.test.ext.junit.runners.AndroidJUnit4
11+
import androidx.test.platform.app.InstrumentationRegistry
12+
import com.health.openscale.core.data.ActivityLevel
13+
import com.health.openscale.core.data.GenderType
14+
import com.health.openscale.core.data.User
15+
import com.health.openscale.core.database.AppDatabase
16+
import com.health.openscale.core.database.DatabaseRepository
17+
import com.health.openscale.core.facade.SettingsFacadeImpl
18+
import kotlinx.coroutines.CoroutineScope
19+
import kotlinx.coroutines.Dispatchers
20+
import kotlinx.coroutines.SupervisorJob
21+
import kotlinx.coroutines.flow.first
22+
import kotlinx.coroutines.runBlocking
23+
import org.junit.After
24+
import org.junit.Assert.assertEquals
25+
import org.junit.Assert.assertFalse
26+
import org.junit.Assert.assertTrue
27+
import org.junit.Before
28+
import org.junit.Test
29+
import org.junit.runner.RunWith
30+
import java.io.File
31+
import java.io.FileOutputStream
32+
import java.util.zip.ZipEntry
33+
import java.util.zip.ZipOutputStream
34+
35+
@RunWith(AndroidJUnit4::class)
36+
class BackupRestoreUseCasesTest {
37+
private lateinit var baseContext: Context
38+
private lateinit var sandboxRoot: File
39+
private lateinit var sandboxContext: Context
40+
private lateinit var database: AppDatabase
41+
private lateinit var repository: DatabaseRepository
42+
private lateinit var useCases: BackupRestoreUseCases
43+
private lateinit var dbFile: File
44+
45+
@Before
46+
fun setUp() = runBlocking {
47+
baseContext = InstrumentationRegistry.getInstrumentation().targetContext
48+
sandboxRoot = File(baseContext.cacheDir, "backup-restore-test-${System.nanoTime()}").apply {
49+
mkdirs()
50+
}
51+
52+
sandboxContext = object : ContextWrapper(baseContext) {
53+
override fun getApplicationContext(): Context = this
54+
55+
override fun getDatabasePath(name: String): File {
56+
return File(sandboxRoot, name).also { file ->
57+
file.parentFile?.mkdirs()
58+
}
59+
}
60+
}
61+
62+
database = buildDatabase(sandboxContext)
63+
64+
repository = DatabaseRepository(
65+
database = database,
66+
userDao = database.userDao(),
67+
userGoalsDao = database.userGoalsDao(),
68+
measurementDao = database.measurementDao(),
69+
measurementTypeDao = database.measurementTypeDao(),
70+
measurementValueDao = database.measurementValueDao()
71+
)
72+
73+
val dataStore = PreferenceDataStoreFactory.create(
74+
scope = CoroutineScope(SupervisorJob() + Dispatchers.IO),
75+
produceFile = { File(sandboxRoot, "settings.preferences_pb") }
76+
)
77+
val settings = SettingsFacadeImpl(dataStore)
78+
useCases = BackupRestoreUseCases(sandboxContext, repository, settings)
79+
80+
repository.insertUser(
81+
User(
82+
name = "restore-test-user",
83+
birthDate = 946684800000L,
84+
gender = GenderType.FEMALE,
85+
heightCm = 170f,
86+
activityLevel = ActivityLevel.MODERATE,
87+
useAssistedWeighing = false
88+
)
89+
)
90+
91+
dbFile = sandboxContext.getDatabasePath(AppDatabase.DATABASE_NAME)
92+
assertTrue("expected seeded test database to exist", dbFile.exists())
93+
assertEquals(1, repository.getAllUsers().first().size)
94+
}
95+
96+
@After
97+
fun tearDown() {
98+
runCatching { database.close() }
99+
sandboxRoot.deleteRecursively()
100+
}
101+
102+
@Test
103+
fun restoreDatabase_withZipMissingMainDb_keepsExistingData() = runBlocking {
104+
val invalidZip = File(sandboxRoot, "invalid-backup.zip")
105+
ZipOutputStream(FileOutputStream(invalidZip)).use { zip ->
106+
zip.putNextEntry(ZipEntry("not-the-database.txt"))
107+
zip.write("wrong backup payload".toByteArray())
108+
zip.closeEntry()
109+
}
110+
111+
val result = useCases.restoreDatabase(Uri.fromFile(invalidZip), baseContext.contentResolver)
112+
113+
assertTrue("restore should fail for zip without openScale.db", result.isFailure)
114+
assertTrue("failed restore should leave the live database file in place", dbFile.exists())
115+
116+
assertEquals("failed restore should not mutate live in-memory data", 1, repository.getAllUsers().first().size)
117+
118+
val reopened = buildDatabase(sandboxContext)
119+
120+
try {
121+
val reopenedRepo = DatabaseRepository(
122+
database = reopened,
123+
userDao = reopened.userDao(),
124+
userGoalsDao = reopened.userGoalsDao(),
125+
measurementDao = reopened.measurementDao(),
126+
measurementTypeDao = reopened.measurementTypeDao(),
127+
measurementValueDao = reopened.measurementValueDao()
128+
)
129+
130+
assertEquals(
131+
"the original record should still exist after a failed restore",
132+
1,
133+
reopenedRepo.getAllUsers().first().size
134+
)
135+
} finally {
136+
reopened.close()
137+
}
138+
}
139+
140+
@Test
141+
fun restoreDatabase_withUnrelatedSqliteFile_keepsExistingData() = runBlocking {
142+
val unrelatedDb = File(sandboxRoot, "unrelated.db")
143+
val sqliteDb = SQLiteDatabase.openOrCreateDatabase(unrelatedDb, null)
144+
try {
145+
sqliteDb.execSQL("CREATE TABLE unrelated_data (id INTEGER PRIMARY KEY, value TEXT)")
146+
sqliteDb.execSQL("INSERT INTO unrelated_data(value) VALUES ('not openscale')")
147+
} finally {
148+
sqliteDb.close()
149+
}
150+
151+
val result = useCases.restoreDatabase(Uri.fromFile(unrelatedDb), baseContext.contentResolver)
152+
153+
assertTrue("restore should fail for unrelated SQLite databases", result.isFailure)
154+
assertTrue("failed restore should leave the live database file in place", dbFile.exists())
155+
assertEquals("failed restore should not mutate live in-memory data", 1, repository.getAllUsers().first().size)
156+
157+
val reopened = buildDatabase(sandboxContext)
158+
try {
159+
val reopenedRepo = DatabaseRepository(
160+
database = reopened,
161+
userDao = reopened.userDao(),
162+
userGoalsDao = reopened.userGoalsDao(),
163+
measurementDao = reopened.measurementDao(),
164+
measurementTypeDao = reopened.measurementTypeDao(),
165+
measurementValueDao = reopened.measurementValueDao()
166+
)
167+
168+
assertEquals(
169+
"the original record should still exist after rejecting an unrelated database",
170+
1,
171+
reopenedRepo.getAllUsers().first().size
172+
)
173+
} finally {
174+
reopened.close()
175+
}
176+
}
177+
178+
@Test
179+
fun restoreDatabase_withLegacySingleFile_restoresAndMigrates() = runBlocking {
180+
val legacyDb = File(sandboxRoot, "legacy-openscale.db")
181+
createLegacyDatabase(legacyDb)
182+
183+
val result = useCases.restoreDatabase(Uri.fromFile(legacyDb), baseContext.contentResolver)
184+
assertTrue("restore should accept legacy openScale single-file databases", result.isSuccess)
185+
186+
val reopened = buildDatabase(sandboxContext)
187+
try {
188+
val reopenedRepo = DatabaseRepository(
189+
database = reopened,
190+
userDao = reopened.userDao(),
191+
userGoalsDao = reopened.userGoalsDao(),
192+
measurementDao = reopened.measurementDao(),
193+
measurementTypeDao = reopened.measurementTypeDao(),
194+
measurementValueDao = reopened.measurementValueDao()
195+
)
196+
197+
val users = reopenedRepo.getAllUsers().first()
198+
assertEquals(1, users.size)
199+
assertEquals("legacy-user", users.single().name)
200+
} finally {
201+
reopened.close()
202+
}
203+
}
204+
205+
@Test
206+
fun restoreDatabase_withValidBackupZip_restoresPreviousSnapshot() = runBlocking {
207+
val backupZip = File(sandboxRoot, "valid-backup.zip")
208+
useCases.backupDatabase(Uri.fromFile(backupZip), baseContext.contentResolver).getOrThrow()
209+
210+
repository.insertUser(
211+
User(
212+
name = "post-backup-user",
213+
birthDate = 978307200000L,
214+
gender = GenderType.MALE,
215+
heightCm = 180f,
216+
activityLevel = ActivityLevel.MILD,
217+
useAssistedWeighing = false
218+
)
219+
)
220+
assertEquals(2, repository.getAllUsers().first().size)
221+
222+
val result = useCases.restoreDatabase(Uri.fromFile(backupZip), baseContext.contentResolver)
223+
assertTrue("restore from app-generated backup should succeed", result.isSuccess)
224+
225+
val reopened = buildDatabase(sandboxContext)
226+
try {
227+
val reopenedRepo = DatabaseRepository(
228+
database = reopened,
229+
userDao = reopened.userDao(),
230+
userGoalsDao = reopened.userGoalsDao(),
231+
measurementDao = reopened.measurementDao(),
232+
measurementTypeDao = reopened.measurementTypeDao(),
233+
measurementValueDao = reopened.measurementValueDao()
234+
)
235+
236+
val users = reopenedRepo.getAllUsers().first()
237+
assertEquals(1, users.size)
238+
assertEquals("restore-test-user", users.single().name)
239+
} finally {
240+
reopened.close()
241+
}
242+
}
243+
244+
private fun buildDatabase(context: Context): AppDatabase =
245+
Room.databaseBuilder(
246+
context,
247+
AppDatabase::class.java,
248+
AppDatabase.DATABASE_NAME
249+
)
250+
.setJournalMode(RoomDatabase.JournalMode.TRUNCATE)
251+
.addMigrations(
252+
com.health.openscale.core.database.MIGRATION_6_7,
253+
com.health.openscale.core.database.MIGRATION_7_8,
254+
com.health.openscale.core.database.MIGRATION_8_9,
255+
com.health.openscale.core.database.MIGRATION_9_10,
256+
com.health.openscale.core.database.MIGRATION_10_11,
257+
com.health.openscale.core.database.MIGRATION_11_12,
258+
com.health.openscale.core.database.MIGRATION_12_13,
259+
com.health.openscale.core.database.MIGRATION_13_14
260+
)
261+
.build()
262+
263+
private fun createLegacyDatabase(file: File) {
264+
val database = SQLiteDatabase.openOrCreateDatabase(file, null)
265+
try {
266+
database.execSQL(
267+
"""
268+
CREATE TABLE scaleUsers (
269+
id INTEGER PRIMARY KEY,
270+
username TEXT NOT NULL,
271+
birthday INTEGER NOT NULL,
272+
gender INTEGER NOT NULL,
273+
bodyHeight REAL NOT NULL,
274+
activityLevel INTEGER NOT NULL
275+
)
276+
""".trimIndent()
277+
)
278+
database.execSQL(
279+
"""
280+
CREATE TABLE scaleMeasurements (
281+
id INTEGER PRIMARY KEY,
282+
userId INTEGER NOT NULL,
283+
datetime INTEGER,
284+
enabled INTEGER NOT NULL,
285+
weight REAL,
286+
fat REAL,
287+
water REAL,
288+
muscle REAL,
289+
visceralFat REAL,
290+
lbm REAL,
291+
waist REAL,
292+
hip REAL,
293+
bone REAL,
294+
chest REAL,
295+
thigh REAL,
296+
biceps REAL,
297+
neck REAL,
298+
caliper1 REAL,
299+
caliper2 REAL,
300+
caliper3 REAL,
301+
calories REAL,
302+
comment TEXT
303+
)
304+
""".trimIndent()
305+
)
306+
database.execSQL(
307+
"""
308+
INSERT INTO scaleUsers (id, username, birthday, gender, bodyHeight, activityLevel)
309+
VALUES (1, 'legacy-user', 946684800000, 1, 168.0, 2)
310+
""".trimIndent()
311+
)
312+
database.execSQL(
313+
"""
314+
INSERT INTO scaleMeasurements (id, userId, datetime, enabled, weight, comment)
315+
VALUES (1, 1, 1712325600000, 1, 72.5, 'legacy measurement')
316+
""".trimIndent()
317+
)
318+
database.execSQL("PRAGMA user_version = 6")
319+
} finally {
320+
database.close()
321+
}
322+
}
323+
}

0 commit comments

Comments
 (0)