이호영 이호영 07-28
feat: 목록접수 자동 전환에 핀 고정 조건 추가, 공공누리 유형 표기 통일
[18] 목록접수 조건이 자료에 적힌 것과 달랐다.
     자료  : 「목록접수 = Mattermost에 첫 자료 업로드 + 핀 고정 시」
     구현  : 파일이 올라가기만 하면 넘어감 (핀 고정 조건 누락)

공지 고정이 끝난 뒤에 판단하도록 옮겼다. 자료만 올리고 고정하지 않은 경우, 자료 없이
공지만 올린 경우, 고정에 실패한 경우 모두 넘어가지 않는다. 다섯 갈래를 테스트로 잠갔다.

[통일] 공공누리 유형 표기를 「N유형」으로 맞춘다.
     조사표에 「제4유형」으로 적혀 온 자료가 68건 있었는데 나머지는 모두 「0~4유형」
     형태였다. 같은 유형인데 유형별 집계에서 따로 잡히던 문제다.

세 곳을 함께 손봤다.
  · 기존 데이터를 정규식으로 일괄 변환(V18). 뜻이 다른 「유형 확인 필요」는 건드리지 않는다
  · 「제4유형」을 선택지에서 제외
  · 업로드 시점에도 옛 표기를 표준 표기로 바꿔 넣는다 - 이게 없으면 기관이 예전 파일을
    다시 올릴 때 되살아난다

「제4유형」 코드 행은 지우지 않고 비활성 별칭으로 남긴다. 업로드 변환이 이 행의 sameAs를
보고 동작하기 때문이다. 코드표가 별칭까지 관리하는 셈이라 앞으로 다른 표기가 발견돼도
데이터 한 줄로 흡수할 수 있다.

백엔드 288건 통과.

Co-Authored-By: Claude Opus 5 (1M context) 
@c2d02b486456e5a5d659de3ad7e8de7d6726cc0f
src/main/java/kr/itn/itnhub/code/CodeService.java
--- src/main/java/kr/itn/itnhub/code/CodeService.java
+++ src/main/java/kr/itn/itnhub/code/CodeService.java
@@ -108,6 +108,30 @@
         return group(groupId).stream().anyMatch(candidate -> candidate.code().equals(code));
     }
 
+    /**
+     * 옛 표기를 표준 표기로 바꿔 준다.
+     *
+     * <p>코드에 {@code sameAs}가 붙어 있으면 그 값으로 옮긴다. 예를 들어 기관 조사표에
+     * 「제4유형」으로 적혀 오는 값은 「4유형」으로 통일한다 - 같은 유형인데 표기가 갈리면
+     * 유형별로 세거나 걸러낼 때 따로 잡히기 때문이다.</p>
+     *
+     * <p>비활성 코드도 본다. 선택지에서는 빠졌어도 옛 표기가 담긴 파일이 다시 올라올 수
+     * 있고, 그때 바꿔 넣어야 하기 때문이다.</p>
+     *
+     * @return 표준 표기. 아는 별칭이 아니면 받은 값을 그대로 돌려준다.
+     */
+    public String canonical(String groupId, String code) {
+        if (code == null || code.isBlank()) {
+            return code;
+        }
+        return find(groupId, code)
+                .map(found -> found.attr(ATTR_SAME_AS))
+                .filter(target -> target != null && !target.isBlank())
+                .orElse(code);
+    }
+
+    private static final String ATTR_SAME_AS = "sameAs";
+
     /** 코드 관리 화면이 쓰는 그룹 메타. 캐시하지 않는다 - 호출 빈도가 낮다. */
     public List<CodeGroup> groups() {
         return mapper.findAllGroups();
src/main/java/kr/itn/itnhub/feed/ChannelFeedService.java
--- src/main/java/kr/itn/itnhub/feed/ChannelFeedService.java
+++ src/main/java/kr/itn/itnhub/feed/ChannelFeedService.java
@@ -72,13 +72,6 @@
         String postId = mattermost.createPost(channelId, message, fileIds);
         PostView post = fetchPost(channelId, postId, message);
 
-        // 요구사항 [18]: 채널에 자료가 올라가면 ②목록접수로 저절로 넘어간다.
-        // 파일이 실린 게시글만 계기로 본다 - 그냥 대화만 오간 것은 자료 접수가 아니다.
-        if (!fileIds.isEmpty()) {
-            stageService.autoAdvance(orgId, kr.itn.itnhub.stage.AutoStage.LIST_RECEIVED,
-                    kr.itn.itnhub.stage.AutoStage.REASON_LIST_RECEIVED);
-        }
-
         if (!notice) {
             return new SendResult(post, null);
         }
@@ -89,8 +82,18 @@
             }
             mattermost.pinPost(postId);
             mattermost.updateChannelHeader(channelId, noticeHeader(message, files, postId));
