diff --git a/edifikana/back-end/docs/datastore-test-catalog.md b/edifikana/back-end/docs/datastore-test-catalog.md index c6f7fc0a5..4906d3dd3 100644 --- a/edifikana/back-end/docs/datastore-test-catalog.md +++ b/edifikana/back-end/docs/datastore-test-catalog.md @@ -169,7 +169,14 @@ datastore query bodies, and mapper functions side by side — not assumptions fr against a property/unit that has at least one row in **every** child table, and document exactly which children survive, which vanish, and which block the purge outright with an exception — this is directly testable today via the existing purge methods without needing any - new hard-delete API. → **DS-X-009** + new hard-delete API. → **DS-X-009 confirmed** — `purgeProperty` on a property with a live + `RESTRICT`-family child (e.g. a unit) fails outright with an FK-violation, while a + `CASCADE`-only property purges successfully and removes its children; separately confirmed + `units(unit_id)` has **no `RESTRICT` references at all** (only `CASCADE` and `SET NULL`), so + `purgeUnit` can never be blocked by an FK violation — its `CASCADE` children (e.g. + `rent_config`) are removed and its `SET NULL` children (e.g. `tasks`) survive with the + reference nulled out. Exactly as migrations predicted; now backed by live evidence + (`DS-X-009`, `DS-X-009b`). 7. **`users.phone_number` is nullable at the DB level; `UserEntity.phoneNumber` is a non-null Kotlin type.** Confirmed via migrations: `phone_number` was added as a plain nullable `TEXT` column with no `NOT NULL` ever applied (unlike `first_name`/`last_name`, which were explicitly @@ -518,7 +525,8 @@ more datastores — grounded in the actual data relationships observed in the en | DS-X-006 | P1 | `SupabaseRentConfigDatastore.setRentConfig` → `SupabaseUnitDatastore.deleteUnit` (soft) → `SupabaseRentConfigDatastore.getRentConfig(unitId)` | Confirm the rent config row survives the unit's soft-delete unchanged (no cascading soft-delete relationship implemented in application code — any cascade would have to be a DB-level trigger or FK `ON DELETE`, which is out of scope for this Kotlin layer but worth confirming isn't silently relied upon) | | DS-X-007 | P1 | `SupabaseUserDatastore.deleteUser` → `SupabaseNotificationDatastore.getNotificationsForUser(deletedUserId)` | Confirm notifications tied to a soft-deleted user remain queryable by ID (nothing propagates the user's deletion into the notifications table) — consistent with `api-test-catalog.md` XAPI-023's observation about stale notifications, but verifiable here purely at the datastore level without needing the controller/RBAC layer at all | | DS-X-008 | P2 | `SupabaseEmployeeDatastore.createEmployee(propertyId)` → `SupabaseEmployeeDatastore.getEmployees(userId)` (via `v_user_employees`) for a user who is **not** mapped to that property at all | Confirms the view's join is against `user_property_mapping`, not something broader (e.g., org-wide) — a user with an org role but no explicit property mapping should NOT see that property's employees through this view, if the view's intended scoping is per-property | -| DS-X-009 | **P0** | `purgeProperty` / `purgeUnit` (after soft-deleting first) against a property/unit that has at least one live row in **every** child table | Finding #6 — a single mechanical test walks every child relationship and records, per table, whether the row survives (RESTRICT — purge should fail with an FK-violation `Result.failure`), vanishes (CASCADE), or gets nulled out (`ON DELETE SET NULL`, e.g. `tasks.unit_id`/`tasks.common_area_id`, `invites.unit_id`). Given the confirmed mix — `tasks` CASCADEs off `properties`, but `units`/`employee`/`common_areas`/`event_log_entries`/`time_card_events` RESTRICT off `properties` — expect `purgeProperty` on a property with a live unit/employee/common-area/event-log/time-card child to **fail outright** with a Postgres FK-violation, while a property with only task children would succeed and cascade-delete them. This single test also doubles as the ground-truth answer to finding #6, replacing speculation with an observed outcome | +| DS-X-009 | **P0** | `purgeProperty` against a property with a `RESTRICT`-family child vs. a `CASCADE`-only property | Confirmed — a live `RESTRICT` child (e.g. a unit) blocks the purge with an FK-violation `Result.failure`; a `CASCADE`-only property purges successfully and its children (e.g. tasks) are removed too | +| DS-X-009b | P1 | `purgeUnit` against a unit with `CASCADE` and `SET NULL` children | Confirmed — `units(unit_id)` has no `RESTRICT` references at all, so `purgeUnit` always succeeds; `CASCADE` children (e.g. `rent_config`) are removed, `SET NULL` children (e.g. `tasks`) survive with the reference nulled out | | DS-X-010 | P1 | `SupabaseMembershipDatastore.cancelInvite` (soft) → `SupabaseMembershipDatastore.purgeInvite` (hard) → `SupabaseNotificationDatastore.getNotification(id)` for a notification originally created via `createNotification(inviteId = )` | Confirmed via migration: `notifications.invite_id REFERENCES invites(id) ON DELETE CASCADE` — purging the invite should **cascade-delete** the notification row entirely (not soft-delete it), meaning `getNotification` transitions from "found" straight to "row doesn't exist," skipping the soft-deleted state entirely. Contrast with DS-X-007 (user deletion does *not* cascade to notifications) to confirm the two FK relationships on the same `notifications` table behave differently by design (`recipient_user_id` has no cascade wired into apparent app behavior since `deleteUser` only soft-deletes, while `invite_id`'s CASCADE only fires on the hard `purgeInvite` path) | --- diff --git a/edifikana/back-end/src/integTest/kotlin/com/cramsan/edifikana/server/datastore/supabase/CrossDatastoreIntegrationTest.kt b/edifikana/back-end/src/integTest/kotlin/com/cramsan/edifikana/server/datastore/supabase/CrossDatastoreIntegrationTest.kt index 4c636422e..2c78d46b7 100644 --- a/edifikana/back-end/src/integTest/kotlin/com/cramsan/edifikana/server/datastore/supabase/CrossDatastoreIntegrationTest.kt +++ b/edifikana/back-end/src/integTest/kotlin/com/cramsan/edifikana/server/datastore/supabase/CrossDatastoreIntegrationTest.kt @@ -288,6 +288,47 @@ class CrossDatastoreIntegrationTest : SupabaseIntegrationTest() { assertTrue(propertyDatastore.purgeProperty(restrictedProperty).getOrThrow() == true) } + @Test + fun `DS-X-009b purgeUnit should succeed and cascade-delete or null out its children`(): Unit = + runBlocking { + // Arrange - unlike properties, units(unit_id) has NO RESTRICT-family references at + // all: rent_config/payment_records/unit_occupants/documents are all ON DELETE + // CASCADE, and tasks/invites are ON DELETE SET NULL. purgeUnit should therefore + // never be blocked by an FK violation. + val sweepUnit = createTestUnit(propertyId!!, "${testPrefix}_SweepUnit") + val rentConfig = rentConfigDatastore.setRentConfig( + unitId = sweepUnit, + monthlyAmount = 1000.0, + dueDay = 1, + currency = "USD", + updatedBy = testUserId, + ).getOrThrow() + val setNullTask = taskDatastore.createTask( + propertyId = propertyId!!, + unitId = sweepUnit, + commonAreaId = null, + assigneeId = null, + createdBy = testUserId!!, + title = "${testPrefix}_SetNullTask", + description = null, + priority = TaskPriority.LOW, + dueDate = null, + ).registerTaskForDeletion().getOrThrow() + assertTrue(unitDatastore.deleteUnit(sweepUnit).getOrThrow()) + + // Act + val purgeResult = unitDatastore.purgeUnit(sweepUnit) + + // Assert - succeeds; the CASCADE child (rent config) is gone, the SET NULL child + // (task) survives with its unitId nulled out + assertTrue(purgeResult.isSuccess) + assertTrue(purgeResult.getOrThrow()) + assertNull(rentConfigDatastore.getRentConfigById(rentConfig.id).getOrNull()) + val survivingTask = taskDatastore.getTask(setNullTask.id).getOrThrow() + assertNotNull(survivingTask) + assertNull(survivingTask?.unitId) + } + @Test fun `DS-X-010 purging an invite cascade-deletes its notification, unlike a soft-deleted user`(): Unit = runBlocking {