Skip to content
Merged
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
12 changes: 10 additions & 2 deletions edifikana/back-end/docs/datastore-test-catalog.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 = <purged invite>)` | 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) |

---
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down