fix(rutinas): cascade al borrar del catálogo, orden por día y test de migración real

- Changeset 004: ON DELETE CASCADE en fk_routine_assignment_task; borrar
  una rutina del catálogo arrastra sus asignaciones en vez de fallar (test).
- El orderIndex de la asignación multi-día lo calcula el backend al final
  de CADA día destino (antes lo enviaba el front desde el día visible).
- MigrationIT con Testcontainers: el changelog completo y el mapeo JPA se
  verifican contra Postgres 16 real (H2 no detecta su tipado estricto).
- DataSeeder delega en InitialDatasetService: una sola fuente de verdad
  del dataset inicial; la demo solo añade sus extras.
- Eliminado .gitkeep de changes/ (WARN de Liquibase en cada arranque).
This commit is contained in:
Jaume Garriga Maestre
2026-07-12 22:02:22 +02:00
parent 5421080b04
commit 3ec611d373
10 changed files with 202 additions and 81 deletions

View File

@@ -25,11 +25,16 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.liquibase:liquibase-core' implementation 'org.liquibase:liquibase-core'
runtimeOnly 'org.postgresql:postgresql' runtimeOnly 'org.postgresql:postgresql'
// H2 solo en test: permite que el contexto cargue sin un Postgres real en la // H2 en test para los tests rápidos de servicio/web. La verificación de las
// Fase 1 (esqueleto). La infraestructura de test realista (Testcontainers) // migraciones Liquibase y del mapeo JPA (ddl-auto=validate) se hace contra un
// se introducirá en la Fase 2, junto con el dominio. // Postgres REAL vía Testcontainers (MigrationIT): H2 no detecta
// incompatibilidades de tipos/SQL propias de Postgres (lección aprendida con
// el CAST(NULL AS INTEGER) del changeset 003).
testRuntimeOnly 'com.h2database:h2' testRuntimeOnly 'com.h2database:h2'
testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.boot:spring-boot-testcontainers'
testImplementation 'org.testcontainers:junit-jupiter'
testImplementation 'org.testcontainers:postgresql'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher' testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
} }

View File

