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
13 changes: 10 additions & 3 deletions edifikana/back-end/docs/datastore-test-catalog.md
Original file line number Diff line number Diff line change
Expand Up @@ -153,8 +153,15 @@ datastore query bodies, and mapper functions side by side — not assumptions fr
**no `purgeDocument` method at all**. Confirm there is genuinely no way to hard-delete a
document via the datastore layer (check whether `SupabaseIntegrationTest`'s cleanup path for
documents falls back to something else, or leaks rows across test runs). → **DS-DOC-007**
(`DS-CA-011`, `DS-OCC-008`, `DS-PAY-007`, `DS-RENT-006`, `DS-TASK-009` fixed — this closes out
Finding #5 except for `DS-DOC-007`)

**Fixed:** added `purgeDocument` (select-then-guard-then-delete, same shape as every other
`purgeX` method) to `DocumentDatastore`/`SupabaseDocumentDatastore`. `SupabaseIntegrationTest`'s
`documentResources` teardown now calls `deleteDocument` then `purgeDocument`, matching every
other resource type, instead of leaving soft-deleted rows to accumulate forever. Confirmed
live: `DS-DOC-007` (refuses to purge an active document) and `DS-DOC-007b` (a soft-deleted
document is genuinely gone afterward, confirmed via a raw untyped select) both pass.
(`DS-CA-011`, `DS-OCC-008`, `DS-PAY-007`, `DS-RENT-006`, `DS-TASK-009`, `DS-DOC-007` all
fixed — this closes out Finding #5 in full)
6. **`ON DELETE` behavior for property/unit children is inconsistent — half `CASCADE`, half
`RESTRICT` — and none of it fires today because every "delete" the app performs is a soft
`UPDATE`, not a real `DELETE`.** Confirmed via migrations: `units.property_id`,
Expand Down Expand Up @@ -294,7 +301,7 @@ datastore query bodies, and mapper functions side by side — not assumptions fr
| DS-DOC-004 | P1 | `getDocuments` for an org with documents belonging to a **different** org mixed in the table | Only the requested org's documents returned — scoping correctness at the query level (this is the sole authorization backstop for `DOC-005`/`DOC-011` in the API catalog, since there's no separate RBAC check inside the datastore) |
| DS-DOC-005 | P1 | `updateDocument(filename = ..., documentType = null)` and vice versa | Independent partial updates both work |
| DS-DOC-006 | P0 | `deleteDocument` then re-`getDocument` | Soft-delete confirmed via read-side filter |
| DS-DOC-007 | **P0** | Confirm no `purgeDocument` exists | Finding #5 — document rows can never be hard-deleted through this datastore; check `SupabaseIntegrationTest`'s cleanup registration for documents to see what it does instead (likely nothing, meaning integration test runs accumulate orphaned document rows over time) |
| DS-DOC-007 | **P0** | Confirm no `purgeDocument` exists | Fixed — `purgeDocument` added (consistent select-then-guard-then-delete pattern); `DS-DOC-007b` confirms a soft-deleted document is genuinely hard-deleted; integration test teardown updated to purge instead of leak |

### 4.3 `SupabaseEmployeeDatastore` (table: `employee`; view: `v_user_employees`)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import com.cramsan.edifikana.lib.model.property.PropertyId
import com.cramsan.edifikana.lib.model.unit.UnitId
import com.cramsan.edifikana.lib.model.user.UserId
import com.cramsan.framework.utils.uuid.UUID
import io.github.jan.supabase.postgrest.postgrest
import kotlinx.coroutines.runBlocking
import kotlin.test.BeforeTest
import kotlin.test.Test
Expand All @@ -19,11 +20,6 @@ import kotlin.test.assertTrue

/**
* Covers datastore-test-catalog.md §4.2 (DS-DOC-001..007).
*
* DS-DOC-007 (no `purgeDocument` method exists) is confirmed by static inspection of
* `DocumentDatastore`/`SupabaseDocumentDatastore` — there is no such method to call, so there is
* no runtime test for it. `SupabaseIntegrationTest.tearDown()` only soft-deletes document
* fixtures created via `registerDocumentForDeletion`; the rows remain, soft-deleted, forever.
*/
class SupabaseDocumentDatastoreIntegrationTest : SupabaseIntegrationTest() {

Expand Down Expand Up @@ -273,6 +269,58 @@ class SupabaseDocumentDatastoreIntegrationTest : SupabaseIntegrationTest() {
assertNull(getResult.getOrNull())
}

@Test
fun `DS-DOC-007 purgeDocument should refuse to purge an active (non-deleted) document`(): Unit = runBlocking {
// Arrange
val created = documentDatastore.createDocument(
orgId = orgId!!,
propertyId = null,
unitId = null,
filename = "${testPrefix}_active_purge_attempt.pdf",
mimeType = MimeType("application/pdf"),
documentType = DocumentType.PHOTO,
assetId = AssetId("documents/${testPrefix}_active_purge_attempt.pdf"),
createdBy = null,
).registerDocumentForDeletion().getOrThrow()

// Act - purge without a prior soft-delete; Document is now in the consistent guarded group
val purgeResult = documentDatastore.purgeDocument(created.id)

// Assert
assertTrue(purgeResult.isSuccess)
assertTrue(purgeResult.getOrNull() != true)
assertNotNull(documentDatastore.getDocument(created.id).getOrNull())
}

@Test
fun `DS-DOC-007b purgeDocument should permanently remove a soft-deleted document`(): Unit = runBlocking {
// Arrange
val created = documentDatastore.createDocument(
orgId = orgId!!,
propertyId = null,
unitId = null,
filename = "${testPrefix}_to_purge.pdf",
mimeType = MimeType("application/pdf"),
documentType = DocumentType.PHOTO,
assetId = AssetId("documents/${testPrefix}_to_purge.pdf"),
createdBy = null,
).registerDocumentForDeletion().getOrThrow()
assertTrue(documentDatastore.deleteDocument(created.id).getOrThrow())

// Act
val purgeResult = documentDatastore.purgeDocument(created.id)

// Assert - succeeds, and the row is genuinely gone (not just soft-deleted) - a raw,
// untyped select is required here since getDocument alone can't distinguish "gone" from
// "still soft-deleted," both return null.
assertTrue(purgeResult.isSuccess)
assertTrue(purgeResult.getOrNull() == true)
val remainingRows = supabase.postgrest.from("documents").select {
filter { eq("document_id", created.id.documentId) }
}.decodeList<kotlinx.serialization.json.JsonObject>()
assertTrue(remainingRows.isEmpty())
}

@Test
fun `getDocument should return null for nonexistent id`(): Unit = runBlocking {
// Arrange
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -477,9 +477,8 @@ abstract class SupabaseIntegrationTest : KoinTest {
results += notificationDatastore.purgeNotification(it)
}
documentResources.forEach {
// No purgeDocument exists (see DS-DOC-007) — soft-delete is the only
// cleanup available; document rows accumulate as soft-deleted after tests.
results += documentDatastore.deleteDocument(it)
results += documentDatastore.purgeDocument(it)
}
invitationResources.forEach {
results += membershipDatastore.cancelInvite(it)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,4 +57,12 @@ interface DocumentDatastore {
* Soft-deletes the document with the given [documentId]. Returns true if the record was deleted.
*/
suspend fun deleteDocument(documentId: DocumentId): Result<Boolean>

/**
* Permanently deletes a soft-deleted document record by [documentId].
* Only purges records that are already soft-deleted. This is intended for testing and
* maintenance purposes only.
* Returns the [Result] of the operation with a [Boolean] indicating if the record was purged.
*/
suspend fun purgeDocument(documentId: DocumentId): Result<Boolean>
}
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,39 @@ class SupabaseDocumentDatastore(private val postgrest: Postgrest, private val cl
}.decodeSingleOrNull<DocumentEntity>() != null
}

/**
* Permanently deletes a soft-deleted document by [documentId]. Returns true if successful.
* Only purges records that are already soft-deleted (deletedAt is not null).
*/

override suspend fun purgeDocument(documentId: DocumentId): Result<Boolean> =
runSuspendCatching(TAG) {
logD(TAG, "Purging document: %s", documentId)

// First verify the record exists and is soft-deleted
val entity =
postgrest
.from(DocumentEntity.COLLECTION)
.select {
filter {
DocumentEntity::documentId eq documentId.documentId
}
}.decodeSingleOrNull<DocumentEntity>()

// Only purge if it exists and is soft-deleted
if (entity?.deletedAt == null) {
return@runSuspendCatching false
}

// Delete the record
postgrest.from(DocumentEntity.COLLECTION).delete {
filter {
DocumentEntity::documentId eq documentId.documentId
}
}
true
}

companion object {
const val TAG = "SupabaseDocumentDatastore"
}
Expand Down