+
+            // 요구사항 [18]: 「목록접수 = Mattermost에 첫 자료 업로드 + 핀 고정 시」.
+            // 자료가 실려 있고 공지로 고정까지 된 경우에만 넘긴다 - 그냥 파일만 주고받은 것은
+            // 접수가 아니고, 자료 없는 공지도 접수가 아니다. 핀 고정이 끝난 뒤에 판단해야
+            // "고정까지 됐다"는 조건이 실제로 지켜진다.
+            if (!fileIds.isEmpty()) {
+                stageService.autoAdvance(orgId, kr.itn.itnhub.stage.AutoStage.LIST_RECEIVED,
+                        kr.itn.itnhub.stage.AutoStage.REASON_LIST_RECEIVED);
+            }
             return new SendResult(post, null);
         } catch (MattermostException e) {
+            // 고정에 실패했으면 단계도 넘기지 않는다.
             return new SendResult(post, "공지 등록 처리 중 일부가 실패했습니다: " + e.getMessage());
         }
     }
src/main/java/kr/itn/itnhub/process/ProcessImportService.java
--- src/main/java/kr/itn/itnhub/process/ProcessImportService.java
+++ src/main/java/kr/itn/itnhub/process/ProcessImportService.java
@@ -18,16 +18,24 @@
     private final ProcessItemMapper mapper;
     private final OrganizationMapper orgMapper;
     private final kr.itn.itnhub.stage.StageService stageService;
