From e1bfa4daa87fcc9213eff3aafe7a1b772a41fcf3 Mon Sep 17 00:00:00 2001 From: krestar Date: Fri, 31 Jul 2026 10:48:28 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20=EB=8D=B0=EB=AA=A8=20=EC=82=AC?= =?UTF-8?q?=EC=97=85=EC=9E=A5=20=EB=B0=8F=20=EA=B7=BC=EB=A1=9C=EC=9E=90=20?= =?UTF-8?q?=EC=8B=9C=EB=93=9C=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=ED=99=95?= =?UTF-8?q?=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 기존 FOWOCO Demo Company의 계정 구성을 유지하고 근로자 5명을 추가한다. - FOWOCO Test Company를 별도 사업장으로 생성한다. - Test Company에 ADMIN, HR, VIEWER 계정을 각각 1명씩 추가한다. - Test Company에 근로자 5명을 추가한다. - 고정 UUID를 사용해 시드를 재실행해도 데이터가 중복되지 않도록 한다. - 예약된 ID나 이메일이 기존 데이터와 충돌하면 덮어쓰지 않고 실행을 중단한다. - Demo Seed 설정과 개발 문서를 두 사업장 기준으로 확장한다. - 단위 및 통합 테스트에서 회사 2개, 계정 23명, 근로자 10명을 검증한다. --- .env.example | 7 +- docs/development-guide.md | 24 +-- .../seed/DemoAuthSeedProperties.java | 4 + .../seed/DemoAuthSeedRunner.java | 54 ++++--- .../seed/DemoWorkerSeedConfiguration.java | 29 ++++ .../seed/DemoWorkerSeedRunner.java | 145 ++++++++++++++++++ src/main/resources/application.yaml | 2 + .../seed/DemoAuthSeedIntegrationTest.java | 38 +++++ .../seed/DemoAuthSeedRunnerTest.java | 20 ++- .../seed/DemoWorkerSeedRunnerTest.java | 131 ++++++++++++++++ 10 files changed, 418 insertions(+), 36 deletions(-) create mode 100644 src/main/java/com/fowoco/server/worker/infrastructure/seed/DemoWorkerSeedConfiguration.java create mode 100644 src/main/java/com/fowoco/server/worker/infrastructure/seed/DemoWorkerSeedRunner.java create mode 100644 src/test/java/com/fowoco/server/worker/infrastructure/seed/DemoWorkerSeedRunnerTest.java diff --git a/.env.example b/.env.example index 5c9f904..4f4c2e7 100644 --- a/.env.example +++ b/.env.example @@ -47,13 +47,14 @@ REFRESH_TOKEN_COOKIE_SAME_SITE=Strict # local=false, dev/prod=true가 기본입니다. HTTPS 정책을 바꿀 때만 명시하세요. # REFRESH_TOKEN_COOKIE_SECURE=true -# 선택 사항: local(H2)에서 로그인 가능한 user_account 더미 계정 20명을 생성합니다. -# 설정한 ADMIN 1명과 고정된 가상 계정 19명이 같은 로컬 비밀번호를 사용합니다. +# 선택 사항: local(H2) 또는 로컬 PostgreSQL(dev)에 회사 2개, 계정 23개, 근로자 10명을 생성합니다. +# Demo Company에는 기존 계정 20개와 근로자 5명, Test Company에는 역할별 계정 3개와 근로자 5명이 생성됩니다. # 기본값은 false이며, true로 켤 때 비밀번호를 반드시 실행 환경의 Secret으로 넣습니다. -# PostgreSQL dev/prod에서는 사용하지 않고 #9 provisioning 단계에서 계정을 준비합니다. +# prod에서는 사용하지 않고 #9 provisioning 단계에서 계정을 준비합니다. # 실제 비밀번호를 이 파일이나 Git에 커밋하지 않습니다. DEMO_SEED_ENABLED=false # DEMO_SEED_ADMIN_PASSWORD= # DEMO_SEED_ADMIN_DISPLAY_NAME=데모 관리자 # DEMO_SEED_ADMIN_EMAIL=demo.admin@example.com # DEMO_SEED_COMPANY_NAME=FOWOCO Demo Company +# DEMO_SEED_TEST_COMPANY_NAME=FOWOCO Test Company diff --git a/docs/development-guide.md b/docs/development-guide.md index 3d3a085..c50154d 100644 --- a/docs/development-guide.md +++ b/docs/development-guide.md @@ -151,12 +151,16 @@ export SPRING_PROFILES_ACTIVE=dev runtime 계정은 업무 DML, migration 계정은 Flyway 적용만 담당합니다. 실제 비밀번호·API Key·Token은 Git, Issue, Discussion과 로그에 올리지 않습니다. -## 선택 사항: local Demo Seed +## 선택 사항: Demo Seed -local H2에서만 사용할 사업장과 `user_account` 더미 계정 20명이 필요할 때 -명시적으로 켭니다. 설정한 관리자 1명과 고정된 가상 계정 19명 -(`ADMIN` 1명, `HR` 12명, `VIEWER` 6명)을 만들며, 모든 계정은 로컬에서 설정한 -같은 비밀번호로 로그인할 수 있습니다. 기본값과 비밀번호 기본값은 없습니다. +local H2 또는 로컬 PostgreSQL `dev`에서만 사용할 데모 데이터가 필요할 때 +명시적으로 켭니다. 모든 계정은 로컬에서 설정한 같은 비밀번호로 로그인할 수 +있으며, 기본 비밀번호는 없습니다. + +| 회사 | 계정 | 근로자 | +| --- | --- | --- | +| `FOWOCO Demo Company` | 기존 20명 (`ADMIN` 2, `HR` 12, `VIEWER` 6) | 5명 | +| `FOWOCO Test Company` | 3명 (`ADMIN` 1, `HR` 1, `VIEWER` 1) | 5명 | ```bash export DEMO_SEED_ENABLED=true @@ -164,14 +168,14 @@ export DEMO_SEED_ADMIN_PASSWORD='로컬 Secret의 12자 이상 값' ./gradlew bootRun ``` -같은 설정으로 재실행해도 중복 생성하지 않습니다. 같은 이메일이 다른 사업장이나 -역할로 존재하면 덮어쓰지 않고 시작을 중단합니다. 이메일은 모두 -`demo.*@example.com` 형식의 가상 주소이며 실제 개인정보를 포함하지 않습니다. +같은 설정으로 재실행해도 중복 생성하지 않습니다. 고정 ID나 이메일이 다른 +사업장·역할·근로자 정보와 충돌하면 덮어쓰지 않고 시작을 중단합니다. 이메일과 +근로자 이름은 모두 데모 전용 값이며 실제 개인정보를 포함하지 않습니다. 최초 계정을 확인한 후에는 `DEMO_SEED_ENABLED=false`로 돌려놓습니다. -PostgreSQL `dev`·`prod`에서는 Demo Seed 대신 배포 Provisioning 단계에서 초기 -계정을 준비합니다. +공유 `dev`와 `prod`에서는 Demo Seed 대신 배포 Provisioning 단계에서 초기 계정을 +준비합니다. ### 팀 공통 PostgreSQL Demo 실행 diff --git a/src/main/java/com/fowoco/server/auth/infrastructure/seed/DemoAuthSeedProperties.java b/src/main/java/com/fowoco/server/auth/infrastructure/seed/DemoAuthSeedProperties.java index e42a08d..683a045 100644 --- a/src/main/java/com/fowoco/server/auth/infrastructure/seed/DemoAuthSeedProperties.java +++ b/src/main/java/com/fowoco/server/auth/infrastructure/seed/DemoAuthSeedProperties.java @@ -8,6 +8,8 @@ public record DemoAuthSeedProperties( boolean enabled, UUID companyId, String companyName, + UUID testCompanyId, + String testCompanyName, UUID adminUserId, String adminDisplayName, String adminEmail, @@ -19,6 +21,8 @@ public String toString() { return "DemoAuthSeedProperties[enabled=" + enabled + ", companyId=" + companyId + ", companyName=" + companyName + + ", testCompanyId=" + testCompanyId + + ", testCompanyName=" + testCompanyName + ", adminUserId=" + adminUserId + ", adminDisplayName=" + adminDisplayName + ", adminEmail=" + adminEmail diff --git a/src/main/java/com/fowoco/server/auth/infrastructure/seed/DemoAuthSeedRunner.java b/src/main/java/com/fowoco/server/auth/infrastructure/seed/DemoAuthSeedRunner.java index 0fd8e44..23aa426 100644 --- a/src/main/java/com/fowoco/server/auth/infrastructure/seed/DemoAuthSeedRunner.java +++ b/src/main/java/com/fowoco/server/auth/infrastructure/seed/DemoAuthSeedRunner.java @@ -15,9 +15,11 @@ import org.slf4j.LoggerFactory; import org.springframework.boot.ApplicationArguments; import org.springframework.boot.ApplicationRunner; +import org.springframework.core.annotation.Order; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.transaction.annotation.Transactional; +@Order(0) class DemoAuthSeedRunner implements ApplicationRunner { private static final Logger LOGGER = LoggerFactory.getLogger(DemoAuthSeedRunner.class); @@ -43,6 +45,11 @@ class DemoAuthSeedRunner implements ApplicationRunner { demoUser("90000000-0000-0000-0000-000000000020", "데모 조회자 05", "demo.viewer05@example.com", UserRole.VIEWER), demoUser("90000000-0000-0000-0000-000000000021", "데모 조회자 06", "demo.viewer06@example.com", UserRole.VIEWER) ); + private static final List TEST_USERS = List.of( + demoUser("91000000-0000-0000-0000-000000000002", "테스트 관리자", "test.admin@example.com", UserRole.ADMIN), + demoUser("91000000-0000-0000-0000-000000000003", "테스트 HR", "test.hr@example.com", UserRole.HR), + demoUser("91000000-0000-0000-0000-000000000004", "테스트 조회자", "test.viewer@example.com", UserRole.VIEWER) + ); private final DemoAuthSeedProperties properties; private final CompanyRepository companyRepository; @@ -75,26 +82,30 @@ class DemoAuthSeedRunner implements ApplicationRunner { public void run(ApplicationArguments arguments) { validateConfiguration(); Instant now = clock.instant(); - ensureCompany(now); + ensureCompany(properties.companyId(), properties.companyName(), now); + ensureCompany(properties.testCompanyId(), properties.testCompanyName(), now); - seedUser(new DemoUser( + seedUser(properties.companyId(), new DemoUser( properties.adminUserId(), properties.adminDisplayName(), properties.adminEmail(), UserRole.ADMIN ), now); - DEMO_USERS.forEach(user -> seedUser(user, now)); + DEMO_USERS.forEach(user -> seedUser(properties.companyId(), user, now)); + TEST_USERS.forEach(user -> seedUser(properties.testCompanyId(), user, now)); LOGGER.info( - "demo_auth_seed ready company_id={} user_count={}", - properties.companyId(), - DEMO_USERS.size() + 1 + "demo_auth_seed ready company_count={} user_count={}", + 2, + DEMO_USERS.size() + TEST_USERS.size() + 1 ); } private void validateConfiguration() { Objects.requireNonNull(properties.companyId(), "demo seed companyId must not be null"); + Objects.requireNonNull(properties.testCompanyId(), "demo seed testCompanyId must not be null"); Objects.requireNonNull(properties.adminUserId(), "demo seed adminUserId must not be null"); requireText(properties.companyName(), "demo seed companyName"); + requireText(properties.testCompanyName(), "demo seed testCompanyName"); requireText(properties.adminDisplayName(), "demo seed adminDisplayName"); requireText(properties.adminEmail(), "demo seed adminEmail"); String password = requireText(properties.adminPassword(), "DEMO_SEED_ADMIN_PASSWORD"); @@ -105,22 +116,25 @@ private void validateConfiguration() { + " characters" ); } - if (DEMO_USERS.stream().anyMatch(user -> user.userId().equals(properties.adminUserId()))) { + if (properties.companyId().equals(properties.testCompanyId())) { + throw new IllegalStateException("demo seed company ids must be different"); + } + if (reservedUsers().stream().anyMatch(user -> user.userId().equals(properties.adminUserId()))) { throw new IllegalStateException("demo seed adminUserId conflicts with a reserved demo user id"); } String normalizedAdminEmail = UserAccount.normalizeEmail(properties.adminEmail()); - if (DEMO_USERS.stream().anyMatch(user -> + if (reservedUsers().stream().anyMatch(user -> UserAccount.normalizeEmail(user.email()).equals(normalizedAdminEmail))) { throw new IllegalStateException("demo seed adminEmail conflicts with a reserved demo user email"); } } - private void ensureCompany(Instant now) { - Optional existingCompany = companyRepository.findById(properties.companyId()); + private void ensureCompany(UUID companyId, String companyName, Instant now) { + Optional existingCompany = companyRepository.findById(companyId); if (existingCompany.isEmpty()) { companyRepository.insert(Company.create( - properties.companyId(), - properties.companyName(), + companyId, + companyName, now )); return; @@ -130,22 +144,22 @@ private void ensureCompany(Instant now) { } } - private void seedUser(DemoUser demoUser, Instant now) { + private void seedUser(UUID companyId, DemoUser demoUser, Instant now) { String normalizedEmail = UserAccount.normalizeEmail(demoUser.email()); Optional existingByEmail = userAccountRepository.findByNormalizedEmail(normalizedEmail); if (existingByEmail.isPresent()) { - verifyExistingUser(existingByEmail.orElseThrow(), demoUser); + verifyExistingUser(existingByEmail.orElseThrow(), companyId, demoUser); return; } if (userAccountRepository - .findByUserIdAndCompanyId(demoUser.userId(), properties.companyId()) + .findByUserIdAndCompanyId(demoUser.userId(), companyId) .isPresent()) { throw new IllegalStateException("a reserved demo user id already belongs to another email"); } userAccountRepository.insert(UserAccount.create( demoUser.userId(), - properties.companyId(), + companyId, demoUser.displayName(), demoUser.email(), passwordEncoder.encode(properties.adminPassword()), @@ -154,9 +168,9 @@ private void seedUser(DemoUser demoUser, Instant now) { )); } - private void verifyExistingUser(UserAccount userAccount, DemoUser demoUser) { + private void verifyExistingUser(UserAccount userAccount, UUID companyId, DemoUser demoUser) { if (!demoUser.userId().equals(userAccount.userId()) - || !properties.companyId().equals(userAccount.companyId()) + || !companyId.equals(userAccount.companyId()) || userAccount.role() != demoUser.role() || !userAccount.canLogin()) { throw new IllegalStateException( @@ -176,6 +190,10 @@ private static DemoUser demoUser(String userId, String displayName, String email return new DemoUser(UUID.fromString(userId), displayName, email, role); } + private static List reservedUsers() { + return java.util.stream.Stream.concat(DEMO_USERS.stream(), TEST_USERS.stream()).toList(); + } + private record DemoUser(UUID userId, String displayName, String email, UserRole role) { } } diff --git a/src/main/java/com/fowoco/server/worker/infrastructure/seed/DemoWorkerSeedConfiguration.java b/src/main/java/com/fowoco/server/worker/infrastructure/seed/DemoWorkerSeedConfiguration.java new file mode 100644 index 0000000..71c5de7 --- /dev/null +++ b/src/main/java/com/fowoco/server/worker/infrastructure/seed/DemoWorkerSeedConfiguration.java @@ -0,0 +1,29 @@ +package com.fowoco.server.worker.infrastructure.seed; + +import com.fowoco.server.auth.infrastructure.seed.DemoAuthSeedProperties; +import com.fowoco.server.company.application.port.CompanyRepository; +import com.fowoco.server.worker.application.port.WorkerRepository; +import java.time.Clock; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration(proxyBeanMethods = false) +public class DemoWorkerSeedConfiguration { + + @Bean + @ConditionalOnProperty(prefix = "app.demo-seed", name = "enabled", havingValue = "true") + DemoWorkerSeedRunner demoWorkerSeedRunner( + DemoAuthSeedProperties properties, + CompanyRepository companyRepository, + WorkerRepository workerRepository, + Clock clock + ) { + return new DemoWorkerSeedRunner( + properties, + companyRepository, + workerRepository, + clock + ); + } +} diff --git a/src/main/java/com/fowoco/server/worker/infrastructure/seed/DemoWorkerSeedRunner.java b/src/main/java/com/fowoco/server/worker/infrastructure/seed/DemoWorkerSeedRunner.java new file mode 100644 index 0000000..27b7732 --- /dev/null +++ b/src/main/java/com/fowoco/server/worker/infrastructure/seed/DemoWorkerSeedRunner.java @@ -0,0 +1,145 @@ +package com.fowoco.server.worker.infrastructure.seed; + +import com.fowoco.server.auth.infrastructure.seed.DemoAuthSeedProperties; +import com.fowoco.server.company.application.port.CompanyRepository; +import com.fowoco.server.company.domain.Company; +import com.fowoco.server.worker.application.port.WorkerRepository; +import com.fowoco.server.worker.domain.Worker; +import java.time.Clock; +import java.time.Instant; +import java.time.LocalDate; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.UUID; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.core.annotation.Order; +import org.springframework.transaction.annotation.Transactional; + +@Order(1) +class DemoWorkerSeedRunner implements ApplicationRunner { + + private static final Logger LOGGER = LoggerFactory.getLogger(DemoWorkerSeedRunner.class); + private static final List DEMO_WORKERS = List.of( + demoWorker("92000000-0000-0000-0000-000000000001", "데모 근로자 01", "VN", "vi", 30, 180), + demoWorker("92000000-0000-0000-0000-000000000002", "데모 근로자 02", "KH", "km", 60, 210), + demoWorker("92000000-0000-0000-0000-000000000003", "데모 근로자 03", "NP", "ne", 90, 240), + demoWorker("92000000-0000-0000-0000-000000000004", "데모 근로자 04", "ID", "id", 120, 270), + demoWorker("92000000-0000-0000-0000-000000000005", "데모 근로자 05", "PH", "en", 180, 365) + ); + private static final List TEST_WORKERS = List.of( + demoWorker("93000000-0000-0000-0000-000000000001", "테스트 근로자 01", "TH", "th", 45, 190), + demoWorker("93000000-0000-0000-0000-000000000002", "테스트 근로자 02", "MN", "mn", 75, 220), + demoWorker("93000000-0000-0000-0000-000000000003", "테스트 근로자 03", "BD", "bn", 105, 250), + demoWorker("93000000-0000-0000-0000-000000000004", "테스트 근로자 04", "UZ", "uz", 135, 280), + demoWorker("93000000-0000-0000-0000-000000000005", "테스트 근로자 05", "LK", "si", 195, 370) + ); + + private final DemoAuthSeedProperties properties; + private final CompanyRepository companyRepository; + private final WorkerRepository workerRepository; + private final Clock clock; + + DemoWorkerSeedRunner( + DemoAuthSeedProperties properties, + CompanyRepository companyRepository, + WorkerRepository workerRepository, + Clock clock + ) { + this.properties = Objects.requireNonNull(properties, "properties must not be null"); + this.companyRepository = Objects.requireNonNull( + companyRepository, + "companyRepository must not be null" + ); + this.workerRepository = Objects.requireNonNull(workerRepository, "workerRepository must not be null"); + this.clock = Objects.requireNonNull(clock, "clock must not be null"); + } + + @Override + @Transactional + public void run(ApplicationArguments arguments) { + Instant now = clock.instant(); + LocalDate today = LocalDate.now(clock); + seedCompanyWorkers(properties.companyId(), DEMO_WORKERS, today, now); + seedCompanyWorkers(properties.testCompanyId(), TEST_WORKERS, today, now); + LOGGER.info("demo_worker_seed ready company_count={} worker_count={}", 2, 10); + } + + private void seedCompanyWorkers( + UUID companyId, + List workers, + LocalDate today, + Instant now + ) { + Company company = companyRepository.findById(companyId) + .orElseThrow(() -> new IllegalStateException("demo worker seed company does not exist")); + if (!company.isActive()) { + throw new IllegalStateException("demo worker seed company is not active"); + } + workers.forEach(worker -> seedWorker(companyId, worker, today, now)); + } + + private void seedWorker(UUID companyId, DemoWorker demoWorker, LocalDate today, Instant now) { + Optional existing = + workerRepository.findByWorkerIdAndCompanyId(demoWorker.workerId(), companyId); + if (existing.isPresent()) { + verifyExistingWorker(existing.orElseThrow(), companyId, demoWorker); + return; + } + workerRepository.insert(Worker.create( + demoWorker.workerId(), + companyId, + demoWorker.displayName(), + demoWorker.nationalityCode(), + demoWorker.preferredLanguage(), + today.plusDays(demoWorker.stayExpiryDays()), + today.minusYears(1), + today.plusDays(demoWorker.contractEndDays()), + now + )); + } + + private void verifyExistingWorker(Worker worker, UUID companyId, DemoWorker demoWorker) { + if (!demoWorker.workerId().equals(worker.workerId()) + || !companyId.equals(worker.companyId()) + || !demoWorker.displayName().equals(worker.displayName()) + || !demoWorker.nationalityCode().equals(worker.nationalityCode()) + || !demoWorker.preferredLanguage().equals(worker.preferredLanguage()) + || !worker.isCurrentlyEmployed()) { + throw new IllegalStateException( + "a reserved demo worker id already belongs to different or inactive worker data" + ); + } + } + + private static DemoWorker demoWorker( + String workerId, + String displayName, + String nationalityCode, + String preferredLanguage, + int stayExpiryDays, + int contractEndDays + ) { + return new DemoWorker( + UUID.fromString(workerId), + displayName, + nationalityCode, + preferredLanguage, + stayExpiryDays, + contractEndDays + ); + } + + private record DemoWorker( + UUID workerId, + String displayName, + String nationalityCode, + String preferredLanguage, + int stayExpiryDays, + int contractEndDays + ) { + } +} diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 677875f..bee9247 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -64,6 +64,8 @@ app: enabled: ${DEMO_SEED_ENABLED:false} company-id: ${DEMO_SEED_COMPANY_ID:90000000-0000-0000-0000-000000000001} company-name: ${DEMO_SEED_COMPANY_NAME:FOWOCO Demo Company} + test-company-id: ${DEMO_SEED_TEST_COMPANY_ID:91000000-0000-0000-0000-000000000001} + test-company-name: ${DEMO_SEED_TEST_COMPANY_NAME:FOWOCO Test Company} admin-user-id: ${DEMO_SEED_ADMIN_USER_ID:90000000-0000-0000-0000-000000000002} admin-display-name: ${DEMO_SEED_ADMIN_DISPLAY_NAME:데모 관리자} admin-email: ${DEMO_SEED_ADMIN_EMAIL:demo.admin@example.com} diff --git a/src/test/java/com/fowoco/server/auth/infrastructure/seed/DemoAuthSeedIntegrationTest.java b/src/test/java/com/fowoco/server/auth/infrastructure/seed/DemoAuthSeedIntegrationTest.java index 752e104..4f00326 100644 --- a/src/test/java/com/fowoco/server/auth/infrastructure/seed/DemoAuthSeedIntegrationTest.java +++ b/src/test/java/com/fowoco/server/auth/infrastructure/seed/DemoAuthSeedIntegrationTest.java @@ -18,6 +18,8 @@ class DemoAuthSeedIntegrationTest { private static final UUID COMPANY_ID = UUID.fromString("90000000-0000-0000-0000-000000000001"); + private static final UUID TEST_COMPANY_ID = + UUID.fromString("91000000-0000-0000-0000-000000000001"); private static final UUID ADMIN_USER_ID = UUID.fromString("90000000-0000-0000-0000-000000000002"); @Autowired @@ -42,6 +44,12 @@ void enabledSeedCreatesAnActiveCompanyAndHashedAdminAfterFlyway() { Integer.class, COMPANY_ID )).isEqualTo(1); + assertThat(jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM company WHERE company_id = ? " + + "AND name = 'FOWOCO Test Company' AND status = 'ACTIVE'", + Integer.class, + TEST_COMPANY_ID + )).isEqualTo(1); assertThat(jdbcTemplate.queryForObject( "SELECT COUNT(*) FROM user_account WHERE user_id = ? AND display_name = '데모 관리자' " + "AND role = 'ADMIN' AND status = 'ACTIVE'", @@ -68,5 +76,35 @@ void enabledSeedCreatesAnActiveCompanyAndHashedAdminAfterFlyway() { Integer.class, COMPANY_ID )).isEqualTo(6); + assertThat(jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM user_account WHERE company_id = ?", + Integer.class, + TEST_COMPANY_ID + )).isEqualTo(3); + assertThat(jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM user_account WHERE company_id = ? AND role = 'ADMIN'", + Integer.class, + TEST_COMPANY_ID + )).isEqualTo(1); + assertThat(jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM user_account WHERE company_id = ? AND role = 'HR'", + Integer.class, + TEST_COMPANY_ID + )).isEqualTo(1); + assertThat(jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM user_account WHERE company_id = ? AND role = 'VIEWER'", + Integer.class, + TEST_COMPANY_ID + )).isEqualTo(1); + assertThat(jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM worker WHERE company_id = ?", + Integer.class, + COMPANY_ID + )).isEqualTo(5); + assertThat(jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM worker WHERE company_id = ?", + Integer.class, + TEST_COMPANY_ID + )).isEqualTo(5); } } diff --git a/src/test/java/com/fowoco/server/auth/infrastructure/seed/DemoAuthSeedRunnerTest.java b/src/test/java/com/fowoco/server/auth/infrastructure/seed/DemoAuthSeedRunnerTest.java index b0aa022..bb5f2ce 100644 --- a/src/test/java/com/fowoco/server/auth/infrastructure/seed/DemoAuthSeedRunnerTest.java +++ b/src/test/java/com/fowoco/server/auth/infrastructure/seed/DemoAuthSeedRunnerTest.java @@ -24,6 +24,8 @@ class DemoAuthSeedRunnerTest { private static final UUID COMPANY_ID = UUID.fromString("90000000-0000-0000-0000-000000000001"); + private static final UUID TEST_COMPANY_ID = + UUID.fromString("91000000-0000-0000-0000-000000000001"); private static final UUID ADMIN_USER_ID = UUID.fromString("90000000-0000-0000-0000-000000000002"); private static final String ADMIN_DISPLAY_NAME = "데모 관리자"; private static final String ADMIN_EMAIL = "demo.admin@example.com"; @@ -45,8 +47,10 @@ void createsAnIdempotentAdminSeedAndStoresOnlyThePasswordHash() throws Exception runner.run(new DefaultApplicationArguments(new String[0])); runner.run(new DefaultApplicationArguments(new String[0])); - assertThat(companyRepository.companies).hasSize(1); - assertThat(userAccountRepository.users).hasSize(20); + assertThat(companyRepository.companies).hasSize(2); + assertThat(companyRepository.companies.get(TEST_COMPANY_ID).name()) + .isEqualTo("FOWOCO Test Company"); + assertThat(userAccountRepository.users).hasSize(23); UserAccount admin = userAccountRepository.users.get(ADMIN_USER_ID); assertThat(admin.companyId()).isEqualTo(COMPANY_ID); assertThat(admin.displayName()).isEqualTo(ADMIN_DISPLAY_NAME); @@ -57,13 +61,17 @@ void createsAnIdempotentAdminSeedAndStoresOnlyThePasswordHash() throws Exception assertThat(admin.canLogin()).isTrue(); assertThat(userAccountRepository.users.values()) .filteredOn(user -> user.role() == UserRole.ADMIN) - .hasSize(2); + .hasSize(3); assertThat(userAccountRepository.users.values()) .filteredOn(user -> user.role() == UserRole.HR) - .hasSize(12); + .hasSize(13); assertThat(userAccountRepository.users.values()) .filteredOn(user -> user.role() == UserRole.VIEWER) - .hasSize(6); + .hasSize(7); + assertThat(userAccountRepository.users.values()) + .filteredOn(user -> user.companyId().equals(TEST_COMPANY_ID)) + .extracting(UserAccount::role) + .containsExactlyInAnyOrder(UserRole.ADMIN, UserRole.HR, UserRole.VIEWER); assertThat(userAccountRepository.users.values()) .allMatch(user -> passwordEncoder.matches(ADMIN_PASSWORD, user.passwordHash())); } @@ -135,6 +143,8 @@ private DemoAuthSeedProperties properties(String password) { true, COMPANY_ID, "FOWOCO Demo Company", + TEST_COMPANY_ID, + "FOWOCO Test Company", ADMIN_USER_ID, ADMIN_DISPLAY_NAME, ADMIN_EMAIL, diff --git a/src/test/java/com/fowoco/server/worker/infrastructure/seed/DemoWorkerSeedRunnerTest.java b/src/test/java/com/fowoco/server/worker/infrastructure/seed/DemoWorkerSeedRunnerTest.java new file mode 100644 index 0000000..c175ef8 --- /dev/null +++ b/src/test/java/com/fowoco/server/worker/infrastructure/seed/DemoWorkerSeedRunnerTest.java @@ -0,0 +1,131 @@ +package com.fowoco.server.worker.infrastructure.seed; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.fowoco.server.auth.infrastructure.seed.DemoAuthSeedProperties; +import com.fowoco.server.company.application.port.CompanyRepository; +import com.fowoco.server.company.domain.Company; +import com.fowoco.server.worker.application.WorkerSearchQuery; +import com.fowoco.server.worker.application.port.WorkerRepository; +import com.fowoco.server.worker.domain.Worker; +import java.time.Clock; +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneOffset; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import org.junit.jupiter.api.Test; +import org.springframework.boot.DefaultApplicationArguments; + +class DemoWorkerSeedRunnerTest { + + private static final UUID DEMO_COMPANY_ID = + UUID.fromString("90000000-0000-0000-0000-000000000001"); + private static final UUID TEST_COMPANY_ID = + UUID.fromString("91000000-0000-0000-0000-000000000001"); + private static final UUID ADMIN_USER_ID = + UUID.fromString("90000000-0000-0000-0000-000000000002"); + private static final Instant NOW = Instant.parse("2026-07-31T00:00:00Z"); + + @Test + void createsFiveIdempotentWorkersForEachDemoCompany() throws Exception { + InMemoryCompanyRepository companyRepository = new InMemoryCompanyRepository(); + companyRepository.insert(Company.create(DEMO_COMPANY_ID, "FOWOCO Demo Company", NOW)); + companyRepository.insert(Company.create(TEST_COMPANY_ID, "FOWOCO Test Company", NOW)); + InMemoryWorkerRepository workerRepository = new InMemoryWorkerRepository(); + DemoWorkerSeedRunner runner = new DemoWorkerSeedRunner( + properties(), + companyRepository, + workerRepository, + Clock.fixed(NOW, ZoneOffset.UTC) + ); + + runner.run(new DefaultApplicationArguments(new String[0])); + runner.run(new DefaultApplicationArguments(new String[0])); + + assertThat(workerRepository.workers.values()) + .filteredOn(worker -> worker.companyId().equals(DEMO_COMPANY_ID)) + .hasSize(5); + assertThat(workerRepository.workers.values()) + .filteredOn(worker -> worker.companyId().equals(TEST_COMPANY_ID)) + .hasSize(5); + assertThat(workerRepository.workers.values()) + .allMatch(Worker::isCurrentlyEmployed); + assertThat(workerRepository.workers.get( + UUID.fromString("92000000-0000-0000-0000-000000000001") + ).stayExpiryDate()).isEqualTo(LocalDate.of(2026, 8, 30)); + } + + private DemoAuthSeedProperties properties() { + return new DemoAuthSeedProperties( + true, + DEMO_COMPANY_ID, + "FOWOCO Demo Company", + TEST_COMPANY_ID, + "FOWOCO Test Company", + ADMIN_USER_ID, + "데모 관리자", + "demo.admin@example.com", + "Demo-password-1!" + ); + } + + private static final class InMemoryCompanyRepository implements CompanyRepository { + + private final Map companies = new LinkedHashMap<>(); + + @Override + public Optional findById(UUID companyId) { + return Optional.ofNullable(companies.get(companyId)); + } + + @Override + public void insert(Company company) { + if (companies.putIfAbsent(company.companyId(), company) != null) { + throw new IllegalStateException("duplicate company"); + } + } + } + + private static final class InMemoryWorkerRepository implements WorkerRepository { + + private final Map workers = new LinkedHashMap<>(); + + @Override + public void insert(Worker worker) { + if (workers.putIfAbsent(worker.workerId(), worker) != null) { + throw new IllegalStateException("duplicate worker"); + } + } + + @Override + public Optional findByWorkerIdAndCompanyId(UUID workerId, UUID companyId) { + return Optional.ofNullable(workers.get(workerId)) + .filter(worker -> worker.companyId().equals(companyId)); + } + + @Override + public Worker update(Worker worker) { + throw new UnsupportedOperationException(); + } + + @Override + public List findPage(UUID companyId, WorkerSearchQuery query) { + throw new UnsupportedOperationException(); + } + + @Override + public long countPage(UUID companyId, WorkerSearchQuery query) { + throw new UnsupportedOperationException(); + } + + @Override + public List findAllByWorkerIdsAndCompanyId(Set workerIds, UUID companyId) { + throw new UnsupportedOperationException(); + } + } +} From 92026d22c0929996ac63d0c047e61c6fa0a9daa3 Mon Sep 17 00:00:00 2001 From: krestar Date: Fri, 31 Jul 2026 13:27:25 +0900 Subject: [PATCH 2/3] =?UTF-8?q?feat:=20Demo=20Company=20=EC=97=85=EB=AC=B4?= =?UTF-8?q?=C2=B7=EC=84=9C=EB=A5=98=C2=B7=EA=B0=90=EC=82=AC=20=EC=8B=9C?= =?UTF-8?q?=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Demo Company에 상태별 데모 Task 5개를 추가한다. - AI 후보와 승인 대기, 근로자 응답 대기, 외부 처리 대기, 당일 완료 상태를 구성한다. - 마감일을 7일 이내, 30일 이내, 여유 구간으로 분산한다. - 근로자별 1~2개의 서류를 추가하고 MISSING, SUBMITTED, VERIFIED 상태를 포함한다. - 30일 이내 만료되는 근로자 서류를 추가한다. - 승인 대기 Task에 최근 AuditEvent 3개를 추가한다. - 고정 UUID와 충돌 검증으로 재실행 시 중복 생성을 방지한다. - 통합 테스트와 Demo Seed 개발 문서를 확장한다. --- .env.example | 3 +- docs/development-guide.md | 9 + .../DemoOperationalSeedConfiguration.java | 35 ++ .../seed/DemoOperationalSeedRunner.java | 510 ++++++++++++++++++ .../seed/DemoAuthSeedIntegrationTest.java | 94 +++- 5 files changed, 649 insertions(+), 2 deletions(-) create mode 100644 src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedConfiguration.java create mode 100644 src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedRunner.java diff --git a/.env.example b/.env.example index 4f4c2e7..b4259b4 100644 --- a/.env.example +++ b/.env.example @@ -48,7 +48,8 @@ REFRESH_TOKEN_COOKIE_SAME_SITE=Strict # REFRESH_TOKEN_COOKIE_SECURE=true # 선택 사항: local(H2) 또는 로컬 PostgreSQL(dev)에 회사 2개, 계정 23개, 근로자 10명을 생성합니다. -# Demo Company에는 기존 계정 20개와 근로자 5명, Test Company에는 역할별 계정 3개와 근로자 5명이 생성됩니다. +# Demo Company에는 기존 계정 20개와 근로자 5명, Task 5개, 근로자 서류 7개, +# Task 최근 활동 3개가 생성됩니다. Test Company에는 역할별 계정 3개와 근로자 5명이 생성됩니다. # 기본값은 false이며, true로 켤 때 비밀번호를 반드시 실행 환경의 Secret으로 넣습니다. # prod에서는 사용하지 않고 #9 provisioning 단계에서 계정을 준비합니다. # 실제 비밀번호를 이 파일이나 Git에 커밋하지 않습니다. diff --git a/docs/development-guide.md b/docs/development-guide.md index c50154d..1b438ad 100644 --- a/docs/development-guide.md +++ b/docs/development-guide.md @@ -162,6 +162,15 @@ local H2 또는 로컬 PostgreSQL `dev`에서만 사용할 데모 데이터가 | `FOWOCO Demo Company` | 기존 20명 (`ADMIN` 2, `HR` 12, `VIEWER` 6) | 5명 | | `FOWOCO Test Company` | 3명 (`ADMIN` 1, `HR` 1, `VIEWER` 1) | 5명 | +Demo Company에는 화면 검증용 업무 데이터도 함께 생성됩니다. + +- Task 5개: `DRAFT`(AI 후보), `READY_FOR_REVIEW`, `WAITING_WORKER`, + `WAITING_EXTERNAL`, `COMPLETED` 각 1개 +- 마감일: 7일 이내, 30일 이내, 30일 초과 구간을 모두 포함 +- 근로자 서류 7개: 근로자별 1~2개, `MISSING`, `SUBMITTED`, `VERIFIED` 포함 +- 30일 이내 만료 서류 1개 이상 +- 승인 대기 Task의 최근 AuditEvent 3개 + ```bash export DEMO_SEED_ENABLED=true export DEMO_SEED_ADMIN_PASSWORD='로컬 Secret의 12자 이상 값' diff --git a/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedConfiguration.java b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedConfiguration.java new file mode 100644 index 0000000..0bd2b7e --- /dev/null +++ b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedConfiguration.java @@ -0,0 +1,35 @@ +package com.fowoco.server.demo.infrastructure.seed; + +import com.fowoco.server.audit.application.port.AuditEventRepository; +import com.fowoco.server.auth.infrastructure.seed.DemoAuthSeedProperties; +import com.fowoco.server.task.application.TaskContentCodec; +import com.fowoco.server.task.application.port.TaskRepository; +import com.fowoco.server.worker.application.port.WorkerDocumentRepository; +import java.time.Clock; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration(proxyBeanMethods = false) +public class DemoOperationalSeedConfiguration { + + @Bean + @ConditionalOnProperty(prefix = "app.demo-seed", name = "enabled", havingValue = "true") + DemoOperationalSeedRunner demoOperationalSeedRunner( + DemoAuthSeedProperties properties, + TaskRepository taskRepository, + TaskContentCodec taskContentCodec, + WorkerDocumentRepository workerDocumentRepository, + AuditEventRepository auditEventRepository, + Clock clock + ) { + return new DemoOperationalSeedRunner( + properties, + taskRepository, + taskContentCodec, + workerDocumentRepository, + auditEventRepository, + clock + ); + } +} diff --git a/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedRunner.java b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedRunner.java new file mode 100644 index 0000000..69b5524 --- /dev/null +++ b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedRunner.java @@ -0,0 +1,510 @@ +package com.fowoco.server.demo.infrastructure.seed; + +import com.fowoco.server.audit.application.port.AuditEventRepository; +import com.fowoco.server.audit.domain.ActorType; +import com.fowoco.server.audit.domain.AuditAction; +import com.fowoco.server.audit.domain.AuditEvent; +import com.fowoco.server.audit.domain.AuditTargetType; +import com.fowoco.server.auth.domain.UserRole; +import com.fowoco.server.auth.infrastructure.seed.DemoAuthSeedProperties; +import com.fowoco.server.task.application.TaskContentCodec; +import com.fowoco.server.task.application.TaskContentCodec.EncodedTaskContent; +import com.fowoco.server.task.application.port.TaskRepository; +import com.fowoco.server.task.domain.Task; +import com.fowoco.server.task.domain.TaskSource; +import com.fowoco.server.task.domain.TaskStatus; +import com.fowoco.server.task.domain.TaskType; +import com.fowoco.server.worker.application.port.WorkerDocumentRepository; +import com.fowoco.server.worker.domain.DocumentType; +import com.fowoco.server.worker.domain.SubmissionStatus; +import com.fowoco.server.worker.domain.WorkerDocument; +import java.time.Clock; +import java.time.Instant; +import java.time.LocalDate; +import java.time.temporal.ChronoUnit; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.UUID; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.core.annotation.Order; +import org.springframework.transaction.annotation.Transactional; + +@Order(2) +class DemoOperationalSeedRunner implements ApplicationRunner { + + private static final Logger LOGGER = LoggerFactory.getLogger(DemoOperationalSeedRunner.class); + private static final String CATALOG_VERSION = "0.2.0"; + private static final List TASKS = List.of( + demoTask( + "94000000-0000-0000-0000-000000000001", + "94100000-0000-0000-0000-000000000001", + "92000000-0000-0000-0000-000000000001", + TaskType.STAY_PERIOD_EXTENSION, + "WF-STY-001", + "AI 추천: 체류기간 연장 준비", + "체류기간 만료 전에 필요한 서류를 확인하고 연장 신청을 준비합니다.", + TaskSource.AI_CANDIDATE, + TaskStatus.DRAFT, + 3, + 6 + ), + demoTask( + "94000000-0000-0000-0000-000000000002", + "94100000-0000-0000-0000-000000000002", + "92000000-0000-0000-0000-000000000002", + TaskType.RECONTRACT, + "WF-CON-001", + "근로계약 갱신 검토", + "갱신 계약 조건과 제출 서류를 검토하고 승인을 기다립니다.", + TaskSource.MANUAL, + TaskStatus.READY_FOR_REVIEW, + 20, + 5 + ), + demoTask( + "94000000-0000-0000-0000-000000000003", + "94100000-0000-0000-0000-000000000003", + "92000000-0000-0000-0000-000000000003", + TaskType.STAY_PERIOD_EXTENSION, + "WF-STY-001", + "여권 사본 제출 대기", + "근로자에게 최신 여권 사본을 요청했으며 응답을 기다립니다.", + TaskSource.MANUAL, + TaskStatus.WAITING_WORKER, + 6, + 4 + ), + demoTask( + "94000000-0000-0000-0000-000000000004", + "94100000-0000-0000-0000-000000000004", + "92000000-0000-0000-0000-000000000004", + TaskType.EMPLOYMENT_PERIOD_EXTENSION, + "WF-CON-001", + "고용허가기간 연장 결과 대기", + "관할 기관에 제출을 완료하고 처리 결과를 기다립니다.", + TaskSource.MANUAL, + TaskStatus.WAITING_EXTERNAL, + 90, + 12 + ), + demoTask( + "94000000-0000-0000-0000-000000000005", + "94100000-0000-0000-0000-000000000005", + "92000000-0000-0000-0000-000000000005", + TaskType.RECONTRACT, + "WF-CON-001", + "근로계약 갱신 완료", + "서명된 근로계약서 확인과 갱신 처리를 완료했습니다.", + TaskSource.SYSTEM_DDAY, + TaskStatus.COMPLETED, + 30, + 10 + ) + ); + private static final List DOCUMENTS = List.of( + demoDocument( + "95000000-0000-0000-0000-000000000001", + "92000000-0000-0000-0000-000000000001", + DocumentType.PASSPORT_COPY, + SubmissionStatus.MISSING, + 25, + "체류기간 연장", + "만료 임박 여권 사본을 다시 받아야 합니다." + ), + demoDocument( + "95000000-0000-0000-0000-000000000002", + "92000000-0000-0000-0000-000000000001", + DocumentType.ARC, + SubmissionStatus.VERIFIED, + 180, + "체류기간 연장", + "외국인등록증 사본 확인 완료" + ), + demoDocument( + "95000000-0000-0000-0000-000000000003", + "92000000-0000-0000-0000-000000000002", + DocumentType.CONTRACT, + SubmissionStatus.SUBMITTED, + 90, + "근로계약 갱신", + "서명본 검토 대기" + ), + demoDocument( + "95000000-0000-0000-0000-000000000004", + "92000000-0000-0000-0000-000000000002", + DocumentType.PERMIT, + SubmissionStatus.VERIFIED, + 150, + "고용허가기간 연장", + "고용허가서 확인 완료" + ), + demoDocument( + "95000000-0000-0000-0000-000000000005", + "92000000-0000-0000-0000-000000000003", + DocumentType.PASSPORT_COPY, + SubmissionStatus.VERIFIED, + 300, + "체류기간 연장", + "유효한 여권 사본" + ), + demoDocument( + "95000000-0000-0000-0000-000000000006", + "92000000-0000-0000-0000-000000000004", + DocumentType.ARC, + SubmissionStatus.MISSING, + 45, + "고용허가기간 연장", + "외국인등록증 사본 요청 필요" + ), + demoDocument( + "95000000-0000-0000-0000-000000000007", + "92000000-0000-0000-0000-000000000005", + DocumentType.CONTRACT, + SubmissionStatus.SUBMITTED, + 120, + "근로계약 갱신", + "갱신 계약서 제출 완료" + ) + ); + private static final UUID TIMELINE_TASK_ID = + UUID.fromString("94000000-0000-0000-0000-000000000002"); + private static final List AUDITS = List.of( + demoAudit( + "96000000-0000-0000-0000-000000000001", + AuditAction.TASK_CREATED, + "demo-seed-task-created", + "업무가 생성되었습니다.", + 48 + ), + demoAudit( + "96000000-0000-0000-0000-000000000002", + AuditAction.TASK_UPDATED, + "demo-seed-task-updated", + "계약 갱신 정보와 마감일이 확인되었습니다.", + 24 + ), + demoAudit( + "96000000-0000-0000-0000-000000000003", + AuditAction.APPROVAL_REQUESTED, + "demo-seed-approval-requested", + "관리자 승인을 요청했습니다.", + 6 + ) + ); + + private final DemoAuthSeedProperties properties; + private final TaskRepository taskRepository; + private final TaskContentCodec taskContentCodec; + private final WorkerDocumentRepository workerDocumentRepository; + private final AuditEventRepository auditEventRepository; + private final Clock clock; + + DemoOperationalSeedRunner( + DemoAuthSeedProperties properties, + TaskRepository taskRepository, + TaskContentCodec taskContentCodec, + WorkerDocumentRepository workerDocumentRepository, + AuditEventRepository auditEventRepository, + Clock clock + ) { + this.properties = Objects.requireNonNull(properties, "properties must not be null"); + this.taskRepository = Objects.requireNonNull(taskRepository, "taskRepository must not be null"); + this.taskContentCodec = Objects.requireNonNull(taskContentCodec, "taskContentCodec must not be null"); + this.workerDocumentRepository = Objects.requireNonNull( + workerDocumentRepository, + "workerDocumentRepository must not be null" + ); + this.auditEventRepository = Objects.requireNonNull( + auditEventRepository, + "auditEventRepository must not be null" + ); + this.clock = Objects.requireNonNull(clock, "clock must not be null"); + } + + @Override + @Transactional + public void run(ApplicationArguments arguments) { + Instant now = clock.instant(); + LocalDate today = LocalDate.now(clock); + TASKS.forEach(task -> seedTask(task, today, now)); + DOCUMENTS.forEach(document -> seedDocument(document, today, now)); + AUDITS.forEach(audit -> seedAudit(audit, now)); + LOGGER.info( + "demo_operational_seed ready company_id={} task_count={} document_count={} audit_count={}", + properties.companyId(), + TASKS.size(), + DOCUMENTS.size(), + AUDITS.size() + ); + } + + private void seedTask(DemoTask seed, LocalDate today, Instant now) { + LocalDate dueDate = today.plusDays(seed.dueDays()); + EncodedTaskContent content = taskContentCodec.encode( + seed.workerId(), + seed.workflowId(), + seed.taskType().name(), + seed.title(), + seed.description(), + dueDate, + Map.of("worker_id", seed.workerId().toString(), "due_at", dueDate.toString()) + ); + Optional existing = taskRepository.findByIdAndCompanyId( + seed.taskId(), + properties.companyId() + ); + existing.ifPresent(task -> verifyExistingTask(task, seed)); + Instant createdAt = existing.map(Task::createdAt) + .orElseGet(() -> now.minus(seed.createdDaysAgo(), ChronoUnit.DAYS)); + Instant desiredUpdatedAt = seed.status() == TaskStatus.COMPLETED + ? now + : now.minus(Math.max(seed.createdDaysAgo() - 1, 0), ChronoUnit.DAYS); + Instant updatedAt = desiredUpdatedAt.isBefore(createdAt) ? createdAt : desiredUpdatedAt; + long version = existing.map(Task::version).orElse(0L); + taskRepository.save(new Task( + seed.taskId(), + properties.companyId(), + seed.workerId(), + seed.caseId(), + seed.taskType(), + seed.workflowId(), + CATALOG_VERSION, + seed.title(), + seed.description(), + content.businessDataJson(), + content.criticalFingerprint(), + 0L, + seed.source(), + seed.status(), + dueDate, + properties.adminUserId(), + properties.adminUserId(), + createdAt, + updatedAt, + version + )); + } + + private void verifyExistingTask(Task task, DemoTask seed) { + if (!seed.taskId().equals(task.taskId()) + || !properties.companyId().equals(task.companyId()) + || !seed.workerId().equals(task.workerId()) + || !seed.caseId().equals(task.caseId()) + || seed.taskType() != task.taskType() + || !seed.workflowId().equals(task.workflowId()) + || !CATALOG_VERSION.equals(task.workflowCatalogVersion()) + || !seed.title().equals(task.title()) + || !seed.description().equals(task.description()) + || seed.source() != task.source() + || seed.status() != task.status() + || task.contentRevision() != 0L + || !properties.adminUserId().equals(task.createdBy()) + || !properties.adminUserId().equals(task.updatedBy())) { + throw new IllegalStateException( + "a reserved demo task id already belongs to different task data" + ); + } + } + + private void seedDocument(DemoDocument seed, LocalDate today, Instant now) { + LocalDate expiryDate = today.plusDays(seed.expiryDays()); + Optional existing = + workerDocumentRepository.findByIdAndWorkerIdAndCompanyId( + seed.documentId(), + seed.workerId(), + properties.companyId() + ); + existing.ifPresent(document -> verifyExistingDocument(document, seed)); + if (existing.isEmpty()) { + workerDocumentRepository.insert(WorkerDocument.create( + seed.documentId(), + seed.workerId(), + properties.companyId(), + seed.documentType(), + seed.submissionStatus(), + expiryDate, + seed.destination(), + seed.note(), + now + )); + return; + } + WorkerDocument document = existing.orElseThrow(); + workerDocumentRepository.update(new WorkerDocument( + document.workerDocumentId(), + document.workerId(), + document.companyId(), + document.documentType(), + document.submissionStatus(), + expiryDate, + document.destination(), + document.note(), + null, + document.createdAt(), + now, + document.version() + )); + } + + private void verifyExistingDocument(WorkerDocument document, DemoDocument seed) { + if (!seed.documentId().equals(document.workerDocumentId()) + || !seed.workerId().equals(document.workerId()) + || !properties.companyId().equals(document.companyId()) + || seed.documentType() != document.documentType() + || seed.submissionStatus() != document.submissionStatus() + || !Objects.equals(seed.destination(), document.destination()) + || !Objects.equals(seed.note(), document.note()) + || document.fileId() != null) { + throw new IllegalStateException( + "a reserved demo worker document id already belongs to different document data" + ); + } + } + + private void seedAudit(DemoAudit seed, Instant now) { + Optional existing = auditEventRepository + .findTaskActivities(properties.companyId(), TIMELINE_TASK_ID) + .stream() + .filter(event -> seed.auditEventId().equals(event.auditEventId())) + .findFirst(); + if (existing.isPresent()) { + verifyExistingAudit(existing.orElseThrow(), seed); + return; + } + auditEventRepository.append(new AuditEvent( + seed.auditEventId(), + properties.companyId(), + ActorType.HR_USER, + properties.adminUserId(), + UserRole.ADMIN, + seed.action(), + AuditTargetType.TASK, + TIMELINE_TASK_ID, + seed.requestId(), + "demo-seed-task-timeline", + "1", + seed.changeSummary(), + now.minus(seed.hoursAgo(), ChronoUnit.HOURS) + )); + } + + private void verifyExistingAudit(AuditEvent event, DemoAudit seed) { + if (!properties.companyId().equals(event.companyId()) + || event.actorType() != ActorType.HR_USER + || !properties.adminUserId().equals(event.actorId()) + || event.userRole() != UserRole.ADMIN + || event.action() != seed.action() + || event.targetType() != AuditTargetType.TASK + || !TIMELINE_TASK_ID.equals(event.targetId()) + || !seed.requestId().equals(event.requestId()) + || !seed.changeSummary().equals(event.changeSummary())) { + throw new IllegalStateException( + "a reserved demo audit event id already belongs to different audit data" + ); + } + } + + private static DemoTask demoTask( + String taskId, + String caseId, + String workerId, + TaskType taskType, + String workflowId, + String title, + String description, + TaskSource source, + TaskStatus status, + int dueDays, + int createdDaysAgo + ) { + return new DemoTask( + UUID.fromString(taskId), + UUID.fromString(caseId), + UUID.fromString(workerId), + taskType, + workflowId, + title, + description, + source, + status, + dueDays, + createdDaysAgo + ); + } + + private static DemoDocument demoDocument( + String documentId, + String workerId, + DocumentType documentType, + SubmissionStatus submissionStatus, + int expiryDays, + String destination, + String note + ) { + return new DemoDocument( + UUID.fromString(documentId), + UUID.fromString(workerId), + documentType, + submissionStatus, + expiryDays, + destination, + note + ); + } + + private static DemoAudit demoAudit( + String auditEventId, + AuditAction action, + String requestId, + String changeSummary, + int hoursAgo + ) { + return new DemoAudit( + UUID.fromString(auditEventId), + action, + requestId, + changeSummary, + hoursAgo + ); + } + + private record DemoTask( + UUID taskId, + UUID caseId, + UUID workerId, + TaskType taskType, + String workflowId, + String title, + String description, + TaskSource source, + TaskStatus status, + int dueDays, + int createdDaysAgo + ) { + } + + private record DemoDocument( + UUID documentId, + UUID workerId, + DocumentType documentType, + SubmissionStatus submissionStatus, + int expiryDays, + String destination, + String note + ) { + } + + private record DemoAudit( + UUID auditEventId, + AuditAction action, + String requestId, + String changeSummary, + int hoursAgo + ) { + } +} diff --git a/src/test/java/com/fowoco/server/auth/infrastructure/seed/DemoAuthSeedIntegrationTest.java b/src/test/java/com/fowoco/server/auth/infrastructure/seed/DemoAuthSeedIntegrationTest.java index 4f00326..fe36bf1 100644 --- a/src/test/java/com/fowoco/server/auth/infrastructure/seed/DemoAuthSeedIntegrationTest.java +++ b/src/test/java/com/fowoco/server/auth/infrastructure/seed/DemoAuthSeedIntegrationTest.java @@ -2,9 +2,17 @@ import static org.assertj.core.api.Assertions.assertThat; +import java.time.Clock; +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneOffset; +import java.util.List; import java.util.UUID; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.ApplicationRunner; +import org.springframework.boot.DefaultApplicationArguments; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.security.crypto.password.PasswordEncoder; @@ -28,8 +36,18 @@ class DemoAuthSeedIntegrationTest { @Autowired private PasswordEncoder passwordEncoder; + @Autowired + private Clock clock; + + @Autowired + @Qualifier("demoOperationalSeedRunner") + private ApplicationRunner demoOperationalSeedRunner; + @Test - void enabledSeedCreatesAnActiveCompanyAndHashedAdminAfterFlyway() { + void enabledSeedCreatesIdempotentCompanyAccountWorkerAndOperationalDataAfterFlyway() + throws Exception { + demoOperationalSeedRunner.run(new DefaultApplicationArguments(new String[0])); + String passwordHash = jdbcTemplate.queryForObject( "SELECT password_hash FROM user_account WHERE user_id = ? AND company_id = ?", String.class, @@ -106,5 +124,79 @@ void enabledSeedCreatesAnActiveCompanyAndHashedAdminAfterFlyway() { Integer.class, TEST_COMPANY_ID )).isEqualTo(5); + assertThat(jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM task WHERE company_id = ?", + Integer.class, + COMPANY_ID + )).isEqualTo(5); + assertThat(jdbcTemplate.queryForList( + "SELECT status FROM task WHERE company_id = ?", + String.class, + COMPANY_ID + )).containsExactlyInAnyOrder( + "DRAFT", + "READY_FOR_REVIEW", + "WAITING_WORKER", + "WAITING_EXTERNAL", + "COMPLETED" + ); + assertThat(jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM task WHERE company_id = ? AND source = 'AI_CANDIDATE' " + + "AND status = 'DRAFT'", + Integer.class, + COMPANY_ID + )).isEqualTo(1); + List completedUpdates = jdbcTemplate.query( + "SELECT updated_at FROM task WHERE company_id = ? AND status = 'COMPLETED'", + (resultSet, rowNumber) -> resultSet.getTimestamp("updated_at").toInstant(), + COMPANY_ID + ); + assertThat(completedUpdates).hasSize(1).allMatch(updatedAt -> + LocalDate.ofInstant(updatedAt, ZoneOffset.UTC).equals(LocalDate.now(clock))); + List dueDates = jdbcTemplate.query( + "SELECT due_date FROM task WHERE company_id = ?", + (resultSet, rowNumber) -> resultSet.getObject("due_date", LocalDate.class), + COMPANY_ID + ); + LocalDate today = LocalDate.now(clock); + assertThat(dueDates).anyMatch(date -> !date.isAfter(today.plusDays(7))); + assertThat(dueDates).anyMatch(date -> + date.isAfter(today.plusDays(7)) && !date.isAfter(today.plusDays(30))); + assertThat(dueDates).anyMatch(date -> date.isAfter(today.plusDays(30))); + assertThat(jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM worker_document WHERE company_id = ?", + Integer.class, + COMPANY_ID + )).isEqualTo(7); + assertThat(jdbcTemplate.queryForList( + "SELECT submission_status FROM worker_document WHERE company_id = ?", + String.class, + COMPANY_ID + )).contains("MISSING", "SUBMITTED", "VERIFIED"); + List expiryDates = jdbcTemplate.query( + "SELECT expiry_date FROM worker_document WHERE company_id = ?", + (resultSet, rowNumber) -> resultSet.getObject("expiry_date", LocalDate.class), + COMPANY_ID + ); + assertThat(expiryDates).anyMatch(date -> + !date.isBefore(today) && !date.isAfter(today.plusDays(30))); + assertThat(jdbcTemplate.queryForList( + "SELECT COUNT(*) FROM worker_document WHERE company_id = ? GROUP BY worker_id", + Integer.class, + COMPANY_ID + )).hasSize(5).allMatch(count -> count >= 1 && count <= 2); + UUID timelineTaskId = UUID.fromString("94000000-0000-0000-0000-000000000002"); + assertThat(jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM audit_event WHERE company_id = ? AND target_id = ?", + Integer.class, + COMPANY_ID, + timelineTaskId + )).isEqualTo(3); + assertThat(jdbcTemplate.queryForList( + "SELECT action FROM audit_event WHERE company_id = ? AND target_id = ?", + String.class, + COMPANY_ID, + timelineTaskId + )).containsExactlyInAnyOrder("TASK_CREATED", "TASK_UPDATED", "APPROVAL_REQUESTED"); } } From f7c962773f8c02f1f4f706e702614c410cd452c0 Mon Sep 17 00:00:00 2001 From: krestar Date: Fri, 31 Jul 2026 13:32:27 +0900 Subject: [PATCH 3/3] =?UTF-8?q?fix:=20PostgreSQL=20Task=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=20=EC=8B=9C=20null=20=EA=B2=80?= =?UTF-8?q?=EC=83=89=EC=96=B4=20=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 검색어가 없을 때 keyword 파라미터를 문자열로 명시적으로 캐스팅한다. - PostgreSQL에서 발생하던 lower(bytea) 오류를 방지한다. - Task 목록 조회가 500 응답을 반환하던 문제를 수정한다. --- .../persistence/SpringDataTaskJpaRepository.java | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/fowoco/server/task/infrastructure/persistence/SpringDataTaskJpaRepository.java b/src/main/java/com/fowoco/server/task/infrastructure/persistence/SpringDataTaskJpaRepository.java index 029471b..c736809 100644 --- a/src/main/java/com/fowoco/server/task/infrastructure/persistence/SpringDataTaskJpaRepository.java +++ b/src/main/java/com/fowoco/server/task/infrastructure/persistence/SpringDataTaskJpaRepository.java @@ -26,8 +26,9 @@ interface SpringDataTaskJpaRepository extends JpaRepository AND (:dueTo IS NULL OR task.dueDate <= :dueTo) AND ( :keyword IS NULL - OR LOWER(task.title) LIKE LOWER(CONCAT('%', :keyword, '%')) - OR LOWER(COALESCE(task.description, '')) LIKE LOWER(CONCAT('%', :keyword, '%')) + OR LOWER(task.title) LIKE LOWER(CONCAT('%', CAST(:keyword AS string), '%')) + OR LOWER(COALESCE(task.description, '')) + LIKE LOWER(CONCAT('%', CAST(:keyword AS string), '%')) ) """) Page search(