feat: Flyway V3 pet_health 结构基线 + V4 字典种子 + pet 域错误码(T2-01)

- V3:pet_health schema 8 张表(breeds/pets/pet_owners/pet_weight_records/
  vaccine_catalog/pet_vaccinations/health_events/care_reminders),列、CHECK、
  部分唯一索引、updated_at 触发器与目标模型一致
- 强制裁剪:剥离 bootstrap SQL 1156~1166 行 4 条指向 marketplace 的跨 schema
  外键(provider_id/booking_id 保留为裸可空 uuid,M5 迁移 marketplace 时补回)
- ADR-010:health_event_media 不建(media 剪出 M2,纯增量表后续补零成本)
- V4:breeds(28 条)与 vaccine_catalog(10 条)字典种子,生产参考数据走正式
  迁移链不放 db/dev;正典目录内容由产品侧供稿(D2-6)
- ErrorCode 预置 pets 域四码:40300 PET_ACCESS_DENIED、40401 PET_NOT_FOUND、
  40402 RECORD_NOT_FOUND、40902 VERSION_CONFLICT(第二波接口直接消费)
- 新增 PetHealthMigrationIntegrationTest:干净 postgres:18 上全量迁移验证
  表数、结构抽查、4 条 FK 确不存在、触发器与种子数据

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-07 14:34:19 +08:00
parent 0d81c38fc6
commit 49299fb4eb
4 changed files with 471 additions and 0 deletions
@@ -0,0 +1,155 @@
package com.patbond.patbond.user.persistence;
import com.patbond.patbond.user.TestcontainersConfiguration;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;
import org.springframework.jdbc.core.simple.JdbcClient;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Flyway V3/V4 (pet_health schema baseline + dictionary seed) apply cleanly
* on a postgres:18 container and produce the expected structure. Validates
* the mandatory cross-schema FK cuts (ADR-013, T2-01): provider_id/booking_id
* exist as bare nullable uuid columns without FKs to marketplace.
*/
@SpringBootTest
@Import(TestcontainersConfiguration.class)
class PetHealthMigrationIntegrationTest {
@Autowired
private JdbcClient jdbcClient;
@Test
void v3CreatesPetHealthSchema() {
String exists = jdbcClient.sql(
"SELECT schema_name FROM information_schema.schemata WHERE schema_name = 'pet_health'")
.query(String.class)
.optional()
.orElse(null);
assertThat(exists).isEqualTo("pet_health");
}
@Test
void v3CreatesAllPetHealthTables() {
int count = jdbcClient.sql(
"SELECT COUNT(*) FROM information_schema.tables " +
"WHERE table_schema = 'pet_health' AND table_type = 'BASE TABLE'")
.query(Integer.class)
.single();
// 8 tables: breeds, pets, pet_owners, pet_weight_records, vaccine_catalog,
// pet_vaccinations, health_events, care_reminders.
// health_event_media is NOT created (ADR-010: media cut from M2).
assertThat(count).isEqualTo(8);
}
@Test
void v3PetsTableHasExpectedStructure() {
// Sample columns and constraints spot-check
String microchipType = jdbcClient.sql(
"SELECT udt_name FROM information_schema.columns " +
"WHERE table_schema = 'pet_health' AND table_name = 'pets' " +
"AND column_name = 'microchip_no'")
.query(String.class)
.single();
assertThat(microchipType).isEqualTo("citext");
String versionDefault = jdbcClient.sql(
"SELECT column_default FROM information_schema.columns " +
"WHERE table_schema = 'pet_health' AND table_name = 'pets' " +
"AND column_name = 'version'")
.query(String.class)
.single();
assertThat(versionDefault).isEqualTo("0");
}
@Test
void v3VaccinationsColumnsExistWithoutMarketplaceFKs() {
// provider_id and booking_id columns exist (bare nullable uuid)
int colCount = jdbcClient.sql(
"SELECT COUNT(*) FROM information_schema.columns " +
"WHERE table_schema = 'pet_health' AND table_name = 'pet_vaccinations' " +
"AND column_name IN ('provider_id', 'booking_id')")
.query(Integer.class)
.single();
assertThat(colCount).isEqualTo(2);
// No FK constraints targeting marketplace (marketplace schema does not exist in M2)
int fkCount = jdbcClient.sql(
"SELECT COUNT(*) FROM information_schema.table_constraints " +
"WHERE table_schema = 'pet_health' AND table_name = 'pet_vaccinations' " +
"AND constraint_type = 'FOREIGN KEY' " +
"AND (constraint_name LIKE '%provider%' OR constraint_name LIKE '%booking%')")
.query(Integer.class)
.single();
assertThat(fkCount).isEqualTo(0);
}
@Test
void v3HealthEventsColumnsExistWithoutMarketplaceFKs() {
// provider_id and booking_id columns exist (bare nullable uuid)
int colCount = jdbcClient.sql(
"SELECT COUNT(*) FROM information_schema.columns " +
"WHERE table_schema = 'pet_health' AND table_name = 'health_events' " +
"AND column_name IN ('provider_id', 'booking_id')")
.query(Integer.class)
.single();
assertThat(colCount).isEqualTo(2);
// No FK constraints targeting marketplace
int fkCount = jdbcClient.sql(
"SELECT COUNT(*) FROM information_schema.table_constraints " +
"WHERE table_schema = 'pet_health' AND table_name = 'health_events' " +
"AND constraint_type = 'FOREIGN KEY' " +
"AND (constraint_name LIKE '%provider%' OR constraint_name LIKE '%booking%')")
.query(Integer.class)
.single();
assertThat(fkCount).isEqualTo(0);
}
@Test
void v3TriggersAreCreated() {
// The four updated_at triggers on pets, pet_vaccinations, health_events, care_reminders
int triggerCount = jdbcClient.sql(
"SELECT COUNT(*) FROM information_schema.triggers " +
"WHERE trigger_schema = 'pet_health' " +
"AND trigger_name IN ('trg_pets_updated_at', 'trg_vaccinations_updated_at', " +
"'trg_health_events_updated_at', 'trg_reminders_updated_at')")
.query(Integer.class)
.single();
assertThat(triggerCount).isEqualTo(4);
}
@Test
void v4SeedsBreedsAndVaccineCatalog() {
int breedCount = jdbcClient.sql("SELECT COUNT(*) FROM pet_health.breeds")
.query(Integer.class)
.single();
assertThat(breedCount).isGreaterThan(0).describedAs("V4 应插入 breeds 种子数据");
int vaccineCount = jdbcClient.sql("SELECT COUNT(*) FROM pet_health.vaccine_catalog")
.query(Integer.class)
.single();
assertThat(vaccineCount).isGreaterThan(0).describedAs("V4 应插入 vaccine_catalog 种子数据");
}
@Test
void v4SeedsIncludeMixedDogAndCat() {
// Spot-check a couple of known rows from V4
String mixedDog = jdbcClient.sql(
"SELECT display_name FROM pet_health.breeds WHERE code = 'mixed_dog'")
.query(String.class)
.optional()
.orElse(null);
assertThat(mixedDog).isEqualTo("中华田园犬");
String rabiesDog = jdbcClient.sql(
"SELECT name FROM pet_health.vaccine_catalog WHERE code = 'rabies_dog'")
.query(String.class)
.optional()
.orElse(null);
assertThat(rabiesDog).isEqualTo("狂犬疫苗(犬)");
}
}