+    private final kr.itn.itnhub.code.CodeService codeService;
     private final TransactionTemplate transactionTemplate;
 
     public ProcessImportService(ProcessParser parser, ProcessItemMapper mapper, OrganizationMapper orgMapper,
                                  kr.itn.itnhub.stage.StageService stageService,
+                                 kr.itn.itnhub.code.CodeService codeService,
                                  PlatformTransactionManager transactionManager) {
         this.parser = parser;
         this.mapper = mapper;
         this.orgMapper = orgMapper;
         this.stageService = stageService;
+        this.codeService = codeService;
         this.transactionTemplate = new TransactionTemplate(transactionManager);
+    }
+
+    /** 공공누리 유형의 옛 표기를 표준 표기로 맞춘다(제4유형 → 4유형). */
+    private String canonicalKogl(String value) {
+        return codeService.canonical(kr.itn.itnhub.code.Codes.KOGL_TYPE, value);
     }
 
     /**
@@ -78,19 +86,20 @@
         item.setUrl(row.url());
         item.setDescription(row.description());
         item.setHasAttachment(row.hasAttachment());
-        item.setPriorKoglType(row.priorKoglType());
+        // 옛 표기(제4유형)로 적혀 온 값은 표준 표기(4유형)로 맞춰 넣는다.
+        item.setPriorKoglType(canonicalKogl(row.priorKoglType()));
         item.setContractDocs(row.contractDocs());
         item.setProducedDate(row.producedDate());
         item.setPublishedDate(row.publishedDate());
         item.setReviewMajor(row.reviewMajor());
         item.setReviewMinor(row.reviewMinor());
         item.setReviewResult(row.reviewResult());
-        item.setReviewKoglType(row.reviewKoglType());
+        item.setReviewKoglType(canonicalKogl(row.reviewKoglType()));
         item.setReviewAiType(row.reviewAiType());
         item.setReviewOpinion(row.reviewOpinion());
         item.setReviewNote(row.reviewNote());
         item.setPriorEvidence(row.priorEvidence());
-        item.setJudgedKoglType(row.judgedKoglType());
+        item.setJudgedKoglType(canonicalKogl(row.judgedKoglType()));
         item.setJudgedAiType(row.judgedAiType());
         item.setFinalOpinion(row.finalOpinion());
         item.setJudgmentBasis(row.judgmentBasis());
src/main/java/kr/itn/itnhub/review/ReviewImportService.java
--- src/main/java/kr/itn/itnhub/review/ReviewImportService.java
+++ src/main/java/kr/itn/itnhub/review/ReviewImportService.java
@@ -18,16 +18,24 @@
     private final ReviewItemMapper mapper;
     private final OrganizationMapper orgMapper;
     private final kr.itn.itnhub.stage.StageService stageService;
+    private final kr.itn.itnhub.code.CodeService codeService;
     private final TransactionTemplate transactionTemplate;
 
     public ReviewImportService(ReviewParser parser, ReviewItemMapper mapper, OrganizationMapper orgMapper,
                                 kr.itn.itnhub.stage.StageService stageService,
+                                kr.itn.itnhub.code.CodeService codeService,
                                 PlatformTransactionManager transactionManager) {
         this.parser = parser;
         this.mapper = mapper;
         this.orgMapper = orgMapper;
         this.stageService = stageService;
+        this.codeService = codeService;
         this.transactionTemplate = new TransactionTemplate(transactionManager);
+    }
+
+    /** 공공누리 유형의 옛 표기를 표준 표기로 맞춘다(제4유형 → 4유형). */
+    private String canonicalKogl(String value) {
+        return codeService.canonical(kr.itn.itnhub.code.Codes.KOGL_TYPE, value);
     }
 
     /**
@@ -82,14 +90,15 @@
         item.setPublishedDate(row.publishedDate());
         item.setHasAttachment(row.hasAttachment());
         item.setKoglAttached(row.koglAttached());
-        item.setKoglType(row.koglType());
+        // 옛 표기(제4유형)로 적혀 온 값은 표준 표기(4유형)로 맞춰 넣는다.
+        item.setKoglType(canonicalKogl(row.koglType()));
         item.setAiType(row.aiType());
         item.setSurveyorNote(row.surveyorNote());
         item.setOpenable(row.openable());
         item.setReviewMajor(row.reviewMajor());
         item.setReviewMinor(row.reviewMinor());
         item.setReviewResult(row.reviewResult());
-        item.setJudgedKoglType(row.judgedKoglType());
+        item.setJudgedKoglType(canonicalKogl(row.judgedKoglType()));
         item.setJudgedAiType(row.judgedAiType());
         item.setOpinion(row.opinion());
         item.setLawyerNote(row.lawyerNote());
src/main/java/kr/itn/itnhub/stage/AutoStage.java
--- src/main/java/kr/itn/itnhub/stage/AutoStage.java
+++ src/main/java/kr/itn/itnhub/stage/AutoStage.java
@@ -11,9 +11,9 @@
     private AutoStage() {
     }
 
-    /** ②목록접수 — 기관 채널에 첫 자료가 올라갔을 때. */
+    /** ②목록접수 — 기관 채널에 자료가 올라가고 공지로 고정까지 됐을 때. */
     public static final int LIST_RECEIVED = 2;
-    public static final String REASON_LIST_RECEIVED = "채널에 자료 업로드";
+    public static final String REASON_LIST_RECEIVED = "채널에 자료 업로드 + 공지 고정";
 
     /** ⑤법률검토(권리확인) — 권리확인 자료가 올라갔을 때. */
     public static final int REVIEW_STARTED = 5;
 