@@ -7,18 +7,16 @@ import es.asepeyo.recordalexia.domain.EventType;
import es.asepeyo.recordalexia.domain.Family; import es.asepeyo.recordalexia.domain.Family;
import es.asepeyo.recordalexia.domain.MaterialItem; import es.asepeyo.recordalexia.domain.MaterialItem;
import es.asepeyo.recordalexia.domain.Reward; import es.asepeyo.recordalexia.domain.Reward;
import es.asepeyo.recordalexia.domain.RoutineTask;
import es.asepeyo.recordalexia.domain.SpecialEvent; import es.asepeyo.recordalexia.domain.SpecialEvent;
import es.asepeyo.recordalexia.domain.WeeklyTemplateEntry; import es.asepeyo.recordalexia.domain.WeeklyTemplateEntry;
import es.asepeyo.recordalexia.repository.ActivityRepository; import es.asepeyo.recordalexia.repository.ActivityRepository;
import es.asepeyo.recordalexia.repository.AfternoonRoutineRepository;
import es.asepeyo.recordalexia.repository.ChildRepository; import es.asepeyo.recordalexia.repository.ChildRepository;
import es.asepeyo.recordalexia.repository.FamilyRepository; import es.asepeyo.recordalexia.repository.FamilyRepository;
import es.asepeyo.recordalexia.repository.MaterialItemRepository; import es.asepeyo.recordalexia.repository.MaterialItemRepository;
import es.asepeyo.recordalexia.repository.RewardRepository; import es.asepeyo.recordalexia.repository.RewardRepository;
import es.asepeyo.recordalexia.repository.RoutineTaskRepository;
import es.asepeyo.recordalexia.repository.SpecialEventRepository; import es.asepeyo.recordalexia.repository.SpecialEventRepository;
import es.asepeyo.recordalexia.repository.WeeklyTemplateEntryRepository; import es.asepeyo.recordalexia.repository.WeeklyTemplateEntryRepository;
import es.asepeyo.recordalexia.service.InitialDatasetService;
import java.time.Clock; import java.time.Clock;
import java.time.DayOfWeek; import java.time.DayOfWeek;
import java.time.LocalDate; import java.time.LocalDate;
@@ -52,29 +50,26 @@ public class DataSeeder implements ApplicationRunner {
private final MaterialItemRepository materialRepository; private final MaterialItemRepository materialRepository;
private final ActivityRepository activityRepository; private final ActivityRepository activityRepository;
private final WeeklyTemplateEntryRepository templateRepository; private final WeeklyTemplateEntryRepository templateRepository;
private final AfternoonRoutineRepository routineRepository;
private final RoutineTaskRepository routineTaskRepository;
private final SpecialEventRepository eventRepository; private final SpecialEventRepository eventRepository;
private final RewardRepository rewardRepository; private final RewardRepository rewardRepository;
private final InitialDatasetService initialDataset;
private final PasswordEncoder passwordEncoder; private final PasswordEncoder passwordEncoder;
private final Clock clock; private final Clock clock;
public DataSeeder(FamilyRepository familyRepository, ChildRepository childRepository, public DataSeeder(FamilyRepository familyRepository, ChildRepository childRepository,
MaterialItemRepository materialRepository, ActivityRepository activityRepository, MaterialItemRepository materialRepository, ActivityRepository activityRepository,
WeeklyTemplateEntryRepository templateRepository, WeeklyTemplateEntryRepository templateRepository,
AfternoonRoutineRepository routineRepository,
RoutineTaskRepository routineTaskRepository,
SpecialEventRepository eventRepository, RewardRepository rewardRepository, SpecialEventRepository eventRepository, RewardRepository rewardRepository,
InitialDatasetService initialDataset,
PasswordEncoder passwordEncoder, Clock clock) { PasswordEncoder passwordEncoder, Clock clock) {
this.familyRepository = familyRepository; this.familyRepository = familyRepository;
this.childRepository = childRepository; this.childRepository = childRepository;
this.materialRepository = materialRepository; this.materialRepository = materialRepository;
this.activityRepository = activityRepository; this.activityRepository = activityRepository;
this.templateRepository = templateRepository; this.templateRepository = templateRepository;
this.routineRepository = routineRepository;
this.routineTaskRepository = routineTaskRepository;
this.eventRepository = eventRepository; this.eventRepository = eventRepository;
this.rewardRepository = rewardRepository; this.rewardRepository = rewardRepository;
this.initialDataset = initialDataset;
this.passwordEncoder = passwordEncoder; this.passwordEncoder = passwordEncoder;
this.clock = clock; this.clock = clock;
} }
@@ -89,9 +84,10 @@ public class DataSeeder implements ApplicationRunner {
Family family = familyRepository.save(new Family(DEMO_EMAIL, Family family = familyRepository.save(new Family(DEMO_EMAIL,
passwordEncoder.encode(DEMO_PASSWORD), "Familia demo", passwordEncoder.encode(DEMO_PIN))); passwordEncoder.encode(DEMO_PASSWORD), "Familia demo", passwordEncoder.encode(DEMO_PIN)));
var materials = seedMaterials(family); // Catálogo base (materiales + rutinas): la MISMA provisión que recibe cualquier
var activities = seedActivities(family, materials); // familia real al registrarse. La demo solo añade sus extras encima.
var routines = seedRoutineCatalog(family); initialDataset.provisionFamily(family);
var activities = seedActivities(family);
seedRewards(family); seedRewards(family);
Child nora = childRepository.save(child(family, "Nora", "🦊", "#F2A65A", 7, 42, LocalTime.of(8, 30))); Child nora = childRepository.save(child(family, "Nora", "🦊", "#F2A65A", 7, 42, LocalTime.of(8, 30)));
@@ -100,7 +96,8 @@ public class DataSeeder implements ApplicationRunner {
for (Child c : List.of(nora, leo, mia)) { for (Child c : List.of(nora, leo, mia)) {
seedWeeklyMornings(c, activities); seedWeeklyMornings(c, activities);
seedAfternoonRoutines(c, routines); // Rutinas de tarde escalonadas por edad, desde el catálogo de la familia.
initialDataset.provisionChildRoutines(c);
} }
LocalDate today = LocalDate.now(clock); LocalDate today = LocalDate.now(clock);
@@ -110,38 +107,40 @@ public class DataSeeder implements ApplicationRunner {
"Ficha de mates", "Fitxa de mates", "📎", "#5B8DEF")); "Ficha de mates", "Fitxa de mates", "📎", "#5B8DEF"));
} }
private MaterialsCatalog seedMaterials(Family family) {
return new MaterialsCatalog(
material(family, "Estuche", "Estoig", "✏️", "#F4C95D", "general"),
material(family, "Libro de mates", "Llibre de mates", "📘", "#5B8DEF", "matematicas"),
material(family, "Regla", "Regle", "📏", "#5B8DEF", "matematicas"),
material(family, "Flauta", "Flauta", "🎵", "#A78BD0", "musica"),
material(family, "Libreta", "Llibreta", "📓", "#A78BD0", "musica"),
material(family, "Ropa de gimnasia", "Roba d'EF", "👕", "#7FBF6B", "gimnasia"),
material(family, "Zapatillas", "Sabatilles", "👟", "#7FBF6B", "gimnasia"),
material(family, "Toalla", "Tovallola", "🧖", "#5BC0BE", "gimnasia"),
material(family, "Agua", "Aigua", "💧", "#5BC0BE", "gimnasia"),
material(family, "Lectura", "Lectura", "📖", "#F2A65A", "lengua"),
material(family, "Cuaderno", "Quadern", "📒", "#F2A65A", "lengua"),
material(family, "Almuerzo", "Esmorzar", "🍎", "#EC8FA4", "general"));
}
private MaterialItem material(Family family, String es, String ca, String icon, String color, String category) { private MaterialItem material(Family family, String es, String ca, String icon, String color, String category) {
MaterialItem m = new MaterialItem(es, ca, icon, color, category); MaterialItem m = new MaterialItem(es, ca, icon, color, category);
m.setFamily(family); m.setFamily(family);
return materialRepository.save(m); return materialRepository.save(m);
} }
private ActivitiesCatalog seedActivities(Family family, MaterialsCatalog m) { /**
* Actividades de la demo. El material sale del catálogo base ya provisionado
* (por su etiqueta ES) más tres extras que solo la demo necesita.
*/
private ActivitiesCatalog seedActivities(Family family) {
List<MaterialItem> base = materialRepository.findByFamilyId(family.getId());
MaterialItem flauta = material(family, "Flauta", "Flauta", "🎵", "#A78BD0", "musica");
MaterialItem regla = material(family, "Regla", "Regle", "📏", "#5B8DEF", "matematicas");
MaterialItem toalla = material(family, "Toalla", "Tovallola", "🧖", "#5BC0BE", "gimnasia");
Activity gimnasia = activity(family, "Gimnasia", "Gimnàstica", "🤸", "#7FBF6B", Activity gimnasia = activity(family, "Gimnasia", "Gimnàstica", "🤸", "#7FBF6B",
m.ropaGimnasia, m.zapatillas, m.toalla, m.agua); fromBase(base, "Ropa de gimnasia"), fromBase(base, "Zapatillas"), toalla,
Activity musica = activity(family, "Música", "Música", "🎵", "#A78BD0", m.flauta, m.libreta); fromBase(base, "Botella de agua"));
Activity musica = activity(family, "Música", "Música", "🎵", "#A78BD0",
flauta, fromBase(base, "Libreta"));
Activity mates = activity(family, "Matemáticas", "Matemàtiques", "📘", "#5B8DEF", Activity mates = activity(family, "Matemáticas", "Matemàtiques", "📘", "#5B8DEF",
m.libroMates, m.regla, m.estuche); fromBase(base, "Libro de texto"), regla, fromBase(base, "Estuche"));
Activity lengua = activity(family, "Lengua", "Llengua", "📖", "#F2A65A", m.lectura, m.cuaderno); Activity lengua = activity(family, "Lengua", "Llengua", "📖", "#F2A65A",
fromBase(base, "Lectura"), fromBase(base, "Libreta"));
return new ActivitiesCatalog(gimnasia, musica, mates, lengua); return new ActivitiesCatalog(gimnasia, musica, mates, lengua);
} }
/** Localiza un material del catálogo base por su etiqueta ES (clave estable del seed). */
private MaterialItem fromBase(List<MaterialItem> base, String labelEs) {
return base.stream().filter(m -> m.getLabelEs().equals(labelEs)).findFirst()
.orElseThrow(() -> new IllegalStateException("Falta el material base: " + labelEs));
}
private Activity activity(Family family, String es, String ca, String icon, String color, MaterialItem... mats) { private Activity activity(Family family, String es, String ca, String icon, String color, MaterialItem... mats) {
Activity activity = new Activity(es, ca, icon, color); Activity activity = new Activity(es, ca, icon, color);
activity.setFamily(family); activity.setFamily(family);
@@ -191,45 +190,7 @@ public class DataSeeder implements ApplicationRunner {
templateRepository.save(new WeeklyTemplateEntry(child, day, activity, 0)); templateRepository.save(new WeeklyTemplateEntry(child, day, activity, 0));
} }
/** Catálogo de rutinas reutilizables de la familia (se definen una sola vez). */
private RoutinesCatalog seedRoutineCatalog(Family family) {
return new RoutinesCatalog(
routineTask(family, "Deshacer la mochila", "Buidar la motxilla", "🎒", "#F2A65A"),
routineTask(family, "Merendar", "Berenar", "🥪", "#F4C95D"),
routineTask(family, "Hacer los deberes", "Fer els deures", "📝", "#5B8DEF"),
routineTask(family, "Practicar piano", "Practicar piano", "🎹", "#A78BD0"),
routineTask(family, "Recoger la mesa", "Parar taula", "🍽️", "#7FBF6B"));
}
private RoutineTask routineTask(Family family, String es, String ca, String icon, String color) {
RoutineTask rt = new RoutineTask(es, ca, icon, color);
rt.setFamily(family);
return routineTaskRepository.save(rt);
}
/** Asigna las rutinas del catálogo a cada día laborable, reutilizando las mismas. */
private void seedAfternoonRoutines(Child child, RoutinesCatalog r) {
for (DayOfWeek day : List.of(DayOfWeek.MONDAY, DayOfWeek.TUESDAY, DayOfWeek.WEDNESDAY,
DayOfWeek.THURSDAY, DayOfWeek.FRIDAY)) {
routineRepository.save(new AfternoonRoutine(child, day, r.mochila, 0));
routineRepository.save(new AfternoonRoutine(child, day, r.merienda, 1));
routineRepository.save(new AfternoonRoutine(child, day, r.deberes, 2));
routineRepository.save(new AfternoonRoutine(child, day, r.piano, 3));
routineRepository.save(new AfternoonRoutine(child, day, r.mesa, 4));
}
}
private record MaterialsCatalog(
MaterialItem estuche, MaterialItem libroMates, MaterialItem regla, MaterialItem flauta,
MaterialItem libreta, MaterialItem ropaGimnasia, MaterialItem zapatillas, MaterialItem toalla,
MaterialItem agua, MaterialItem lectura, MaterialItem cuaderno, MaterialItem almuerzo) {
}
private record ActivitiesCatalog(Activity gimnasia, Activity musica, Activity matematicas, private record ActivitiesCatalog(Activity gimnasia, Activity musica, Activity matematicas,
Activity lengua) { Activity lengua) {
} }
private record RoutinesCatalog(RoutineTask mochila, RoutineTask merienda, RoutineTask deberes,
RoutineTask piano, RoutineTask mesa) {
}
} }

