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

@@ -7,18 +7,16 @@ import es.asepeyo.recordalexia.domain.EventType;
import es.asepeyo.recordalexia.domain.Family;
import es.asepeyo.recordalexia.domain.MaterialItem;
import es.asepeyo.recordalexia.domain.Reward;
import es.asepeyo.recordalexia.domain.RoutineTask;
import es.asepeyo.recordalexia.domain.SpecialEvent;
import es.asepeyo.recordalexia.domain.WeeklyTemplateEntry;
import es.asepeyo.recordalexia.repository.ActivityRepository;
import es.asepeyo.recordalexia.repository.AfternoonRoutineRepository;
import es.asepeyo.recordalexia.repository.ChildRepository;
import es.asepeyo.recordalexia.repository.FamilyRepository;
import es.asepeyo.recordalexia.repository.MaterialItemRepository;
import es.asepeyo.recordalexia.repository.RewardRepository;
import es.asepeyo.recordalexia.repository.RoutineTaskRepository;
import es.asepeyo.recordalexia.repository.SpecialEventRepository;
import es.asepeyo.recordalexia.repository.WeeklyTemplateEntryRepository;
import es.asepeyo.recordalexia.service.InitialDatasetService;
import java.time.Clock;
import java.time.DayOfWeek;
import java.time.LocalDate;
@@ -52,29 +50,26 @@ public class DataSeeder implements ApplicationRunner {
private final MaterialItemRepository materialRepository;
private final ActivityRepository activityRepository;
private final WeeklyTemplateEntryRepository templateRepository;
private final AfternoonRoutineRepository routineRepository;
private final RoutineTaskRepository routineTaskRepository;
private final SpecialEventRepository eventRepository;
private final RewardRepository rewardRepository;
private final InitialDatasetService initialDataset;
private final PasswordEncoder passwordEncoder;
private final Clock clock;
public DataSeeder(FamilyRepository familyRepository, ChildRepository childRepository,
MaterialItemRepository materialRepository, ActivityRepository activityRepository,
WeeklyTemplateEntryRepository templateRepository,
AfternoonRoutineRepository routineRepository,
RoutineTaskRepository routineTaskRepository,
SpecialEventRepository eventRepository, RewardRepository rewardRepository,
InitialDatasetService initialDataset,
PasswordEncoder passwordEncoder, Clock clock) {
this.familyRepository = familyRepository;
this.childRepository = childRepository;
this.materialRepository = materialRepository;
this.activityRepository = activityRepository;
this.templateRepository = templateRepository;
this.routineRepository = routineRepository;
this.routineTaskRepository = routineTaskRepository;
this.eventRepository = eventRepository;
this.rewardRepository = rewardRepository;
this.initialDataset = initialDataset;
this.passwordEncoder = passwordEncoder;
this.clock = clock;
}
@@ -89,9 +84,10 @@ public class DataSeeder implements ApplicationRunner {
Family family = familyRepository.save(new Family(DEMO_EMAIL,
passwordEncoder.encode(DEMO_PASSWORD), "Familia demo", passwordEncoder.encode(DEMO_PIN)));
var materials = seedMaterials(family);
var activities = seedActivities(family, materials);
var routines = seedRoutineCatalog(family);
// Catálogo base (materiales + rutinas): la MISMA provisión que recibe cualquier
// familia real al registrarse. La demo solo añade sus extras encima.
initialDataset.provisionFamily(family);
var activities = seedActivities(family);
seedRewards(family);
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)) {
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);
@@ -110,38 +107,40 @@ public class DataSeeder implements ApplicationRunner {
"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) {
MaterialItem m = new MaterialItem(es, ca, icon, color, category);
m.setFamily(family);
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",
m.ropaGimnasia, m.zapatillas, m.toalla, m.agua);
Activity musica = activity(family, "Música", "Música", "🎵", "#A78BD0", m.flauta, m.libreta);
fromBase(base, "Ropa de gimnasia"), fromBase(base, "Zapatillas"), toalla,
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",
m.libroMates, m.regla, m.estuche);
Activity lengua = activity(family, "Lengua", "Llengua", "📖", "#F2A65A", m.lectura, m.cuaderno);
fromBase(base, "Libro de texto"), regla, fromBase(base, "Estuche"));
Activity lengua = activity(family, "Lengua", "Llengua", "📖", "#F2A65A",
fromBase(base, "Lectura"), fromBase(base, "Libreta"));
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) {
Activity activity = new Activity(es, ca, icon, color);
activity.setFamily(family);
@@ -191,45 +190,7 @@ public class DataSeeder implements ApplicationRunner {
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,
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> 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());
RoutineTask task = routineTaskRepository.findByIdAndFamilyId(req.routineTaskId(), fid())
.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 -> {
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());
return toRoutineView(routineRepository.save(routine));
}).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 es.asepeyo.recordalexia.repository.ActivityRepository;
import es.asepeyo.recordalexia.repository.AfternoonRoutineRepository;
import es.asepeyo.recordalexia.repository.ChildRepository;
import es.asepeyo.recordalexia.repository.FamilyRepository;
import es.asepeyo.recordalexia.repository.MaterialItemRepository;
import es.asepeyo.recordalexia.repository.RewardRepository;
import es.asepeyo.recordalexia.repository.RoutineTaskRepository;
import es.asepeyo.recordalexia.repository.WeeklyTemplateEntryRepository;
import org.junit.jupiter.api.Test;
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
* 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
@TestPropertySource(properties = {
@@ -28,6 +32,9 @@ class DataSeederIT {
@Autowired private RewardRepository rewardRepository;
@Autowired private ActivityRepository activityRepository;
@Autowired private WeeklyTemplateEntryRepository templateRepository;
@Autowired private MaterialItemRepository materialRepository;
@Autowired private RoutineTaskRepository routineTaskRepository;
@Autowired private AfternoonRoutineRepository routineRepository;
@Test
void siembraLaFamiliaDemoConSusDatos() {
@@ -37,4 +44,12 @@ class DataSeederIT {
assertThat(activityRepository.count()).isEqualTo(4); // 4 actividades
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();
}
}