src/main/resources/db/migration/V18__normalize_kogl_type.sql (added)
+++ src/main/resources/db/migration/V18__normalize_kogl_type.sql
@@ -0,0 +1,35 @@
+-- 공공누리 유형 표기를 「N유형」으로 통일한다.
+--
+-- 기관 조사표에 「제4유형」으로 적혀 온 자료가 68건 있었다. 나머지 자료와 코드표는 전부
+-- 「0유형 ~ 4유형」 형태라 같은 유형인데도 다른 값으로 잡혔다. 유형별로 세거나 걸러낼 때
+-- 둘이 따로 집계되므로 표기를 맞춘다.
+--
+-- '제숫자유형' 형태만 바꾼다. 「유형 확인 필요」처럼 뜻이 다른 값은 건드리지 않는다.
+
+update review_item
+   set kogl_type = regexp_replace(kogl_type, '^제([0-9]+)유형$', '\1유형')
+ where kogl_type ~ '^제[0-9]+유형$';
+
+update review_item
+   set judged_kogl_type = regexp_replace(judged_kogl_type, '^제([0-9]+)유형$', '\1유형')
+ where judged_kogl_type ~ '^제[0-9]+유형$';
+
+update process_item
+   set prior_kogl_type = regexp_replace(prior_kogl_type, '^제([0-9]+)유형$', '\1유형')
+ where prior_kogl_type ~ '^제[0-9]+유형$';
+
+update process_item
+   set review_kogl_type = regexp_replace(review_kogl_type, '^제([0-9]+)유형$', '\1유형')
+ where review_kogl_type ~ '^제[0-9]+유형$';
+
+update process_item
+   set judged_kogl_type = regexp_replace(judged_kogl_type, '^제([0-9]+)유형$', '\1유형')
+ where judged_kogl_type ~ '^제[0-9]+유형$';
+
+-- 「제4유형」은 이제 선택지에서 뺀다. 다만 행을 지우지는 않는다 -
+-- 기관이 예전 표기 그대로 엑셀을 다시 올릴 수 있고, 그때 sameAs를 보고 「4유형」으로
+-- 바꿔 넣어야 하기 때문이다(별칭 역할로 남긴다).
+update code
+   set active = false,
+       label = '제4유형 (옛 표기 → 4유형)'
+ where group_id = 'KOGL_TYPE' and code = '제4유형';
src/test/java/kr/itn/itnhub/code/CodeControllerTest.java
--- src/test/java/kr/itn/itnhub/code/CodeControllerTest.java
+++ src/test/java/kr/itn/itnhub/code/CodeControllerTest.java
@@ -31,8 +31,9 @@
                 .andExpect(jsonPath("$.STAGE[12].code").value("13"))
                 .andExpect(jsonPath("$.STAGE[12].label").value("보고서 제출 완료"))
                 .andExpect(jsonPath("$.STAGE[12].attrs.progress").value("done"))
-                // 0~4유형 + 개방불가 + 보류 + 조사표에서 온 2개(유형 확인 필요, 제4유형)
-                .andExpect(jsonPath("$.KOGL_TYPE.length()").value(9))
+                // 0~4유형 + 개방불가 + 보류 + 조사표에서 온 '유형 확인 필요'.
+                // '제4유형'은 '4유형'으로 통일해 선택지에서 빠졌다(V18).
+                .andExpect(jsonPath("$.KOGL_TYPE.length()").value(8))
                 .andExpect(jsonPath("$.CONTACT_CATEGORY[0].code").value("APPLICANT"))
                 .andExpect(jsonPath("$.CONTACT_CATEGORY[0].label").value("신청기관"));
     }
src/test/java/kr/itn/itnhub/code/CodeSeedTest.java
--- src/test/java/kr/itn/itnhub/code/CodeSeedTest.java
+++ src/test/java/kr/itn/itnhub/code/CodeSeedTest.java
@@ -74,16 +74,32 @@
     @Test
     void 공공누리_유형은_사용처별로_다르게_노출된다() {
         // 세 화면의 목록이 7개/5개/6개로 달랐던 이유는 쓰이는 자리가 달라서다.
-        assertThat(koglScope("review")).hasSize(7).doesNotContain("유형 확인 필요");
+        assertThat(koglScope("review")).hasSize(7)
+                .doesNotContain("유형 확인 필요")
+                .doesNotContain("제4유형");
         assertThat(koglScope("process")).hasSize(6).doesNotContain("보류");
     }
 
     @Test
     void 기존_부착유형에는_조사표에서_온_값도_들어간다() {
-        // 운영 DB 실측(2026-07-28): kogl_type에 '유형 확인 필요' 1,858건, '제4유형' 68건이
-        // 저장돼 있었다. 코드로 등록해 두지 않으면 상세 화면에서 선택 안 됨으로 보인다.
+        // 운영 DB 실측(2026-07-28): kogl_type에 '유형 확인 필요' 1,858건이 저장돼 있었다.
+        // 코드로 등록해 두지 않으면 상세 화면에서 선택 안 됨으로 보인다.
+        // '제4유형'은 '4유형'으로 통일했으므로 선택지에서는 빠진다(V18).
         assertThat(koglScope("prior")).containsExactly(
-                "0유형", "1유형", "2유형", "3유형", "4유형", "유형 확인 필요", "제4유형");
+                "0유형", "1유형", "2유형", "3유형", "4유형", "유형 확인 필요");
+    }
+
+    /**
+     * 「제4유형」은 지웠으면 안 된다. 기관이 옛 표기 그대로 엑셀을 다시 올릴 수 있고,
+     * 그때 sameAs를 보고 「4유형」으로 바꿔 넣어야 한다.
+     */
+    @Test
+    void 통일된_옛_표기는_별칭으로_남아_있다() {
+        String active = jdbc.queryForObject(
+                "select active::text from code where group_id = 'KOGL_TYPE' and code = '제4유형'",
+                String.class);
+        assertThat(active).isEqualTo("false");
+        assertThat(attr("KOGL_TYPE", "제4유형", "sameAs")).isEqualTo("4유형");
     }
 
     @Test
