using DynamicCrud.Tests.Fixtures; using ServicesDAO.models; using ServicesDAO.Repositories; using Xunit; namespace DynamicCrud.Tests; public class UpsertCompositeKeyTests : IClassFixture { private readonly DbContextFixture _fixture; public UpsertCompositeKeyTests(DbContextFixture fixture) { _fixture = fixture; } // El job client_bkps sincroniza backup_tracking vía Upsert con conflict_fields // compuestos ["VeleroBackupName", "K8sContext"]. Este test cubre exactamente ese caso: // insertar, volver a upsertar con el mismo par, y comprobar que actualiza en vez de duplicar. [Fact] public async Task Upsert_BackupTracking_CompositeKey_UpdatesInsteadOfDuplicating() { var entityType = ModelRegistry.Resolve("backup_tracking")!; var statusId = await GetABackupDomainStatusId(); var veleroBackupName = $"test-upsert-{Guid.NewGuid():N}"; var k8sContext = "test-context-upsert"; var conflictFields = new[] { "VeleroBackupName", "K8sContext" }; var insertData = new Dictionary { ["veleroBackupName"] = veleroBackupName, ["k8sContext"] = k8sContext, ["veleroNamespace"] = "velero", ["backupDatetime"] = DateTime.UtcNow, ["statusId"] = statusId, ["phase"] = "New", }; using var ctx1 = _fixture.CreateContext(); var (insertOk, insertMsg, insertStatus, insertedId, insertDetail) = await RepoHelper.Upsert(RepoHelper.Create(ctx1, entityType), insertData, conflictFields); Assert.True(insertOk, $"Upsert (insert) falló: {insertDetail}"); Assert.Equal("success", insertStatus); Assert.NotNull(insertedId); var updateData = new Dictionary { ["veleroBackupName"] = veleroBackupName, ["k8sContext"] = k8sContext, ["veleroNamespace"] = "velero", ["backupDatetime"] = DateTime.UtcNow, ["statusId"] = statusId, ["phase"] = "Completed", }; using var ctx2 = _fixture.CreateContext(); var (updateOk, updateMsg, updateStatus, updatedInsertedId, updateDetail) = await RepoHelper.Upsert(RepoHelper.Create(ctx2, entityType), updateData, conflictFields); Assert.True(updateOk, $"Upsert (update) falló: {updateDetail}"); Assert.Null(updatedInsertedId); // null insertedId == tomó la rama de UPDATE, no INSERT using var ctx3 = _fixture.CreateContext(); var matches = await RepoHelper.Search( RepoHelper.Create(ctx3, entityType), and: new[] { new FilterCondition("veleroBackupName", "eq", veleroBackupName), new FilterCondition("k8sContext", "eq", k8sContext), }); Assert.Single(matches); // una sola fila: no se duplicó using var ctx4 = _fixture.CreateContext(); await RepoHelper.Delete(RepoHelper.Create(ctx4, entityType), insertedId!); } // Cubre el fix de BaseRepository.Upsert: si un conflict_field no viene en `data`, // debe fallar explícito en vez de degradar el filtro en silencio (ver el PR). [Fact] public async Task Upsert_BackupTracking_MissingConflictFieldInData_ReturnsExplicitError() { var entityType = ModelRegistry.Resolve("backup_tracking")!; var statusId = await GetABackupDomainStatusId(); var data = new Dictionary { ["veleroBackupName"] = $"test-upsert-missing-{Guid.NewGuid():N}", // K8sContext deliberadamente omitido. ["veleroNamespace"] = "velero", ["backupDatetime"] = DateTime.UtcNow, ["statusId"] = statusId, }; using var ctx = _fixture.CreateContext(); var (ok, message, status, insertedId, detail) = await RepoHelper.Upsert( RepoHelper.Create(ctx, entityType), data, new[] { "VeleroBackupName", "K8sContext" }); Assert.False(ok); Assert.Equal("error", status); Assert.Null(insertedId); Assert.Contains("K8sContext", message); } // backup_tracking.status_id referencia status_catalog (dominio "backup" vía // status_type.id_type) — ver migrations/2026-08_backup_status_catalog_revert.sql. private async Task GetABackupDomainStatusId() { using var typeCtx = _fixture.CreateContext(); var typeRepo = RepoHelper.Create(typeCtx, typeof(StatusType)); var backupType = (await RepoHelper.Search( typeRepo, and: new[] { new FilterCondition("name", "eq", "backup") })) .Cast().FirstOrDefault(); Assert.True(backupType is not null, "status_type no tiene una fila 'backup' — necesaria para este test " + "(ver migrations/2026-08_backup_status_catalog_revert.sql, PARTE 1, semillas)."); using var catalogCtx = _fixture.CreateContext(); var catalogRepo = RepoHelper.Create(catalogCtx, typeof(StatusCatalog)); var backupStatus = (await RepoHelper.Search( catalogRepo, and: new[] { new FilterCondition("idType", "eq", backupType!.StatusTypeID.ToString()) })) .Cast().FirstOrDefault(); Assert.True(backupStatus is not null, "status_catalog no tiene filas para el dominio 'backup' — necesarias para este test " + "(ver migrations/2026-08_backup_status_catalog_revert.sql, PARTE 1, semillas)."); return backupStatus!.StatusCatalogID; } }