View File

@@ -11,4 +11,7 @@ public interface AfternoonRoutineRepository extends JpaRepository<AfternoonRouti
List<AfternoonRoutine> findByChildIdAndDayOfWeekOrderByOrderIndexAsc(Long childId, DayOfWeek dayOfWeek); List<AfternoonRoutine> findByChildIdAndDayOfWeekOrderByOrderIndexAsc(Long childId, DayOfWeek dayOfWeek);
List<AfternoonRoutine> findByChildIdOrderByDayOfWeekAscOrderIndexAsc(Long childId); List<AfternoonRoutine> findByChildIdOrderByDayOfWeekAscOrderIndexAsc(Long childId);
/** Nº de asignaciones de un niño en un día; da el siguiente orderIndex al asignar. */
int countByChildIdAndDayOfWeek(Long childId, DayOfWeek dayOfWeek);
} }

View File

@@ -109,9 +109,12 @@ public class ParentScheduleController {
Child child = requireChild(req.childId()); Child child = requireChild(req.childId());
RoutineTask task = routineTaskRepository.findByIdAndFamilyId(req.routineTaskId(), fid()) RoutineTask task = routineTaskRepository.findByIdAndFamilyId(req.routineTaskId(), fid())
.orElseThrow(() -> new NotFoundException("No existe la rutina con id " + req.routineTaskId())); .orElseThrow(() -> new NotFoundException("No existe la rutina con id " + req.routineTaskId()));
int orderIndex = req.orderIndex() != null ? req.orderIndex() : 0;
List<RoutineView> created = req.daysOfWeek().stream().map(day -> { List<RoutineView> created = req.daysOfWeek().stream().map(day -> {
AfternoonRoutine routine = new AfternoonRoutine(child, DayOfWeek.valueOf(day), task, orderIndex); DayOfWeek dayOfWeek = DayOfWeek.valueOf(day);
// Sin orden explícito, la rutina se añade al final DE CADA día destino.
int orderIndex = req.orderIndex() != null ? req.orderIndex()
: routineRepository.countByChildIdAndDayOfWeek(child.getId(), dayOfWeek);
AfternoonRoutine routine = new AfternoonRoutine(child, dayOfWeek, task, orderIndex);
routine.setCoinsReward(req.coinsReward()); routine.setCoinsReward(req.coinsReward());
return toRoutineView(routineRepository.save(routine)); return toRoutineView(routineRepository.save(routine));
}).toList(); }).toList();

View File

@@ -1 +0,0 @@
# Los changesets de Liquibase de la Fase 2 (dominio) vivirán aquí.

View File

@@ -0,0 +1,30 @@
# Borrado en cascada del catálogo de rutinas.
# Al eliminar una rutina del catálogo (routine_task) deben desaparecer sus
# asignaciones a días (afternoon_routine): es el modelo mental del padre
# ("quito Merendar" = desaparece de todos los días). Sin esto, el DELETE
# fallaba con violación de FK y el panel devolvía un error 500.
databaseChangeLog:
- changeSet:
id: 400-cascade-routine-assignment
author: recordalexia
changes:
- dropForeignKeyConstraint:
baseTableName: afternoon_routine
constraintName: fk_routine_assignment_task
- addForeignKeyConstraint:
baseTableName: afternoon_routine
baseColumnNames: routine_task_id
referencedTableName: routine_task
referencedColumnNames: id
constraintName: fk_routine_assignment_task
onDelete: CASCADE
rollback:
- dropForeignKeyConstraint:
baseTableName: afternoon_routine
constraintName: fk_routine_assignment_task
- addForeignKeyConstraint:
baseTableName: afternoon_routine
baseColumnNames: routine_task_id
referencedTableName: routine_task
referencedColumnNames: id
constraintName: fk_routine_assignment_task

View File

@@ -0,0 +1,45 @@
package es.asepeyo.recordalexia;
import static org.assertj.core.api.Assertions.assertThat;
import es.asepeyo.recordalexia.repository.AfternoonRoutineRepository;
import es.asepeyo.recordalexia.repository.FamilyRepository;
import es.asepeyo.recordalexia.repository.RoutineTaskRepository;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.springframework.test.context.TestPropertySource;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
/**
* Verifica el changelog completo de Liquibase contra un Postgres REAL (misma
* imagen que producción). Cubre lo que H2 no puede: tipado estricto de Postgres
* en la migración SQL, y que el esquema resultante casa con el mapeo JPA
* (ddl-auto=validate). Con el seeder activado comprueba además el estado final.
*
* Se desactiva sola si Docker no está disponible en la máquina.
*/
@SpringBootTest
@Testcontainers(disabledWithoutDocker = true)
@TestPropertySource(properties = "recordalexia.seed.enabled=true")
class MigrationIT {
@Container
@ServiceConnection
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine");
@Autowired private FamilyRepository familyRepository;
@Autowired private RoutineTaskRepository routineTaskRepository;
@Autowired private AfternoonRoutineRepository routineRepository;
@Test
void lasMigracionesAplicanYelSeederPueblaEnPostgresReal() {
// Si llegamos aquí, Liquibase aplicó 001-004 y Hibernate validó el esquema.
assertThat(familyRepository.findByEmailIgnoreCase("demo@recordalexia.local")).isPresent();
assertThat(routineTaskRepository.count()).isEqualTo(14);
assertThat(routineRepository.count()).isEqualTo(75); // 3 niños x 5 rutinas x 5 días
}
}

View File

@@ -3,9 +3,12 @@ package es.asepeyo.recordalexia.bootstrap;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import es.asepeyo.recordalexia.repository.ActivityRepository; import es.asepeyo.recordalexia.repository.ActivityRepository;
import es.asepeyo.recordalexia.repository.AfternoonRoutineRepository;
import es.asepeyo.recordalexia.repository.ChildRepository; import es.asepeyo.recordalexia.repository.ChildRepository;
import es.asepeyo.recordalexia.repository.FamilyRepository; import es.asepeyo.recordalexia.repository.FamilyRepository;
import es.asepeyo.recordalexia.repository.MaterialItemRepository;
import es.asepeyo.recordalexia.repository.RewardRepository; import es.asepeyo.recordalexia.repository.RewardRepository;
import es.asepeyo.recordalexia.repository.RoutineTaskRepository;
import es.asepeyo.recordalexia.repository.WeeklyTemplateEntryRepository; import es.asepeyo.recordalexia.repository.WeeklyTemplateEntryRepository;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
@@ -14,7 +17,8 @@ import org.springframework.test.context.TestPropertySource;
/** /**
* Verifica que el sembrado del prototipo (familia demo + sus datos) se ejecuta * Verifica que el sembrado del prototipo (familia demo + sus datos) se ejecuta
* correctamente. Usa una BD H2 propia y el seeder activado. * correctamente. La demo se apoya en la MISMA provisión inicial que una familia
* real (InitialDatasetService) y añade encima sus extras. Usa una BD H2 propia.
*/ */
@SpringBootTest @SpringBootTest
@TestPropertySource(properties = { @TestPropertySource(properties = {
@@ -28,6 +32,9 @@ class DataSeederIT {
@Autowired private RewardRepository rewardRepository; @Autowired private RewardRepository rewardRepository;
@Autowired private ActivityRepository activityRepository; @Autowired private ActivityRepository activityRepository;
@Autowired private WeeklyTemplateEntryRepository templateRepository; @Autowired private WeeklyTemplateEntryRepository templateRepository;
@Autowired private MaterialItemRepository materialRepository;
@Autowired private RoutineTaskRepository routineTaskRepository;
@Autowired private AfternoonRoutineRepository routineRepository;
@Test @Test
void siembraLaFamiliaDemoConSusDatos() { void siembraLaFamiliaDemoConSusDatos() {
@@ -37,4 +44,12 @@ class DataSeederIT {
assertThat(activityRepository.count()).isEqualTo(4); // 4 actividades assertThat(activityRepository.count()).isEqualTo(4); // 4 actividades
assertThat(templateRepository.count()).isEqualTo(15); // 3 niños x 5 días assertThat(templateRepository.count()).isEqualTo(15); // 3 niños x 5 días
} }
@Test
void laDemoUsaLaProvisionInicialComun() {
assertThat(materialRepository.count()).isEqualTo(12); // 9 base + 3 extras demo
assertThat(routineTaskRepository.count()).isEqualTo(14); // catálogo común completo
// 3 niños (6, 7 y 9 años) -> tramo base: 5 rutinas x 5 días laborables cada uno.
assertThat(routineRepository.count()).isEqualTo(75);
}
} }

View File

@@ -0,0 +1,61 @@
package es.asepeyo.recordalexia.repository;
import static org.assertj.core.api.Assertions.assertThat;
import es.asepeyo.recordalexia.domain.AfternoonRoutine;
import es.asepeyo.recordalexia.domain.Child;
import es.asepeyo.recordalexia.domain.Family;
import es.asepeyo.recordalexia.domain.RoutineTask;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import java.time.DayOfWeek;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.transaction.annotation.Transactional;
/**
* Borrar una rutina del catálogo arrastra sus asignaciones a días (ON DELETE
* CASCADE del changeset 004). Es el modelo mental del padre: "quito Merendar"
* significa que desaparece de todos los días, no un error 500.
*/
@SpringBootTest
@Transactional
class RoutineTaskCascadeTest {
@Autowired private FamilyRepository familyRepository;
@Autowired private ChildRepository childRepository;
@Autowired private RoutineTaskRepository routineTaskRepository;
@Autowired private AfternoonRoutineRepository routineRepository;
@PersistenceContext private EntityManager entityManager;
@Test
void borrarRutinaDelCatalogoArrastraSusAsignaciones() {
Family family = familyRepository.save(new Family("cascade@x.com", "h", "C", "p"));
Child child = new Child();
child.setFamily(family);
child.setName("Test");
child.setMascot("🦊");
child.setAccentColor("#F2A65A");
child.setAge(8);
child = childRepository.save(child);
RoutineTask merendar = new RoutineTask("Merendar", "Berenar", "🥪", "#F4C95D");
merendar.setFamily(family);
merendar = routineTaskRepository.save(merendar);
routineRepository.save(new AfternoonRoutine(child, DayOfWeek.MONDAY, merendar, 0));
routineRepository.save(new AfternoonRoutine(child, DayOfWeek.TUESDAY, merendar, 0));
routineRepository.flush();
// Contexto limpio, como en una petición real: el cascade lo resuelve la BD,
// no la sesión de Hibernate (que si tuviera las asignaciones cargadas se
// quejaría de referencias a una entidad borrada).
entityManager.clear();
routineTaskRepository.deleteById(merendar.getId());
routineTaskRepository.flush();
assertThat(routineRepository.findByChildIdOrderByDayOfWeekAscOrderIndexAsc(child.getId()))
.isEmpty();
assertThat(routineTaskRepository.findById(merendar.getId())).isEmpty();
}
}

View File

@@ -210,13 +210,12 @@ export class RoutinesTabComponent {
if (this.selectedTaskId === null || this.selectedDays().length === 0) { if (this.selectedTaskId === null || this.selectedDays().length === 0) {
return; return;
} }
const order = this.routinesForDay().length; // El backend calcula el orden al final de CADA día destino.
this.api this.api
.createRoutine({ .createRoutine({
childId: this._childId, childId: this._childId,
routineTaskId: this.selectedTaskId, routineTaskId: this.selectedTaskId,
daysOfWeek: this.selectedDays(), daysOfWeek: this.selectedDays(),
orderIndex: order,
}) })
.subscribe(() => { .subscribe(() => {
this.selectedTaskId = null; this.selectedTaskId = null;