@@ -99,9 +115,10 @@
         assertThat(leaked).isZero();
     }
 
+    /** 화면 선택지에 실제로 나가는 것만 본다. 서버는 활성 코드만 내려준다. */
     private List<String> koglScope(String scope) {
         return jdbc.queryForList(
-                "select code from code c where c.group_id = 'KOGL_TYPE' "
+                "select code from code c where c.group_id = 'KOGL_TYPE' and c.active "
                         + "  and exists (select 1 from jsonb_array_elements_text(c.attrs->'scopes') s(v) "
                         + "    where s.v = ?) "
                         + "order by sort_order",
src/test/java/kr/itn/itnhub/code/CodeServiceTest.java
--- src/test/java/kr/itn/itnhub/code/CodeServiceTest.java
+++ src/test/java/kr/itn/itnhub/code/CodeServiceTest.java
@@ -58,12 +58,24 @@
         assertThat(service.scoped(Codes.KOGL_TYPE, Codes.SCOPE_PROCESS))
                 .hasSize(6)
                 .noneMatch(c -> c.code().equals("보류"));
-        // prior에는 기관 조사표에서 온 값('유형 확인 필요', '제4유형')까지 들어간다.
+        // prior에는 기관 조사표에서 온 값('유형 확인 필요')까지 들어간다.
         // 운영 DB에 실제로 저장돼 있는 값이라 등록해 두지 않으면 화면에서 선택 안 됨으로 보인다.
         assertThat(service.scoped(Codes.KOGL_TYPE, Codes.SCOPE_PRIOR))
                 .extracting(Code::code)
-                .containsExactly("0유형", "1유형", "2유형", "3유형", "4유형",
-                        "유형 확인 필요", "제4유형");
+                .containsExactly("0유형", "1유형", "2유형", "3유형", "4유형", "유형 확인 필요");
+    }
+
+    @Test
+    void 옛_표기를_표준_표기로_바꿔_준다() {
+        // 「제4유형」으로 적혀 온 값은 「4유형」으로 통일한다.
+        assertThat(service.canonical(Codes.KOGL_TYPE, "제4유형")).isEqualTo("4유형");
+        // 표준 표기는 그대로 둔다.
+        assertThat(service.canonical(Codes.KOGL_TYPE, "4유형")).isEqualTo("4유형");
+        // 뜻이 다른 값은 건드리지 않는다.
+        assertThat(service.canonical(Codes.KOGL_TYPE, "유형 확인 필요")).isEqualTo("유형 확인 필요");
+        // 모르는 값도 그대로 돌려준다.
+        assertThat(service.canonical(Codes.KOGL_TYPE, "이상한값")).isEqualTo("이상한값");
+        assertThat(service.canonical(Codes.KOGL_TYPE, null)).isNull();
     }
 
     @Test
 
src/test/java/kr/itn/itnhub/feed/ChannelUploadAutoStageTest.java (added)
+++ src/test/java/kr/itn/itnhub/feed/ChannelUploadAutoStageTest.java
@@ -0,0 +1,124 @@
+package kr.itn.itnhub.feed;
+
+import kr.itn.itnhub.AbstractDbTest;
+import kr.itn.itnhub.mattermost.MattermostClient;
+import kr.itn.itnhub.mattermost.MattermostException;
+import kr.itn.itnhub.org.Organization;
+import kr.itn.itnhub.org.OrganizationMapper;
+import kr.itn.itnhub.stage.AutoStage;
+import kr.itn.itnhub.stage.StageHistoryMapper;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.mock.mockito.MockBean;
+import org.springframework.jdbc.core.JdbcTemplate;
+
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.when;
+
+/**
+ * 「목록접수 = Mattermost에 첫 자료 업로드 + 핀 고정 시」(기능확인 PPT 14쪽, 요구사항 [18]).
+ *
+ * <p>조건이 둘이라는 점이 핵심이다. 자료만 올리고 공지로 고정하지 않았으면 접수가 아니고,
+ * 자료 없이 공지만 올린 것도 접수가 아니다.</p>
+ */
+class ChannelUploadAutoStageTest extends AbstractDbTest {
+
+    @Autowired
+    ChannelFeedService feedService;
+
+    @Autowired
+    OrganizationMapper orgMapper;
+
+    @Autowired
+    StageHistoryMapper historyMapper;
+
+    @Autowired
+    JdbcTemplate jdbc;
+
+    @MockBean
+    MattermostClient mattermost;
+
+    private Long orgId;
+
+    @BeforeEach
+    void setUp() {
+        jdbc.update("delete from stage_history");
+        jdbc.update("delete from organization where org_no = '940'");
+
+        Organization org = new Organization();
+        org.setOrgNo("940");
+        org.setOrgName("업로드전환테스트기관");
+        org.setChannelSlug("940");
+        orgMapper.upsertBySeed(org);
+        orgId = orgMapper.findByOrgNoAndOrgName("940", "업로드전환테스트기관").getId();
+        orgMapper.updateChannelIdMj(orgId, "chan-mj");
+        orgMapper.updateChannelIdLaw(orgId, "chan-law");
+
+        // 채널 생성으로 ①신청까지 와 있는 상태를 만든다.
+        orgMapper.updateStage(orgId, 1);
+        historyMapper.insert(orgId, 1, true, "채널 생성");
+
+        when(mattermost.uploadFile(anyString(), anyString(), any(), anyString()))
+                .thenReturn("file-1");
+        when(mattermost.createPost(anyString(), anyString(), any())).thenReturn("post-1");
+        when(mattermost.getRecentPosts(anyString(), any(Integer.class))).thenReturn(List.of());
+        when(mattermost.getPinnedPosts(anyString())).thenReturn(List.of());
+    }
+
+    private UploadedFile file() {
+        return new UploadedFile("목록.xlsx", new byte[]{1, 2, 3},
+                "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
+    }
+
+    private Integer stage() {
+        return orgMapper.findById(orgId).getStage();
+    }
+
+    @Test
+    void 자료를_공지로_고정해서_올리면_목록접수로_넘어간다() {
+        feedService.sendMessage(orgId, "mj", "목록 보냅니다", List.of(file()), true);
+
+        assertThat(stage()).isEqualTo(AutoStage.LIST_RECEIVED);
+        assertThat(historyMapper.findByOrg(orgId).get(0).isAuto()).isTrue();
+        assertThat(historyMapper.findByOrg(orgId).get(0).getReason())
+                .isEqualTo(AutoStage.REASON_LIST_RECEIVED);
+    }
+
+    @Test
+    void 자료만_올리고_고정하지_않으면_넘어가지_않는다() {
+        feedService.sendMessage(orgId, "mj", "그냥 파일 하나 드립니다", List.of(file()), false);
+
+        assertThat(stage()).isEqualTo(1);
+    }
+
+    @Test
+    void 자료_없이_공지만_올리면_넘어가지_않는다() {
+        feedService.sendMessage(orgId, "mj", "안내드립니다", List.of(), true);
+
+        assertThat(stage()).isEqualTo(1);
+    }
+
+    @Test
+    void 고정에_실패하면_단계도_넘기지_않는다() {
+        doThrow(new MattermostException("핀 고정 실패")).when(mattermost).pinPost(anyString());
+
+        feedService.sendMessage(orgId, "mj", "목록 보냅니다", List.of(file()), true);
+
+        assertThat(stage()).isEqualTo(1);
+    }
+
+    @Test
+    void 이미_더_앞선_단계면_뒤로_밀지_않는다() {
+        orgMapper.updateStage(orgId, 7);
+
+        feedService.sendMessage(orgId, "mj", "목록 보냅니다", List.of(file()), true);
+
+        assertThat(stage()).isEqualTo(7);
+    }
+}
Add a comment
List