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:
@@ -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'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
# Los changesets de Liquibase de la Fase 2 (dominio) vivirán aquí.
|
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user