diff --git a/patbond-common/src/main/java/com/patbond/patbond/common/error/ErrorCode.java b/patbond-common/src/main/java/com/patbond/patbond/common/error/ErrorCode.java index 7a9a059..81830bf 100644 --- a/patbond-common/src/main/java/com/patbond/patbond/common/error/ErrorCode.java +++ b/patbond-common/src/main/java/com/patbond/patbond/common/error/ErrorCode.java @@ -13,9 +13,13 @@ public enum ErrorCode { INVALID_CREDENTIALS(40100, 401, "用户名或密码错误"), TOKEN_INVALID(40101, 401, "token 无效或过期"), REFRESH_TOKEN_INVALID(40102, 401, "refresh token 已失效或被重用"), + PET_ACCESS_DENIED(40300, 403, "无权操作该宠物"), USER_NOT_FOUND(40400, 404, "用户不存在"), + PET_NOT_FOUND(40401, 404, "宠物不存在"), + RECORD_NOT_FOUND(40402, 404, "记录不存在"), USERNAME_EXISTS(40900, 409, "用户名已存在"), PHONE_EXISTS(40901, 409, "手机号已被使用"), + VERSION_CONFLICT(40902, 409, "数据已被修改,请刷新后重试"), LOGIN_LOCKED(42300, 423, "登录失败次数过多,账号已临时锁定"), INTERNAL_ERROR(50000, 500, "服务器内部错误"), DOWNSTREAM_UNAVAILABLE(50300, 503, "依赖服务暂不可用"); diff --git a/patbond-user/src/main/resources/db/migration/V3__pet_health_baseline.sql b/patbond-user/src/main/resources/db/migration/V3__pet_health_baseline.sql new file mode 100644 index 0000000..f4e72c6 --- /dev/null +++ b/patbond-user/src/main/resources/db/migration/V3__pet_health_baseline.sql @@ -0,0 +1,257 @@ +/* + V3 baseline: pet_health schema (M2 宠物健康档案), extracted from + patbond-doc/docs/database/patbond_postgresql.sql (reviewed target model, + lines 333-554) per iteration-2 T2-01. + + Scope notes: + - 8 of the 9 pet_health objects are created here: breeds, pets, pet_owners, + pet_weight_records, vaccine_catalog, pet_vaccinations, health_events, + care_reminders. + - pet_health.health_event_media is NOT created (ADR-010: media/attachments + are cut from M2; the table's asset_id is a NOT NULL FK to media.assets and + the media upload flow does not exist yet). It is a pure additive table and + will be introduced by a later migration together with the media work. + - Cross-schema FKs into marketplace are STRIPPED (mandatory cut, T2-01): + the target model adds these four constraints at lines 1156-1166 — + * fk_vaccinations_provider (pet_vaccinations.provider_id -> marketplace.providers) + * fk_vaccinations_booking (pet_vaccinations.booking_id -> marketplace.bookings) + * fk_health_events_provider (health_events.provider_id -> marketplace.providers) + * fk_health_events_booking (health_events.booking_id -> marketplace.bookings) + The marketplace schema belongs to M5 and is not migrated in M2. The four + columns are kept as bare nullable uuid; the constraints will be re-added + by the M5 migration that creates the marketplace schema (M5 补回). + - FKs to identity.users and media.assets are kept as-is: both schemas exist + since V1 (same precedent as media.assets.owner_user_id in V1). + - updated_at triggers reuse platform.set_updated_at() created in V1. + - Structure only; the breeds / vaccine_catalog dictionary seed rows are + production reference data and live in V4 (not in db/dev). + - Never edit this file after release; subsequent changes go into V4+. +*/ + +CREATE SCHEMA pet_health; + +COMMENT ON SCHEMA pet_health IS 'Pet profile, health facts, vaccines and reminders'; + +CREATE TABLE pet_health.breeds ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + species varchar(16) NOT NULL, + code varchar(64) NOT NULL UNIQUE, + display_name varchar(64) NOT NULL, + enabled boolean NOT NULL DEFAULT true, + sort_order integer NOT NULL DEFAULT 0, + created_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (id, species), + CONSTRAINT ck_breeds_species CHECK (species IN ('dog', 'cat', 'other')), + CONSTRAINT ck_breeds_names CHECK ( + code = btrim(code) AND display_name = btrim(display_name) + AND char_length(code) BETWEEN 2 AND 64 + AND char_length(display_name) BETWEEN 1 AND 64 + ) +); + +CREATE INDEX ix_breeds_species_order ON pet_health.breeds (species, sort_order, id) WHERE enabled; + +CREATE TABLE pet_health.pets ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + name varchar(64) NOT NULL, + species varchar(16) NOT NULL, + breed_id uuid, + custom_breed_name varchar(64), + sex varchar(8) NOT NULL DEFAULT 'unknown', + birth_date date, + birth_date_estimated boolean NOT NULL DEFAULT false, + personality varchar(64), + avatar_asset_id uuid REFERENCES media.assets(id) ON DELETE SET NULL, + microchip_no citext, + sterilized_on date, + status varchar(16) NOT NULL DEFAULT 'active', + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + version integer NOT NULL DEFAULT 0, + CONSTRAINT ck_pets_name CHECK (name = btrim(name) AND char_length(name) BETWEEN 1 AND 64), + CONSTRAINT ck_pets_species CHECK (species IN ('dog', 'cat', 'other')), + CONSTRAINT ck_pets_sex CHECK (sex IN ('male', 'female', 'unknown')), + FOREIGN KEY (breed_id, species) + REFERENCES pet_health.breeds(id, species) ON DELETE RESTRICT, + CONSTRAINT ck_pets_breed CHECK ( + (breed_id IS NOT NULL AND custom_breed_name IS NULL) + OR ( + breed_id IS NULL AND custom_breed_name IS NOT NULL + AND char_length(btrim(custom_breed_name)) BETWEEN 1 AND 64 + ) + ), + CONSTRAINT ck_pets_birth_date CHECK (birth_date IS NULL OR birth_date >= DATE '1990-01-01'), + CONSTRAINT ck_pets_status CHECK (status IN ('active', 'lost', 'deceased', 'archived', 'deleted')), + CONSTRAINT ck_pets_version CHECK (version >= 0), + CONSTRAINT ck_pets_deleted CHECK ((status = 'deleted') = (deleted_at IS NOT NULL)) +); + +CREATE UNIQUE INDEX uq_pets_microchip + ON pet_health.pets (microchip_no) + WHERE microchip_no IS NOT NULL; +CREATE INDEX ix_pets_breed_species ON pet_health.pets (breed_id, species); +CREATE INDEX ix_pets_avatar ON pet_health.pets (avatar_asset_id); +CREATE INDEX ix_pets_active_updated ON pet_health.pets (updated_at DESC, id) WHERE status = 'active'; + +CREATE TABLE pet_health.pet_owners ( + pet_id uuid NOT NULL REFERENCES pet_health.pets(id) ON DELETE CASCADE, + user_id uuid NOT NULL REFERENCES identity.users(id) ON DELETE RESTRICT, + role varchar(16) NOT NULL DEFAULT 'owner', + is_primary boolean NOT NULL DEFAULT false, + created_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (pet_id, user_id), + CONSTRAINT ck_pet_owners_role CHECK (role IN ('owner', 'caregiver', 'viewer')) +); + +CREATE INDEX ix_pet_owners_user ON pet_health.pet_owners (user_id, pet_id); +CREATE UNIQUE INDEX uq_pet_primary_owner + ON pet_health.pet_owners (pet_id) + WHERE is_primary; + +CREATE TABLE pet_health.pet_weight_records ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + pet_id uuid NOT NULL REFERENCES pet_health.pets(id) ON DELETE CASCADE, + weight_kg numeric(6,2) NOT NULL, + measured_at timestamptz NOT NULL, + source varchar(16) NOT NULL DEFAULT 'manual', + note varchar(500), + created_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT ck_pet_weight CHECK (weight_kg > 0 AND weight_kg <= 500), + CONSTRAINT ck_pet_weight_source CHECK (source IN ('manual', 'clinic', 'device')) +); + +CREATE INDEX ix_pet_weight_pet_measured + ON pet_health.pet_weight_records (pet_id, measured_at DESC, id DESC); + +CREATE TABLE pet_health.vaccine_catalog ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + code varchar(64) NOT NULL UNIQUE, + name varchar(128) NOT NULL, + species varchar(16) NOT NULL, + description varchar(500), + enabled boolean NOT NULL DEFAULT true, + created_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT ck_vaccine_species CHECK (species IN ('dog', 'cat', 'other')), + CONSTRAINT ck_vaccine_names CHECK ( + code = btrim(code) AND name = btrim(name) + AND char_length(code) BETWEEN 2 AND 64 + AND char_length(name) BETWEEN 1 AND 128 + ) +); + +CREATE INDEX ix_vaccine_catalog_species ON pet_health.vaccine_catalog (species, name) WHERE enabled; + +CREATE TABLE pet_health.pet_vaccinations ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + pet_id uuid NOT NULL REFERENCES pet_health.pets(id) ON DELETE CASCADE, + vaccine_id uuid NOT NULL REFERENCES pet_health.vaccine_catalog(id) ON DELETE RESTRICT, + series_key varchar(64) NOT NULL, + dose_no smallint NOT NULL, + dose_label varchar(64), + status varchar(16) NOT NULL DEFAULT 'scheduled', + planned_on date, + administered_on date, + next_due_on date, + -- provider_id / booking_id: bare nullable uuid, FKs to marketplace stripped (M5 补回) + provider_id uuid, + provider_name_snapshot varchar(128), + manufacturer varchar(128), + batch_no varchar(64), + certificate_asset_id uuid REFERENCES media.assets(id) ON DELETE SET NULL, + booking_id uuid, + notes varchar(1000), + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + version integer NOT NULL DEFAULT 0, + CONSTRAINT ck_vaccination_series CHECK (series_key = btrim(series_key) AND char_length(series_key) BETWEEN 1 AND 64), + CONSTRAINT ck_vaccination_dose CHECK (dose_no > 0), + CONSTRAINT ck_vaccination_status CHECK (status IN ('scheduled', 'completed', 'cancelled')), + CONSTRAINT ck_vaccination_dates CHECK ( + (status = 'completed' AND administered_on IS NOT NULL) + OR (status = 'scheduled' AND administered_on IS NULL AND planned_on IS NOT NULL) + OR (status = 'cancelled' AND administered_on IS NULL) + ), + CONSTRAINT ck_vaccination_next_due CHECK ( + next_due_on IS NULL OR administered_on IS NULL OR next_due_on >= administered_on + ), + CONSTRAINT ck_vaccination_version CHECK (version >= 0) +); + +CREATE UNIQUE INDEX uq_pet_vaccination_dose + ON pet_health.pet_vaccinations (pet_id, vaccine_id, series_key, dose_no) + WHERE status <> 'cancelled'; +CREATE INDEX ix_vaccinations_pet ON pet_health.pet_vaccinations (pet_id); +CREATE INDEX ix_vaccinations_vaccine ON pet_health.pet_vaccinations (vaccine_id); +CREATE INDEX ix_vaccinations_certificate ON pet_health.pet_vaccinations (certificate_asset_id); +CREATE INDEX ix_vaccinations_provider ON pet_health.pet_vaccinations (provider_id); +CREATE INDEX ix_vaccinations_booking ON pet_health.pet_vaccinations (booking_id); +CREATE INDEX ix_vaccinations_pet_completed + ON pet_health.pet_vaccinations (pet_id, administered_on DESC, id DESC) + WHERE status = 'completed'; +CREATE INDEX ix_vaccinations_due + ON pet_health.pet_vaccinations (planned_on, pet_id) + WHERE status = 'scheduled'; + +CREATE TABLE pet_health.health_events ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + pet_id uuid NOT NULL REFERENCES pet_health.pets(id) ON DELETE CASCADE, + event_type varchar(24) NOT NULL, + occurred_at timestamptz NOT NULL, + title varchar(160) NOT NULL, + notes text, + amount_cents bigint, + -- provider_id / booking_id: bare nullable uuid, FKs to marketplace stripped (M5 补回) + provider_id uuid, + provider_name_snapshot varchar(128), + booking_id uuid, + created_by_user_id uuid NOT NULL REFERENCES identity.users(id) ON DELETE RESTRICT, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + version integer NOT NULL DEFAULT 0, + CONSTRAINT ck_health_event_type CHECK ( + event_type IN ('medical', 'feeding', 'deworming', 'grooming', 'measurement', 'note') + ), + CONSTRAINT ck_health_event_title CHECK (title = btrim(title) AND char_length(title) BETWEEN 1 AND 160), + CONSTRAINT ck_health_event_amount CHECK (amount_cents IS NULL OR amount_cents >= 0), + CONSTRAINT ck_health_event_version CHECK (version >= 0) +); + +CREATE INDEX ix_health_events_pet_time ON pet_health.health_events (pet_id, occurred_at DESC, id DESC); +CREATE INDEX ix_health_events_creator ON pet_health.health_events (created_by_user_id, created_at DESC); +CREATE INDEX ix_health_events_provider ON pet_health.health_events (provider_id); +CREATE INDEX ix_health_events_booking ON pet_health.health_events (booking_id); + +CREATE TABLE pet_health.care_reminders ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + pet_id uuid NOT NULL REFERENCES pet_health.pets(id) ON DELETE CASCADE, + reminder_type varchar(24) NOT NULL, + title varchar(160) NOT NULL, + due_at timestamptz NOT NULL, + status varchar(16) NOT NULL DEFAULT 'pending', + completed_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT ck_care_reminder_type CHECK ( + reminder_type IN ('deworming', 'checkup', 'medication', 'other') + ), + CONSTRAINT ck_care_reminder_status CHECK (status IN ('pending', 'completed', 'dismissed')), + CONSTRAINT ck_care_reminder_completed CHECK ((status = 'completed') = (completed_at IS NOT NULL)) +); + +CREATE INDEX ix_care_reminders_pet ON pet_health.care_reminders (pet_id); +CREATE INDEX ix_care_reminders_due + ON pet_health.care_reminders (due_at, pet_id) + WHERE status = 'pending'; + +-- Automatic updated_at maintenance (function created in V1). Business version +-- increments remain explicit so optimistic locking stays visible in +-- repository update statements. +CREATE TRIGGER trg_pets_updated_at BEFORE UPDATE ON pet_health.pets + FOR EACH ROW EXECUTE FUNCTION platform.set_updated_at(); +CREATE TRIGGER trg_vaccinations_updated_at BEFORE UPDATE ON pet_health.pet_vaccinations + FOR EACH ROW EXECUTE FUNCTION platform.set_updated_at(); +CREATE TRIGGER trg_health_events_updated_at BEFORE UPDATE ON pet_health.health_events + FOR EACH ROW EXECUTE FUNCTION platform.set_updated_at(); +CREATE TRIGGER trg_reminders_updated_at BEFORE UPDATE ON pet_health.care_reminders + FOR EACH ROW EXECUTE FUNCTION platform.set_updated_at(); diff --git a/patbond-user/src/main/resources/db/migration/V4__pet_health_dictionary_seed.sql b/patbond-user/src/main/resources/db/migration/V4__pet_health_dictionary_seed.sql new file mode 100644 index 0000000..b450d13 --- /dev/null +++ b/patbond-user/src/main/resources/db/migration/V4__pet_health_dictionary_seed.sql @@ -0,0 +1,55 @@ +/* + V4: breeds / vaccine_catalog dictionary seed (iteration-2 T2-01, D2-6). + + These are production reference data (business rows the app FKs against), + not development fixtures, so they live in the versioned chain rather than + db/dev. The set is a curated development-grade starter list (10~20 common + entries per species); the authoritative catalog is a separate content task + owned by the product side (D2-6) and will land as later migrations. + + Rows use the column DEFAULT gen_random_uuid() for ids: dictionary rows are + server-born reference data, the application-side UUIDv7 rule applies to + rows created through the API. +*/ + +INSERT INTO pet_health.breeds (species, code, display_name, sort_order) VALUES + ('dog', 'mixed_dog', '中华田园犬', 10), + ('dog', 'golden_retriever', '金毛寻回犬', 20), + ('dog', 'labrador_retriever', '拉布拉多犬', 30), + ('dog', 'poodle', '贵宾犬', 40), + ('dog', 'bichon_frise', '比熊犬', 50), + ('dog', 'pomeranian', '博美犬', 60), + ('dog', 'welsh_corgi', '威尔士柯基犬', 70), + ('dog', 'shiba_inu', '柴犬', 80), + ('dog', 'siberian_husky', '哈士奇', 90), + ('dog', 'samoyed', '萨摩耶犬', 100), + ('dog', 'border_collie', '边境牧羊犬', 110), + ('dog', 'french_bulldog', '法国斗牛犬', 120), + ('dog', 'chihuahua', '吉娃娃', 130), + ('dog', 'dachshund', '腊肠犬', 140), + ('dog', 'schnauzer', '雪纳瑞', 150), + ('dog', 'german_shepherd', '德国牧羊犬', 160), + ('cat', 'mixed_cat', '中华田园猫', 10), + ('cat', 'british_shorthair', '英国短毛猫', 20), + ('cat', 'american_shorthair', '美国短毛猫', 30), + ('cat', 'ragdoll', '布偶猫', 40), + ('cat', 'siamese', '暹罗猫', 50), + ('cat', 'persian', '波斯猫', 60), + ('cat', 'maine_coon', '缅因猫', 70), + ('cat', 'scottish_fold', '苏格兰折耳猫', 80), + ('cat', 'exotic_shorthair', '异国短毛猫', 90), + ('cat', 'russian_blue', '俄罗斯蓝猫', 100), + ('cat', 'bengal', '孟加拉豹猫', 110), + ('cat', 'sphynx', '斯芬克斯猫', 120); + +INSERT INTO pet_health.vaccine_catalog (code, name, species, description) VALUES + ('canine_2in1', '犬二联疫苗', 'dog', '预防犬瘟热、犬细小病毒'), + ('canine_4in1', '犬四联疫苗', 'dog', '预防犬瘟热、犬细小病毒、犬传染性肝炎、副流感'), + ('canine_5in1', '犬五联疫苗', 'dog', '预防犬瘟热、犬细小病毒、犬传染性肝炎、副流感、腺病毒 II 型'), + ('canine_8in1', '犬八联疫苗', 'dog', '五联基础上增加钩端螺旋体等'), + ('rabies_dog', '狂犬疫苗(犬)', 'dog', '狂犬病毒灭活疫苗,首免后按年加强'), + ('kennel_cough', '犬窝咳疫苗', 'dog', '预防支气管败血波氏杆菌引起的犬窝咳'), + ('feline_3in1', '猫三联疫苗', 'cat', '预防猫瘟、猫杯状病毒、猫疱疹病毒'), + ('rabies_cat', '狂犬疫苗(猫)', 'cat', '狂犬病毒灭活疫苗,首免后按年加强'), + ('felv', '猫白血病疫苗', 'cat', '预防猫白血病病毒感染'), + ('feline_chlamydia', '猫衣原体疫苗', 'cat', '预防猫衣原体感染'); diff --git a/patbond-user/src/test/java/com/patbond/patbond/user/persistence/PetHealthMigrationIntegrationTest.java b/patbond-user/src/test/java/com/patbond/patbond/user/persistence/PetHealthMigrationIntegrationTest.java new file mode 100644 index 0000000..6dbba13 --- /dev/null +++ b/patbond-user/src/test/java/com/patbond/patbond/user/persistence/PetHealthMigrationIntegrationTest.java @@ -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("狂犬疫苗(犬)"); + } +}