From 2b5e29eb1b189235c2ea3d6ab70d4f4126419101 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=A4=80=EC=98=81?= Date: Wed, 19 Aug 2026 20:45:51 +0900 Subject: [PATCH] =?UTF-8?q?T-05=20=ED=99=88=ED=8E=98=EC=9D=B4=EC=A7=80=20C?= =?UTF-8?q?MS=20=EC=8A=A4=ED=82=A4=EB=A7=88=20(V9=5F=5Fcreate=5Fhomepage?= =?UTF-8?q?=5Fcontent.sql)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit track_page, curriculum(주차>토픽>세부항목), activity(카테고리>활동>사진), image_asset까지 12개 테이블. 엔티티는 아직 없다 — T-06 이후 도메인 티켓의 몫이다. - track_page: track(V7) 1 : 0..1, slug/track_id 각각 partial unique index(INV-1) - curriculum: 트랙당 공개 세트 1개(INV-2, partial unique index) - curriculum_week: week_to >= week_from CHECK(INV-5) - activity: month 1~12, year 2000~2100 CHECK(INV-6) - 최상위(track_page/curriculum/activity_category/activity)만 soft delete, 하위 트리는 ON DELETE CASCADE(INV-7) - activity -> activity_category는 CASCADE를 걸지 않았다 — 활동이 남은 카테고리 삭제는 FK 위반으로 거부되어야 한다(AC-3.8) - image_asset은 콘텐츠 테이블과 FK로 묶지 않는다(ADR-009) - track_page_member.member_id에 FK 즉시 적용(ADR-012, 명부가 이미 있으므로) 테스트: HomepageContentSchemaIntegrationTest(신규, IntegrationTestSupport 세 번째 소비자) — raw JDBC로 partial unique index 2개, CHECK 제약 2개, CASCADE 2개, RESTRICT 1개를 직접 검증한다. 아직 엔티티가 없어 JPA로는 검증할 수 없는 DDL 자체의 정확성을 여기서 굳힌다. AC-7.3(빈 DB / 기존 DB 양쪽 적용)을 두 경로로 확인했다: - Testcontainers: 항상 빈 DB에 V1~V9 순차 적용 (자동화 테스트) - 실제 로컬 Postgres: T-04에서 V1~V6까지 적용해 뒀던 DB에 이어서 부팅 → "Current version of schema: 6" → V7,V8,V9 적용 → v9, /health 200 (수동 확인) ./gradlew test 전체 통과(40개, 실패 0). Refs #17 --- .../migration/V9__create_homepage_content.sql | 177 +++++++++++++++++ .../HomepageContentSchemaIntegrationTest.java | 181 ++++++++++++++++++ 2 files changed, 358 insertions(+) create mode 100644 src/main/resources/db/migration/V9__create_homepage_content.sql create mode 100644 src/test/java/com/bcsdlab/bcsdinternalapiv2/track/HomepageContentSchemaIntegrationTest.java diff --git a/src/main/resources/db/migration/V9__create_homepage_content.sql b/src/main/resources/db/migration/V9__create_homepage_content.sql new file mode 100644 index 0000000..a1a1ee9 --- /dev/null +++ b/src/main/resources/db/migration/V9__create_homepage_content.sql @@ -0,0 +1,177 @@ +-- 홈페이지 CMS 스키마 (05-api-spec.md §1.2). track_page는 트랙 마스터(V7)를 참조한다. +-- 최상위 엔티티(track_page/curriculum/activity_category/activity)만 soft delete를 쓰고, +-- 하위 트리는 ON DELETE CASCADE로 물리 삭제한다. + +-- ===== 홈페이지 노출 프로필 (track 1 : track_page 0..1) ===== +CREATE TABLE track_page ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + track_id BIGINT NOT NULL REFERENCES track (id), + slug VARCHAR(50) NOT NULL, + display_name VARCHAR(50) NOT NULL, + tagline VARCHAR(120) NOT NULL, + hero_image_url VARCHAR(500), + og_image_url VARCHAR(500), + seo_description VARCHAR(200), + display_order INT NOT NULL, + is_published BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted_at TIMESTAMPTZ +); +CREATE UNIQUE INDEX uq_track_page_slug ON track_page (slug) WHERE deleted_at IS NULL; +CREATE UNIQUE INDEX uq_track_page_track ON track_page (track_id) WHERE deleted_at IS NULL; + +CREATE TABLE track_study_point ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + track_page_id BIGINT NOT NULL REFERENCES track_page (id) ON DELETE CASCADE, + title VARCHAR(60) NOT NULL, + description VARCHAR(200) NOT NULL, + icon_image_url VARCHAR(500), + display_order INT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX idx_study_point_page ON track_study_point (track_page_id, display_order); + +CREATE TABLE tech_stack ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + name VARCHAR(50) NOT NULL, + icon_url VARCHAR(500) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + + CONSTRAINT uk_tech_stack_name UNIQUE (name) +); + +CREATE TABLE track_page_tech_stack ( + track_page_id BIGINT NOT NULL REFERENCES track_page (id) ON DELETE CASCADE, + tech_stack_id BIGINT NOT NULL REFERENCES tech_stack (id) ON DELETE CASCADE, + display_order INT NOT NULL, + + PRIMARY KEY (track_page_id, tech_stack_id) +); + +-- 명부(member)가 이미 구현되어 있으므로 FK를 즉시 적용한다 (ADR-012). +CREATE TABLE track_page_member ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + track_page_id BIGINT NOT NULL REFERENCES track_page (id) ON DELETE CASCADE, + member_id BIGINT NOT NULL REFERENCES member (id), + display_order INT NOT NULL, + is_visible BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + + CONSTRAINT uk_track_page_member UNIQUE (track_page_id, member_id) +); + +-- ===== 커리큘럼 ===== +CREATE TABLE curriculum ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + track_page_id BIGINT NOT NULL REFERENCES track_page (id) ON DELETE CASCADE, + name VARCHAR(50) NOT NULL, + is_published BOOLEAN NOT NULL DEFAULT FALSE, + display_order INT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted_at TIMESTAMPTZ +); +-- INV-2: 트랙당 공개 세트는 1개만 (ADR-004) +CREATE UNIQUE INDEX uq_curriculum_published + ON curriculum (track_page_id) WHERE is_published AND deleted_at IS NULL; + +CREATE TABLE curriculum_week ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + curriculum_id BIGINT NOT NULL REFERENCES curriculum (id) ON DELETE CASCADE, + week_from INT NOT NULL, + week_to INT, + display_order INT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + + CONSTRAINT ck_week_range CHECK (week_to IS NULL OR week_to >= week_from), + CONSTRAINT ck_week_from CHECK (week_from BETWEEN 1 AND 99) +); +CREATE INDEX idx_week_curriculum ON curriculum_week (curriculum_id, display_order); + +CREATE TABLE curriculum_topic ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + week_id BIGINT NOT NULL REFERENCES curriculum_week (id) ON DELETE CASCADE, + title VARCHAR(200) NOT NULL, + display_order INT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX idx_topic_week ON curriculum_topic (week_id, display_order); + +CREATE TABLE curriculum_topic_detail ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + topic_id BIGINT NOT NULL REFERENCES curriculum_topic (id) ON DELETE CASCADE, + content VARCHAR(300) NOT NULL, + display_order INT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX idx_detail_topic ON curriculum_topic_detail (topic_id, display_order); + +-- ===== 활동 ===== +CREATE TABLE activity_category ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + slug VARCHAR(30) NOT NULL, + name VARCHAR(30) NOT NULL, + headline VARCHAR(200), + hero_image_url VARCHAR(500), + display_order INT NOT NULL, + is_published BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted_at TIMESTAMPTZ +); +CREATE UNIQUE INDEX uq_activity_category_slug ON activity_category (slug) WHERE deleted_at IS NULL; + +CREATE TABLE activity ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + category_id BIGINT NOT NULL REFERENCES activity_category (id), + year INT NOT NULL, + month INT NOT NULL, + title VARCHAR(80) NOT NULL, + summary VARCHAR(200) NOT NULL, + content TEXT, + external_url VARCHAR(500), + display_order INT NOT NULL, + is_published BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted_at TIMESTAMPTZ, + + CONSTRAINT ck_activity_month CHECK (month BETWEEN 1 AND 12), + CONSTRAINT ck_activity_year CHECK (year BETWEEN 2000 AND 2100) +); +CREATE INDEX idx_activity_timeline + ON activity (category_id, year DESC, month DESC, display_order) + WHERE deleted_at IS NULL; + +CREATE TABLE activity_image ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + activity_id BIGINT NOT NULL REFERENCES activity (id) ON DELETE CASCADE, + image_url VARCHAR(500) NOT NULL, + display_order INT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX idx_activity_image ON activity_image (activity_id, display_order); + +-- ===== 미디어 라이브러리 (콘텐츠 테이블과 FK로 묶지 않는다 · ADR-009) ===== +CREATE TABLE image_asset ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + s3_key VARCHAR(300) NOT NULL, + url VARCHAR(500) NOT NULL, + original_name VARCHAR(255) NOT NULL, + content_type VARCHAR(100) NOT NULL, + byte_size BIGINT NOT NULL, + purpose VARCHAR(30) NOT NULL, + is_confirmed BOOLEAN NOT NULL DEFAULT FALSE, + uploaded_by BIGINT REFERENCES member (id), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + + CONSTRAINT uk_image_asset_s3_key UNIQUE (s3_key) +); +CREATE INDEX idx_image_asset_library ON image_asset (purpose, created_at DESC) WHERE is_confirmed; diff --git a/src/test/java/com/bcsdlab/bcsdinternalapiv2/track/HomepageContentSchemaIntegrationTest.java b/src/test/java/com/bcsdlab/bcsdinternalapiv2/track/HomepageContentSchemaIntegrationTest.java new file mode 100644 index 0000000..2fda6b1 --- /dev/null +++ b/src/test/java/com/bcsdlab/bcsdinternalapiv2/track/HomepageContentSchemaIntegrationTest.java @@ -0,0 +1,181 @@ +package com.bcsdlab.bcsdinternalapiv2.track; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.bcsdlab.bcsdinternalapiv2.IntegrationTestSupport; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.jdbc.core.JdbcTemplate; + +/** + * T-05(V9__create_homepage_content.sql)의 제약 조건이 설계대로 동작하는지 검증한다. + * 아직 JPA 엔티티가 없으므로(T-06 이후 도메인 티켓의 몫) raw SQL로 직접 검증한다 — + * partial unique index, CHECK 제약, ON DELETE CASCADE처럼 오타 하나로도 조용히 깨지는 + * DDL을 엔티티가 생기기 전에 먼저 굳혀 둔다. + */ +class HomepageContentSchemaIntegrationTest extends IntegrationTestSupport { + + @Autowired + private JdbcTemplate jdbcTemplate; + + private Long frontendTrackId; + private Long backendTrackId; + + @BeforeEach + void setUp() { + jdbcTemplate.update("delete from activity"); + jdbcTemplate.update("delete from activity_category"); + jdbcTemplate.update("delete from curriculum"); + jdbcTemplate.update("delete from track_page"); + jdbcTemplate.update("delete from image_asset"); + + frontendTrackId = jdbcTemplate.queryForObject( + "select id from track where code = 'FRONTEND'", Long.class); + backendTrackId = jdbcTemplate.queryForObject( + "select id from track where code = 'BACKEND'", Long.class); + } + + @Test + @DisplayName("INV-1 slug는 삭제되지 않은 track_page끼리만 유일하다") + void track_page_slug는_삭제되지_않은_행끼리만_유일하다() { + insertTrackPage(frontendTrackId, "frontend"); + + assertThatThrownBy(() -> insertTrackPage(backendTrackId, "frontend")) + .isInstanceOf(DataIntegrityViolationException.class); + + jdbcTemplate.update("update track_page set deleted_at = now() where slug = 'frontend'"); + + // 예외를 던지지 않으면 성공 + insertTrackPage(backendTrackId, "frontend"); + } + + @Test + @DisplayName("INV-2 한 트랙에 공개 커리큘럼 세트는 하나만 허용한다") + void 공개_커리큘럼은_트랙당_하나만_허용한다() { + Long trackPageId = insertTrackPage(frontendTrackId, "frontend"); + insertCurriculum(trackPageId, "비기너", true); + + assertThatThrownBy(() -> insertCurriculum(trackPageId, "심화", true)) + .isInstanceOf(DataIntegrityViolationException.class); + + // 공개가 아니면 여러 개 허용 + insertCurriculum(trackPageId, "초안", false); + } + + @Test + @DisplayName("INV-5 week_to가 week_from보다 작으면 거부된다") + void 주차_범위가_거꾸로면_거부된다() { + Long trackPageId = insertTrackPage(frontendTrackId, "frontend"); + Long curriculumId = insertCurriculum(trackPageId, "비기너", true); + + assertThatThrownBy(() -> jdbcTemplate.update( + "insert into curriculum_week (curriculum_id, week_from, week_to, display_order) " + + "values (?, 5, 3, 0)", curriculumId)) + .isInstanceOf(DataIntegrityViolationException.class); + + jdbcTemplate.update( + "insert into curriculum_week (curriculum_id, week_from, week_to, display_order) " + + "values (?, 13, 16, 0)", curriculumId); + jdbcTemplate.update( + "insert into curriculum_week (curriculum_id, week_from, week_to, display_order) " + + "values (?, 4, null, 1)", curriculumId); + } + + @Test + @DisplayName("INV-6 활동의 month/year 범위를 벗어나면 거부된다") + void 활동_연월_범위를_벗어나면_거부된다() { + Long categoryId = insertActivityCategory("event", "EVENT"); + + assertThatThrownBy(() -> insertActivity(categoryId, 2019, 13)) + .isInstanceOf(DataIntegrityViolationException.class); + assertThatThrownBy(() -> insertActivity(categoryId, 1999, 5)) + .isInstanceOf(DataIntegrityViolationException.class); + + insertActivity(categoryId, 2019, 5); + } + + @Test + @DisplayName("INV-7 커리큘럼을 삭제하면 주차·토픽·세부항목이 cascade로 함께 삭제된다") + void 커리큘럼_삭제는_하위_트리에_cascade된다() { + Long trackPageId = insertTrackPage(frontendTrackId, "frontend"); + Long curriculumId = insertCurriculum(trackPageId, "비기너", true); + Long weekId = jdbcTemplate.queryForObject( + "insert into curriculum_week (curriculum_id, week_from, display_order) " + + "values (?, 1, 0) returning id", + Long.class, curriculumId); + Long topicId = jdbcTemplate.queryForObject( + "insert into curriculum_topic (week_id, title, display_order) values (?, '웹 동작 방식', 0) " + + "returning id", + Long.class, weekId); + jdbcTemplate.update( + "insert into curriculum_topic_detail (topic_id, content, display_order) values (?, 'HTTP', 0)", + topicId); + + jdbcTemplate.update("delete from curriculum where id = ?", curriculumId); + + assertThat(jdbcTemplate.queryForObject( + "select count(*) from curriculum_week where id = ?", Integer.class, weekId)).isZero(); + assertThat(jdbcTemplate.queryForObject( + "select count(*) from curriculum_topic where id = ?", Integer.class, topicId)).isZero(); + assertThat(jdbcTemplate.queryForObject( + "select count(*) from curriculum_topic_detail where topic_id = ?", Integer.class, topicId)) + .isZero(); + } + + @Test + @DisplayName("INV-7 활동을 삭제하면 사진도 cascade로 함께 삭제된다") + void 활동_삭제는_사진에_cascade된다() { + Long categoryId = insertActivityCategory("event", "EVENT"); + Long activityId = insertActivity(categoryId, 2019, 5); + jdbcTemplate.update( + "insert into activity_image (activity_id, image_url, display_order) values (?, 'https://x/1.png', 0)", + activityId); + + jdbcTemplate.update("delete from activity where id = ?", activityId); + + assertThat(jdbcTemplate.queryForObject( + "select count(*) from activity_image where activity_id = ?", Integer.class, activityId)) + .isZero(); + } + + @Test + @DisplayName("카테고리에 활동이 남아 있으면 FK 제약으로 삭제가 거부된다 (AC-3.8의 DB 쪽 근거)") + void 활동이_남은_카테고리는_삭제할_수_없다() { + Long categoryId = insertActivityCategory("event", "EVENT"); + insertActivity(categoryId, 2019, 5); + + assertThatThrownBy(() -> jdbcTemplate.update("delete from activity_category where id = ?", categoryId)) + .isInstanceOf(DataIntegrityViolationException.class); + } + + private Long insertTrackPage(Long trackId, String slug) { + return jdbcTemplate.queryForObject( + "insert into track_page (track_id, slug, display_name, tagline, display_order) " + + "values (?, ?, ?, 'tagline', 0) returning id", + Long.class, trackId, slug, slug); + } + + private Long insertCurriculum(Long trackPageId, String name, boolean published) { + return jdbcTemplate.queryForObject( + "insert into curriculum (track_page_id, name, is_published, display_order) " + + "values (?, ?, ?, 0) returning id", + Long.class, trackPageId, name, published); + } + + private Long insertActivityCategory(String slug, String name) { + return jdbcTemplate.queryForObject( + "insert into activity_category (slug, name, display_order) values (?, ?, 0) returning id", + Long.class, slug, name); + } + + private Long insertActivity(Long categoryId, int year, int month) { + return jdbcTemplate.queryForObject( + "insert into activity (category_id, year, month, title, summary, display_order) " + + "values (?, ?, ?, 't', 's', 0) returning id", + Long.class, categoryId, year, month); + } +}