Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import com.plusmobileapps.chefmate.auth.data.AuthenticationRepository
import com.plusmobileapps.chefmate.auth.usecase.DeleteAccountUseCase
import com.plusmobileapps.chefmate.auth.usecase.SignOutUseCase
import com.plusmobileapps.chefmate.di.AppScope
import com.plusmobileapps.chefmate.grocery.data.GroceryCategoryOverrideRepository
import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.SingleIn
Expand All @@ -14,13 +15,17 @@ import dev.zacsweers.metro.SingleIn
class DeleteAccountUseCaseImpl(
private val authenticationRepository: AuthenticationRepository,
private val signOutUseCase: SignOutUseCase,
private val groceryCategoryOverrideRepository: GroceryCategoryOverrideRepository,
) : DeleteAccountUseCase {
override suspend fun invoke(): Result<Unit> {
// Delete the remote account first. Only if that succeeds do we tear down the local session
// and data — otherwise we'd leave the user signed out with their cloud account intact.
val result = authenticationRepository.deleteAccount()
if (result.isFailure) return result
signOutUseCase()
// Sign-out deliberately preserves the device-local grocery category rules, but account
// deletion is explicit erasure — so wipe them here.
groceryCategoryOverrideRepository.clearLocalData()
return Result.success(Unit)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,10 @@ class SignInUseCaseImpl(
recipeRepository.clearLocalData()
categoryRepository.clearLocalData()
groceryAutocompleteRepository.clearLocalData()
// Grocery category rules are deliberately NOT cleared. The wipe above exists to avoid
// reconciling anon-owned rows against the new account's pull — but rules never sync, so
// there is no pull to reconcile and clearing would just destroy a guest's rules the
// moment they sign up. See SignOutUseCaseImpl for the full rationale.
groceryRepository.clearLocalData()
groceryRepository.ensureDefaultList()
aiChatLocalDataCleaner.clearLocalData()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,11 @@ class SignOutUseCaseImpl(
recipeBookRepository.clearLocalData()
categoryRepository.clearLocalData()
groceryAutocompleteRepository.clearLocalData()
// Grocery category rules are deliberately NOT cleared here. Every other repository above
// is server-backed, so wiping it locally is recoverable on the next sign-in. Category
// rules are device-local (no Supabase table yet), so clearing them would destroy the
// user's rules permanently. They're treated as a device preference that outlives the
// session; DeleteAccountUseCase still wipes them, since that's explicit erasure.
groceryRepository.clearLocalData()
groceryRepository.ensureDefaultList()
aiChatLocalDataCleaner.clearLocalData()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,25 @@ package com.plusmobileapps.chefmate.auth.usecase.impl

import com.plusmobileapps.chefmate.auth.data.testing.FakeAuthenticationRepository
import com.plusmobileapps.chefmate.auth.usecase.SignOutUseCase
import com.plusmobileapps.chefmate.grocery.data.GroceryCategory
import com.plusmobileapps.chefmate.grocery.data.testing.FakeGroceryCategoryOverrideRepository
import io.kotest.matchers.shouldBe
import kotlin.test.Test
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest

class DeleteAccountUseCaseImplTest {

private val authenticationRepository = FakeAuthenticationRepository()
private var signedOut = false
private val signOutUseCase = SignOutUseCase { signedOut = true }
private val groceryCategoryOverrideRepository = FakeGroceryCategoryOverrideRepository()

private val useCase =
DeleteAccountUseCaseImpl(
authenticationRepository = authenticationRepository,
signOutUseCase = signOutUseCase,
groceryCategoryOverrideRepository = groceryCategoryOverrideRepository,
)

@Test
Expand All @@ -38,4 +43,25 @@ class DeleteAccountUseCaseImplTest {
result.isFailure shouldBe true
signedOut shouldBe false
}

@Test
fun When_account_is_deleted_Then_grocery_category_rules_are_wiped() = runTest {
// Sign-out preserves the device-local rules, but deleting the account is explicit erasure.
groceryCategoryOverrideRepository.setOverride("Cold brew", GroceryCategory.BEVERAGES)

useCase()

groceryCategoryOverrideRepository.observeOverrides().first() shouldBe emptyList()
}

@Test
fun When_remote_deletion_fails_Then_grocery_category_rules_are_preserved() = runTest {
groceryCategoryOverrideRepository.setOverride("Cold brew", GroceryCategory.BEVERAGES)
authenticationRepository.deleteAccountResult = Result.failure(RuntimeException("boom"))

useCase()

groceryCategoryOverrideRepository.observeOverrideMap().first() shouldBe
mapOf("cold brew" to GroceryCategory.BEVERAGES)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ package com.plusmobileapps.chefmate.auth.usecase.impl

import com.plusmobileapps.chefmate.aichat.AiChatLocalDataCleaner
import com.plusmobileapps.chefmate.auth.data.testing.FakeAuthenticationRepository
import com.plusmobileapps.chefmate.grocery.data.GroceryCategory
import com.plusmobileapps.chefmate.grocery.data.testing.FakeGroceryAutocompleteRepository
import com.plusmobileapps.chefmate.grocery.data.testing.FakeGroceryCategoryOverrideRepository
import com.plusmobileapps.chefmate.grocery.data.testing.FakeGroceryRepository
import com.plusmobileapps.chefmate.meal.data.testing.FakeMealPlanRepository
import com.plusmobileapps.chefmate.recipe.data.Category
Expand Down Expand Up @@ -35,6 +37,7 @@ class SignOutUseCaseImplTest {
)
private val categoryRepository = FakeCategoryRepository(categories)
private val groceryAutocompleteRepository = FakeGroceryAutocompleteRepository()
private val groceryCategoryOverrideRepository = FakeGroceryCategoryOverrideRepository()
private var aiChatCleared = false
private val aiChatLocalDataCleaner = AiChatLocalDataCleaner { aiChatCleared = true }

Expand Down Expand Up @@ -70,4 +73,16 @@ class SignOutUseCaseImplTest {

recipeBookRepository.getRecipeBooks().first() shouldBe emptyList()
}

@Test
fun When_signing_out_Then_grocery_category_rules_are_preserved() = runTest {
// Category rules are device-local (no backend table), so wiping them on sign-out would
// destroy them permanently rather than restoring them on the next sign-in.
groceryCategoryOverrideRepository.setOverride("Cold brew", GroceryCategory.BEVERAGES)

useCase()

groceryCategoryOverrideRepository.observeOverrideMap().first() shouldBe
mapOf("cold brew" to GroceryCategory.BEVERAGES)
}
}
3 changes: 3 additions & 0 deletions client/composeApp/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,8 @@ kotlin {
api(projects.client.featureflag.public)
api(projects.client.grocery.autocomplete.impl)
api(projects.client.grocery.autocomplete.public)
api(projects.client.grocery.categoryRules.impl)
api(projects.client.grocery.categoryRules.public)
api(projects.client.grocery.data.impl)
api(projects.client.grocery.core.impl)
api(projects.client.grocery.core.public)
Expand Down Expand Up @@ -168,6 +170,7 @@ kotlin {
implementation(projects.client.subscription.testing)
implementation(projects.client.recipe.categories.implRobots)
implementation(projects.client.grocery.autocomplete.implRobots)
implementation(projects.client.grocery.categoryRules.implRobots)
implementation(projects.client.grocery.core.implRobots)
implementation(projects.client.recipe.core.implRobots)
implementation(projects.client.recipe.list.implRobots)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import com.plusmobileapps.chefmate.database.CategoryQueries
import com.plusmobileapps.chefmate.database.CookingSessionQueries
import com.plusmobileapps.chefmate.database.Database
import com.plusmobileapps.chefmate.database.GroceryAutocompleteItemQueries
import com.plusmobileapps.chefmate.database.GroceryCategoryOverrideQueries
import com.plusmobileapps.chefmate.database.GroceryListMemberQueries
import com.plusmobileapps.chefmate.database.GroceryListQueries
import com.plusmobileapps.chefmate.database.GroceryQueries
Expand Down Expand Up @@ -50,6 +51,10 @@ abstract class BaseTestApplicationComponent : TestApplicationComponent {
fun providesGroceryAutocompleteItemQueries(database: Database): GroceryAutocompleteItemQueries =
database.groceryAutocompleteItemQueries

@Provides
fun providesGroceryCategoryOverrideQueries(database: Database): GroceryCategoryOverrideQueries =
database.groceryCategoryOverrideQueries

@Provides
fun providesMealPlanQueries(database: Database): MealPlanQueries = database.mealPlanQueries

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package com.plusmobileapps.chefmate.tests

import androidx.compose.ui.test.ExperimentalTestApi
import com.plusmobileapps.chefmate.grocery.categoryrules.robots.groceryCategoryRules
import com.plusmobileapps.chefmate.harness.runRootBlocTest
import com.plusmobileapps.chefmate.recipe.bottomnav.robots.bottomNav
import com.plusmobileapps.chefmate.settings.robots.more
import com.plusmobileapps.chefmate.settings.root.robots.settingsRoot
import kotlin.test.Test

@OptIn(ExperimentalTestApi::class)
class GroceryCategoryRulesNavigationUiTest {

@Test
fun opening_settings_then_category_rules_lands_on_the_management_screen() = runRootBlocTest {
bottomNav().clickMoreTab()
more().awaitDisplayed().clickAppSettingsRow()

settingsRoot().awaitDisplayed().clickRow("Category rules")

// The "Your rules" section header renders near the top of the list, so it's a stable
// signal the management screen loaded.
groceryCategoryRules().awaitDisplayed().assertTextDisplayed("Your rules")
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import com.plusmobileapps.chefmate.database.CategoryQueries
import com.plusmobileapps.chefmate.database.CookingSessionQueries
import com.plusmobileapps.chefmate.database.Database
import com.plusmobileapps.chefmate.database.GroceryAutocompleteItemQueries
import com.plusmobileapps.chefmate.database.GroceryCategoryOverrideQueries
import com.plusmobileapps.chefmate.database.GroceryListMemberQueries
import com.plusmobileapps.chefmate.database.GroceryListQueries
import com.plusmobileapps.chefmate.database.GroceryQueries
Expand Down Expand Up @@ -94,4 +95,9 @@ interface DatabaseComponent {
@Provides
fun providesGroceryAutocompleteItemQueries(database: Database): GroceryAutocompleteItemQueries =
database.groceryAutocompleteItemQueries

@SingleIn(AppScope::class)
@Provides
fun providesGroceryCategoryOverrideQueries(database: Database): GroceryCategoryOverrideQueries =
database.groceryCategoryOverrideQueries
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
-- User-defined "always file <name> under <aisle>" rules (see GroceryCategoryOverride.sq).
-- Sync-ready columns (remoteId, clientId, isDirty, ownerId) are unused while the
-- feature is local-only; a follow-up wires Supabase sync without a migration.
CREATE TABLE GroceryCategoryOverride (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
categoryKey TEXT NOT NULL,
createdAt TEXT NOT NULL DEFAULT (datetime('now')),
remoteId TEXT UNIQUE,
clientId TEXT,
isDirty INTEGER NOT NULL DEFAULT 0,
ownerId TEXT
);

CREATE UNIQUE INDEX idx_grocery_category_override_name ON GroceryCategoryOverride(name COLLATE NOCASE);
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import kotlin.Boolean;

-- User-defined "always file <name> under <aisle>" rules. When a grocery item's
-- name matches a rule, that rule's category wins over the IngredientParser guess
-- (but a per-item stored aisle on the Grocery row still wins over the rule).
--
-- `categoryKey` stores a GroceryCategory enum name today. It is a free-form TEXT
-- column so a future "custom aisle" (Phase 2) can store its own key without a
-- schema migration.
--
-- `remoteId`, `clientId`, `isDirty`, and `ownerId` mirror the GroceryAutocompleteItem
-- table's sync scaffolding. The feature is local-only for now; a follow-up wires
-- Supabase sync without another migration.
CREATE TABLE GroceryCategoryOverride (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
categoryKey TEXT NOT NULL,
createdAt TEXT NOT NULL DEFAULT (datetime('now')),
remoteId TEXT UNIQUE,
clientId TEXT,
isDirty INTEGER AS Boolean NOT NULL DEFAULT 0,
ownerId TEXT
);

-- Case-insensitive uniqueness: one rule per name, so "Cold Brew" and "cold brew"
-- can't both hold conflicting rules.
CREATE UNIQUE INDEX idx_grocery_category_override_name ON GroceryCategoryOverride(name COLLATE NOCASE);

getAll:
SELECT * FROM GroceryCategoryOverride ORDER BY name COLLATE NOCASE ASC;

getById:
SELECT * FROM GroceryCategoryOverride WHERE id = ?;

getByName:
SELECT * FROM GroceryCategoryOverride WHERE name = ? COLLATE NOCASE;

getByRemoteId:
SELECT * FROM GroceryCategoryOverride WHERE remoteId = ?;

getByClientId:
SELECT * FROM GroceryCategoryOverride WHERE clientId = ?;

-- Upsert on the case-insensitive name so re-tagging an existing name updates its
-- aisle instead of failing the unique index. Marked dirty for a future sync push.
upsert:
INSERT INTO GroceryCategoryOverride (name, categoryKey, clientId, ownerId, isDirty)
VALUES (?, ?, ?, ?, 1)
ON CONFLICT(name COLLATE NOCASE) DO UPDATE SET
categoryKey = excluded.categoryKey,
isDirty = 1;

createWithRemoteId:
INSERT INTO GroceryCategoryOverride (name, categoryKey, remoteId, clientId, ownerId)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(remoteId) DO UPDATE SET
name = excluded.name,
categoryKey = excluded.categoryKey,
clientId = excluded.clientId,
ownerId = excluded.ownerId;

lastInsertId:
SELECT MAX(id) FROM GroceryCategoryOverride;

getUnsynced:
SELECT * FROM GroceryCategoryOverride WHERE remoteId IS NULL OR isDirty = 1;

updateRemoteId:
UPDATE GroceryCategoryOverride SET remoteId = ?, isDirty = 0 WHERE id = ?;

updateClientId:
UPDATE GroceryCategoryOverride SET clientId = ? WHERE id = ?;

deleteById:
DELETE FROM GroceryCategoryOverride WHERE id = ?;

deleteByName:
DELETE FROM GroceryCategoryOverride WHERE name = ? COLLATE NOCASE;

deleteAll:
DELETE FROM GroceryCategoryOverride;
17 changes: 17 additions & 0 deletions client/grocery/category-rules/impl-robots/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
plugins {
alias(libs.plugins.kmpLibrary)
alias(libs.plugins.compose)
}

kotlin {
sourceSets {
commonMain.dependencies {
implementation(projects.client.grocery.categoryRules.public)
}
}
}

plusLibrary {
namespace = "com.plusmobileapps.chefmate.grocery.categoryrules.robots"
uiTest = true
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
@file:OptIn(ExperimentalTestApi::class)

package com.plusmobileapps.chefmate.grocery.categoryrules.robots

import androidx.compose.ui.test.ComposeUiTest
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.hasAnyAncestor
import androidx.compose.ui.test.hasTestTag
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTextInput
import androidx.compose.ui.test.waitUntilExactlyOneExists
import com.plusmobileapps.chefmate.grocery.categoryrules.GroceryCategoryRulesTestTags

/**
* Robot for the Settings → Grocery → Category rules management screen. Every node lookup is scoped
* under [GroceryCategoryRulesTestTags.SCREEN] so a rule label here never matches a like-named node
* on another screen (e.g. the same item name rendered in the grocery list).
*/
class GroceryCategoryRulesRobot(private val test: ComposeUiTest) {

private val onScreen = hasAnyAncestor(hasTestTag(GroceryCategoryRulesTestTags.SCREEN))

fun awaitDisplayed(): GroceryCategoryRulesRobot = apply {
test.waitUntilExactlyOneExists(hasTestTag(GroceryCategoryRulesTestTags.SCREEN))
}

fun assertTextDisplayed(text: String): GroceryCategoryRulesRobot = apply {
test.onNode(hasText(text, substring = true) and onScreen).assertIsDisplayed()
}

fun openAddField(): GroceryCategoryRulesRobot = apply {
test.onNode(hasTestTag(GroceryCategoryRulesTestTags.ADD_BUTTON) and onScreen).performClick()
}

fun typeRuleName(name: String): GroceryCategoryRulesRobot = apply {
test
.onNode(hasTestTag(GroceryCategoryRulesTestTags.CREATE_FIELD) and onScreen)
.performTextInput(name)
}
}

fun ComposeUiTest.groceryCategoryRules(): GroceryCategoryRulesRobot =
GroceryCategoryRulesRobot(this)
Loading
Loading