fix(data): account-lifecycle data integrity (non-destructive upsert, id normalization, deleteAccount cleanup)

#309: AccountDao no longer uses @Insert(REPLACE). New insertIfAbsent (IGNORE)
+ @Update back a non-destructive upsert, and insertAtEnd updates an existing id
in place (preserving its sortOrder) instead of REPLACE. Re-adding an existing
account id (e.g. re-authing an Outlook account, whose id is the deterministic
outlook:<email>) therefore no longer cascade-deletes its account_settings +
signatures.

#305: normalizeEmailForAccountId always lowercases the domain (mail domains are
case-insensitive), and the whole address for the consumer providers (Gmail,
Yahoo, iCloud, AOL, Outlook). Applied at every id-derivation site
(MailProvider.createAccount, Account.outlook, ManualSetupViewModel) so
differently-cased addresses can't spawn duplicate accounts. The displayed email
keeps the user's casing.

#299: deleteAccount collects the account's message ids and draft attachment URIs
while the rows still exist, deletes the rows (now including the account's
drafts), then deleteRecursively()'s each message's on-disk attachment cache dir
and releases the drafts' now-unreferenced persistable URI grants.

Adds unit tests for id normalization + deleteAccount cleanup and DAO-level
instrumented tests for the non-destructive create/update.

Closes #309
Closes #305
Closes #299

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-04 02:13:21 -05:00
co-authored by Claude Opus 4.8
parent 7966efde2f
commit cae11233f3
13 changed files with 297 additions and 13 deletions
@@ -67,7 +67,9 @@ class AccountDaoTest {
}
@Test
fun upsertReplacesAnAccountWithTheSameId() = runBlocking {
fun upsertOnAConflictingIdUpdatesTheRowInPlace() = runBlocking {
// Non-destructive by design (issue #309): a second upsert of the same id refreshes the row
// rather than delete-then-reinserting it (which would cascade-delete settings/signatures).
dao.upsert(account("acct", "ada@example.org", displayName = "Ada"))
dao.upsert(account("acct", "ada@example.org", displayName = "Ada Lovelace"))
@@ -110,4 +110,40 @@ class AccountDatabaseTest {
db.signatureDao().observeForAccount("acct").first().isEmpty(),
)
}
@Test
fun upsertOnAnExistingIdUpdatesInPlaceWithoutCascadingSettingsOrSignatures() = runBlocking<Unit> {
// Regression for issue #309: an @Insert(REPLACE) upsert deletes-then-reinserts on an id
// conflict, firing the ON DELETE CASCADE that wipes the account's settings + signatures.
db.accountDao().upsert(account("acct"))
db.accountSettingsDao().upsert(AccountSettingsEntity("acct", signature = "Keep me"))
db.signatureDao().upsert(SignatureEntity("sig-1", "acct", "Work", "<p>Regards</p>", isDefault = true))
db.accountDao().upsert(account("acct").copy(displayName = "Updated"))
assertEquals("Updated", db.accountDao().getById("acct")?.displayName)
assertEquals("Keep me", db.accountSettingsDao().get("acct")?.signature)
assertEquals(1, db.signatureDao().observeForAccount("acct").first().size)
assertEquals(1, db.accountDao().getAll().size)
}
@Test
fun insertAtEndReAddingAnExistingAccountKeepsItsSettingsSignaturesAndPosition() = runBlocking<Unit> {
// The production re-add path (addOutlookAccount -> insertAtEnd -> upsert). Re-adding a
// deterministic id must not cascade-delete its settings/signatures nor move it to the end.
db.accountDao().insertAtEnd(account("a"))
db.accountDao().insertAtEnd(account("b"))
db.accountSettingsDao().upsert(AccountSettingsEntity("b", signature = "Sig B", signatureEnabled = false))
db.signatureDao().upsert(SignatureEntity("sig-b", "b", "Work", "<p>Regards</p>", isDefault = true))
db.accountDao().insertAtEnd(account("b").copy(displayName = "Renamed"))
// Updated in place, position preserved (still 1, not appended after), children intact.
assertEquals("Renamed", db.accountDao().getById("b")?.displayName)
assertEquals(1, db.accountDao().getById("b")?.sortOrder)
assertEquals("Sig B", db.accountSettingsDao().get("b")?.signature)
assertEquals(false, db.accountSettingsDao().get("b")?.signatureEnabled)
assertEquals(1, db.signatureDao().observeForAccount("b").first().size)
assertEquals(2, db.accountDao().getAll().size)
}
}