diff --git a/docs/mvp-database-erd.md b/docs/mvp-database-erd.md index 4f819ca..27f6323 100644 --- a/docs/mvp-database-erd.md +++ b/docs/mvp-database-erd.md @@ -349,7 +349,7 @@ media_unit_region_columns·`V7` campaign_registration_schema와의 번호 충돌 **팀당 활성 코드 1개 제약 (DB 레벨 강제)** -새 초대 코드를 발급하면 기존에 살아있던 코드는 자동 폐기되어야 한다는 요구사항을, `team_members`의 활성 OWNER 제약과 동일한 패턴(generated column + unique index)으로 강제한다. +한 팀에 폐기되지 않은(`revoked_at IS NULL`) 코드가 동시에 2개 이상 존재할 수 없다는 요구사항을, `team_members`의 활성 OWNER 제약과 동일한 패턴(generated column + unique index)으로 강제한다. **아직 안 만료된 활성 코드가 있으면 그 코드를 그대로 재사용**하고(폐기·재발급 없음), **없거나 만료됐을 때만** 있던 코드를 폐기하고 새로 발급한다 — "팀원 초대하기" 버튼을 여러 번 눌러도 유효 기간(발급 시점 + 24시간) 안에는 같은 코드가 나온다. ```sql ALTER TABLE team_invite_links @@ -359,7 +359,7 @@ ALTER TABLE team_invite_links CREATE UNIQUE INDEX uk_invite_one_active_code_per_team ON team_invite_links(active_code_marker); ``` -새 코드를 발급하는 트랜잭션은 반드시 **① 기존 활성 코드에 `revoke()` 호출(`revoked_at` 설정) → ② 새 코드 INSERT** 순서로 처리해야 한다. 순서를 지키지 않으면(기존 코드를 안 지우고 새 코드부터 넣으면) 유니크 인덱스 위반으로 즉시 실패한다 — 즉 이 실수를 DB가 스스로 막아준다. +이 제약은 `active_code_marker`가 `revoked_at`만으로 계산되고 `expires_at`은 보지 않는다는 뜻이다 — 그래서 만료됐지만 아직 폐기되지 않은 코드가 있는 상태에서 새 코드를 발급하는 트랜잭션은 반드시 **① 기존(만료된) 코드에 `revoke()` 호출(`revoked_at` 설정) → ② 새 코드 INSERT** 순서로 처리해야 한다. 순서를 지키지 않으면(기존 코드를 안 지우고 새 코드부터 넣으면) 유니크 인덱스 위반으로 즉시 실패한다 — 즉 이 실수를 DB가 스스로 막아준다. (재사용 분기를 타는 경우는 revoke도 insert도 하지 않으므로 이 제약과 아예 무관하다.) 초대 처리 흐름: @@ -639,8 +639,9 @@ attention_count ### 팀원 초대 ```text -팀원(OWNER/ADMIN/MEMBER 누구나)이 초대 코드 생성 -→ 기존 활성 코드가 있으면 먼저 revoke, team_invite_links에 새 코드 저장(평문) +팀원(OWNER/ADMIN/MEMBER 누구나)이 초대 코드 생성 요청 +→ 활성 코드가 아직 안 만료됐으면 그대로 반환, 없거나 만료됐으면 (있는 경우 먼저 revoke 후) + team_invite_links에 새 코드 저장(평문) → 팀원이 초대 코드 입력 → 카카오 로그인 → team_members를 role=MEMBER로 생성 (또는 재가입 시 기존 행 UPDATE) diff --git a/docs/team-creation-api-spec.md b/docs/team-creation-api-spec.md index 4cb96c2..98135a6 100644 --- a/docs/team-creation-api-spec.md +++ b/docs/team-creation-api-spec.md @@ -88,8 +88,8 @@ API 단위로 담당을 분리했다 — 상세 내용은 "2. 담당자 요약" | v 백엔드(A): 하나의 트랜잭션으로 - a. 이 팀의 기존 활성 코드가 있으면 폐기(revoke) - b. 새 코드 생성 (6절 "초대 코드 생성 규칙") + a. 이 팀의 기존 활성 코드가 아직 안 만료됐으면 그대로 반환하고 끝 + b. 없거나 만료됐으면: 있는 경우 폐기(revoke) 후 새 코드 생성 (6절 "초대 코드 생성 규칙") | v 백엔드(A) -> 프론트: 새 초대 코드(평문) 응답 @@ -168,7 +168,7 @@ Spring 쪽 초대 코드 API만 다루고 Lambda와는 접점이 없다. |---|---|---|---|---| | 사업자등록증 업로드 (OCR 포함) | 백엔드 B | POST | `/api/v1/teams/business-registration` | 파일을 S3에 저장하고 OCR 결과를 응답 | | 팀 생성 | 백엔드 B | POST | `/api/v1/teams` | 팀 + 팀원(OWNER) + 사업자등록 정보를 한 트랜잭션으로 생성 | -| 초대 코드 발급 | 백엔드 A | POST | `/api/v1/teams/{teamId}/invite-code` | 팀원 초대용 코드를 새로 발급 (기존 활성 코드가 있으면 폐기) | +| 초대 코드 발급 | 백엔드 A | POST | `/api/v1/teams/{teamId}/invite-code` | 팀원 초대용 코드 발급 (활성 코드가 아직 유효하면 재사용, 없거나 만료됐으면 새로 발급) | 앞의 두 API(백엔드 B)는 "인증된 사용자인가"만 확인하면 된다 (아직 팀이 없는 시점). 초대 코드 발급은 다르다 — 이미 존재하는 팀에 대한 작업이라 "이 사용자가 이 팀 소속인가"와 역할까지 @@ -472,9 +472,11 @@ MVP 범위에서는 신경 쓰지 않기로 함) ## 6. 초대 코드 발급 API — 담당: 백엔드 A 팀 생성 직후(5절 성공 이후 프론트가 곧바로 호출) **또는** 팀원 페이지의 "팀원 초대하기" -버튼에서 호출한다 — 두 경우 모두 완전히 같은 API를 쓴다. 누를 때마다 항상 새 코드를 -발급한다 — 기존 활성 코드가 있어도 그대로 보여주지 않고, 폐기 후 새로 하나 더 만든다 -(처음 호출이라 폐기할 게 없으면 그 단계는 그냥 아무 일도 안 하고 넘어간다). +버튼에서 호출한다 — 두 경우 모두 완전히 같은 API를 쓴다. **아직 만료되지 않은 활성 코드가 +있으면 그 코드를 그대로 반환한다** — 버튼을 여러 번 눌러도 유효 기간 안에는 같은 코드가 +나온다. 활성 코드가 없거나(처음 호출) 이미 만료된 경우에만 새 코드를 발급한다(만료된 코드는 +그 직전에 폐기한다). 예전에는 호출할 때마다 무조건 새 코드를 발급했지만, 만료 기간을 1년에서 +1일로 크게 줄이면서 "누를 때마다 새 코드"일 필요가 없어져 재사용 방식으로 바꿨다. ### Request @@ -504,17 +506,20 @@ Authorization: Bearer {accessToken} `team_invite_links`에는 최초 발급 시점엔 잠글 행 자체가 없으므로, 대신 항상 존재하는 `teams` 행을 잠근다 — `TeamRepository.findByIdForUpdate()`를 쓴다(DV-113에서 추가됨, DV-112는 Javadoc만 보강). -1. 이 팀의 현재 활성 코드(`revoked_at IS NULL`)가 있으면 조회해서 폐기(`revoke(now)`) — - 없으면(팀 생성 직후 최초 호출) 이 단계는 건너뛴다 -2. 새 코드 생성 — 아래 "초대 코드 생성 규칙" -3. 새 `team_invite_links` row insert (`team`, `createdBy` = 요청자, `tokenHash`, `maxUses = null`, +1. 이 팀의 현재 활성 코드(`revoked_at IS NULL`)가 있으면 조회한다. **아직 만료 전(`now < expires_at`)이면 + 그 코드를 그대로 응답하고 끝난다** — 폐기도, 새 코드 발급도 하지 않는다. +2. 활성 코드가 없거나 이미 만료됐으면: 있는 경우에 한해 먼저 폐기(`revoke(now)`) — 처음 + 호출이라 폐기할 게 없으면 이 단계는 건너뛴다. +3. 새 코드 생성 — 아래 "초대 코드 생성 규칙" +4. 새 `team_invite_links` row insert (`team`, `createdBy` = 요청자, `inviteCode`, `maxUses = null`, `expiresAt`, `revokedAt = null`) -0단계의 잠금 덕분에 같은 팀에 대한 동시 요청은 하나씩 순서대로만 처리된다 — 뒤에 처리되는 -요청은 앞선 요청이 커밋한 새 코드를 "현재 활성 코드"로 보고 다시 폐기 후 재발급하게 되므로, -경합 상황에서도 최종적으로 팀에는 활성 코드가 정확히 1개만 남는다. 1~3단계 중 하나라도 -실패하면 전체 롤백된다(같은 트랜잭션) — 기존 코드는 폐기했는데 새 코드 발급에 실패해서 -팀에 활성 코드가 하나도 없는 상태가 되는 걸 방지한다. +0단계의 잠금 덕분에 같은 팀에 대한 동시 요청은 하나씩 순서대로만 처리된다. 재사용 분기(1번)만 +타는 요청끼리는 서로 아무 것도 바꾸지 않으니 경합이랄 게 없고, 재발급 분기(2~4번)를 타는 +요청이 있어도 뒤에 처리되는 요청은 앞선 요청이 커밋한 새 코드를 "현재 활성 코드"로 보고 그걸 +그대로 반환(아직 안 만료됐으므로)하게 되므로, 경합 상황에서도 최종적으로 팀에는 활성 코드가 +정확히 1개만 남는다. 2~4단계 중 하나라도 실패하면 전체 롤백된다(같은 트랜잭션) — 기존 코드는 +폐기했는데 새 코드 발급에 실패해서 팀에 활성 코드가 하나도 없는 상태가 되는 걸 방지한다. ### 초대 코드 생성 규칙 @@ -530,16 +535,20 @@ Authorization: Bearer {accessToken} 평문 코드가 이미 존재해서 insert가 실패하면 새 코드를 다시 뽑아 재시도한다 (예: 최대 3회). - **사용 횟수 제한 없음(`maxUses = null`)**: 한 코드를 여러 팀원이 각자 입력해서 합류하는 흐름이라, 특정 인원수로 막지 않는다. -- **만료 기간**: **발급일로부터 1년**으로 확정 (DB 스키마상 `expires_at`은 `NOT NULL`이라 - 값을 반드시 넣어야 한다). 사용자가 능동적으로 재발급 버튼을 누르지 않는 한 자동 갱신되진 - 않으므로, 방치된 팀이 초대 자체를 못 하게 되는 걸 막기 위해 길게 잡았다. +- **만료 기간**: **발급일로부터 1일**로 확정 (DB 스키마상 `expires_at`은 `NOT NULL`이라 + 값을 반드시 넣어야 한다). 예전엔 "버튼을 누를 때마다 항상 새 코드"였고 재발급하지 않으면 + 자동 갱신도 안 됐던 탓에 방치된 팀이 초대 자체를 못 하게 되는 걸 막으려고 1년으로 길게 + 잡았었다. 지금은 활성 코드가 있으면 그대로 재사용하는 방식으로 바뀌어서 "길게 잡아야 할 + 이유"가 없어졌고, 오히려 유출된 코드가 오래 살아있는 게 더 큰 위험이라 짧게 줄였다. 하루 + 안에 초대를 못 끝내면 팀원 페이지에서 "팀원 초대하기"를 다시 누르면 되고, 그 시점엔 코드가 + 이미 만료돼 있으니 새 코드가 나온다. ### Response Fields | 필드 | 타입 | 설명 | |---|---|---| -| `inviteCode` | string | 새로 발급된 초대 코드 (평문, 7자리) | -| `inviteCodeExpiresAt` | string(ISO-8601) | 새 초대 코드의 만료 시각 | +| `inviteCode` | string | 초대 코드 (평문, 7자리). 기존 코드를 재사용한 응답이면 이전과 같은 값 | +| `inviteCodeExpiresAt` | string(ISO-8601) | 이 초대 코드의 만료 시각 | ### Response Example @@ -550,7 +559,7 @@ Authorization: Bearer {accessToken} "message": "성공적으로 요청을 처리했습니다.", "result": { "inviteCode": "K2M8XZ1", - "inviteCodeExpiresAt": "2027-07-17T10:12:00+09:00" + "inviteCodeExpiresAt": "2026-07-18T10:12:00+09:00" } } ``` diff --git a/src/main/java/com/shinhan/klljs/domain/team/controller/TeamMemberControllerDocs.java b/src/main/java/com/shinhan/klljs/domain/team/controller/TeamMemberControllerDocs.java index 05c8ece..9b9bbf5 100644 --- a/src/main/java/com/shinhan/klljs/domain/team/controller/TeamMemberControllerDocs.java +++ b/src/main/java/com/shinhan/klljs/domain/team/controller/TeamMemberControllerDocs.java @@ -42,7 +42,7 @@ ApiResponse getMembers( @Parameter(description = "이름 또는 이메일 검색어, 최대 100자", example = "김") String keyword ); - @Operation(summary = "초대 코드 발급", description = "팀의 ACTIVE 멤버(OWNER/ADMIN/MEMBER 누구나)가 호출한다. 기존 활성 코드를 폐기하고 1년 유효한 새 코드를 반환한다.") + @Operation(summary = "초대 코드 발급", description = "팀의 ACTIVE 멤버(OWNER/ADMIN/MEMBER 누구나)가 호출한다. 아직 유효한(만료 전) 코드가 있으면 그 코드를 그대로 반환하고, 없거나 만료됐으면 새로 발급한다(유효 기간 1일). 즉 버튼을 여러 번 눌러도 코드는 유효 기간 동안 바뀌지 않는다.") @ApiResponses({ @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "발급 성공"), @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "미소속 또는 역할 부족 (TEAM_403_001/002)"), diff --git a/src/main/java/com/shinhan/klljs/domain/team/entity/TeamInviteLink.java b/src/main/java/com/shinhan/klljs/domain/team/entity/TeamInviteLink.java index 8e5df5d..db0385f 100644 --- a/src/main/java/com/shinhan/klljs/domain/team/entity/TeamInviteLink.java +++ b/src/main/java/com/shinhan/klljs/domain/team/entity/TeamInviteLink.java @@ -22,8 +22,9 @@ /** * 초대 코드로 합류하면 항상 MEMBER로 합류한다 (역할 선택 없음, 승격은 팀원 관리 화면에서 별도 처리). - * 팀당 폐기되지 않은(revoked_at IS NULL) 코드는 항상 하나만 존재한다 — 새 코드를 발급하면 - * 기존 코드를 같은 트랜잭션에서 먼저 폐기해야 한다 (activeCodeMarker 유니크 인덱스가 강제). + * 팀당 폐기되지 않은(revoked_at IS NULL) 코드는 항상 하나만 존재한다 — 아직 안 만료된 코드가 + * 있으면 그대로 재사용하고, 없거나 만료된 경우에만 그 코드를 같은 트랜잭션에서 먼저 폐기한 뒤 + * 새로 발급한다 (activeCodeMarker 유니크 인덱스가 팀당 미폐기 행 1개만 강제하므로 순서를 지켜야 한다). */ @Entity @Table(name = "team_invite_links") diff --git a/src/main/java/com/shinhan/klljs/domain/team/service/TeamInviteCodeTransactionService.java b/src/main/java/com/shinhan/klljs/domain/team/service/TeamInviteCodeTransactionService.java index fa31520..8d5a860 100644 --- a/src/main/java/com/shinhan/klljs/domain/team/service/TeamInviteCodeTransactionService.java +++ b/src/main/java/com/shinhan/klljs/domain/team/service/TeamInviteCodeTransactionService.java @@ -21,11 +21,19 @@ import java.time.Clock; import java.time.LocalDateTime; import java.util.Locale; +import java.util.Optional; @Service @RequiredArgsConstructor public class TeamInviteCodeTransactionService { + /** + * 방치된 팀이 초대 자체를 못 하게 되는 걸 막으면서도, 유출된 코드의 노출 기간을 짧게 + * 가져가기 위한 값이다. 예전엔 1년이었지만, 버튼을 눌러도 코드가 안 바뀌는 재사용 방식으로 + * 바뀌면서 "길게 잡아야 할 이유"가 없어져 짧게 줄였다. + */ + private static final long INVITE_CODE_TTL_DAYS = 1; + private final TeamRepository teamRepository; private final TeamMemberRepository teamMemberRepository; private final TeamInviteLinkRepository teamInviteLinkRepository; @@ -45,13 +53,24 @@ public TeamInviteCodeResponse issueOnce(Long requesterId, Long teamId) { } LocalDateTime nowUtc = LocalDateTime.now(clock); - teamInviteLinkRepository.findByTeamIdAndRevokedAtIsNull(teamId).ifPresent(activeInvite -> { - activeInvite.revoke(nowUtc); + Optional activeInvite = teamInviteLinkRepository.findByTeamIdAndRevokedAtIsNull(teamId); + + // 아직 안 만료된 코드가 있으면 그대로 재사용한다 - 버튼을 눌러도 새 코드가 안 나온다. + if (activeInvite.isPresent() && isReusable(activeInvite.get(), nowUtc)) { + TeamInviteLink current = activeInvite.get(); + return new TeamInviteCodeResponse(current.getInviteCode(), KstDateTimes.toKstOffset(current.getExpiresAt())); + } + + // 없거나 이미 만료됐거나, V11 이전 해시 저장 방식이라 평문 코드를 복원할 수 없는(inviteCode + // == null) 레거시 행만 있으면 폐기하고 새로 발급한다 (activeCodeMarker 유니크 인덱스가 + // 팀당 미폐기 행 1개만 허용하므로 순서를 지켜야 한다). + activeInvite.ifPresent(old -> { + old.revoke(nowUtc); teamInviteLinkRepository.flush(); }); String rawCode = inviteCodeGenerator.generate(); - LocalDateTime expiresAtUtc = nowUtc.plusYears(1); + LocalDateTime expiresAtUtc = nowUtc.plusDays(INVITE_CODE_TTL_DAYS); TeamInviteLink invite = TeamInviteLink.builder() .team(team) .createdBy(requester.getUser()) @@ -72,6 +91,16 @@ public TeamInviteCodeResponse issueOnce(Long requesterId, Long teamId) { return new TeamInviteCodeResponse(rawCode, KstDateTimes.toKstOffset(expiresAtUtc)); } + /** + * isUsable()은 만료·폐기·사용횟수만 보고 코드 자체의 존재 여부는 보지 않는다. V11 마이그레이션 + * 이전 해시 저장 방식에서 넘어온 행은 평문 코드를 복원할 수 없어 inviteCode가 null인데, + * 그 행이 아직 revoke도 만료도 안 됐다면 isUsable()만으로는 재사용 가능하다고 잘못 판단해 + * inviteCode: null을 그대로 응답해버린다 - 그래서 null 여부를 별도로 확인한다. + */ + private boolean isReusable(TeamInviteLink invite, LocalDateTime now) { + return invite.getInviteCode() != null && invite.isUsable(now); + } + private boolean isInviteCodeCollision(DataIntegrityViolationException exception) { Throwable cause = exception.getMostSpecificCause(); String message = cause == null ? exception.getMessage() : cause.getMessage(); diff --git a/src/main/resources/db/migration/V13__revoke_all_active_invite_codes.sql b/src/main/resources/db/migration/V13__revoke_all_active_invite_codes.sql new file mode 100644 index 0000000..81d02b5 --- /dev/null +++ b/src/main/resources/db/migration/V13__revoke_all_active_invite_codes.sql @@ -0,0 +1,17 @@ +-- ============================================================ +-- V13__revoke_all_active_invite_codes.sql +-- 팀원 초대 코드 정책을 "누를 때마다 새 코드 발급 + 1년 만료"에서 "만료 전이면 재사용 + +-- 1일 만료"로 바꾸면서, 배포 시점에 이미 발급돼 있던 코드도 즉시 새 정책을 적용받도록 +-- 전부 폐기한다. 하드 삭제가 아니라 revoke만 하므로, 그 코드로 합류한 이력 +-- (team_members.joined_via_invite_id)은 그대로 보존된다. +-- +-- 이 마이그레이션 직후 다음 "초대 코드 발급" 호출 시점에 각 팀마다 새 코드(1일 만료)가 +-- 발급된다. 그 순간 기존에 공유돼 있던 옛 코드는 즉시 무효화된다. +-- +-- V11 이전 해시 저장 방식에서 넘어와 invite_code가 NULL인 레거시 행도 이 조건 +-- (revoked_at IS NULL)에 해당하므로 함께 폐기된다. +-- ============================================================ + +UPDATE team_invite_links +SET revoked_at = NOW(3) +WHERE revoked_at IS NULL; diff --git a/src/test/java/com/shinhan/klljs/domain/team/service/TeamInviteCodeIntegrationTest.java b/src/test/java/com/shinhan/klljs/domain/team/service/TeamInviteCodeIntegrationTest.java index d300f26..42ad3cd 100644 --- a/src/test/java/com/shinhan/klljs/domain/team/service/TeamInviteCodeIntegrationTest.java +++ b/src/test/java/com/shinhan/klljs/domain/team/service/TeamInviteCodeIntegrationTest.java @@ -18,6 +18,7 @@ import org.springframework.boot.test.context.SpringBootTest; import org.springframework.transaction.annotation.Transactional; +import java.time.Clock; import java.time.LocalDateTime; import java.util.List; @@ -37,6 +38,9 @@ class TeamInviteCodeIntegrationTest { @Autowired private EntityManager entityManager; + @Autowired + private Clock clock; + @Test void issue_createsInviteWithPlaintextCode() { Fixture fixture = persistFixture(TeamStatus.ACTIVE, TeamMemberRole.OWNER); @@ -51,7 +55,7 @@ void issue_createsInviteWithPlaintextCode() { } @Test - void issue_revokesPreviousInviteAndLeavesOneActiveCode() { + void issue_reusesActiveCodeWithinValidityWindow() { Fixture fixture = persistFixture(TeamStatus.ACTIVE, TeamMemberRole.ADMIN); TeamInviteCodeResponse first = service.issue(fixture.member().getUser().getId(), fixture.team().getId()); @@ -61,9 +65,59 @@ void issue_revokesPreviousInviteAndLeavesOneActiveCode() { List teamInvites = inviteRepository.findAll().stream() .filter(invite -> invite.getTeam().getId().equals(fixture.team().getId())) .toList(); - assertThat(teamInvites).hasSize(2); - assertThat(teamInvites).filteredOn(invite -> invite.getRevokedAt() == null).hasSize(1); - assertThat(first.inviteCode()).isNotEqualTo(second.inviteCode()); + assertThat(teamInvites).hasSize(1); + assertThat(teamInvites.getFirst().getRevokedAt()).isNull(); + assertThat(first.inviteCode()).isEqualTo(second.inviteCode()); + assertThat(first.inviteCodeExpiresAt()).isEqualTo(second.inviteCodeExpiresAt()); + } + + @Test + void issue_reissuesWhenPreviousCodeExpired() { + Fixture fixture = persistFixture(TeamStatus.ACTIVE, TeamMemberRole.OWNER); + TeamInviteLink expired = TeamInviteLink.builder() + .team(fixture.team()) + .createdBy(fixture.member().getUser()) + .inviteCode("OLDCOD1") + .maxUses(null) + .expiresAt(LocalDateTime.now(clock).minusHours(1)) + .build(); + entityManager.persist(expired); + entityManager.flush(); + + TeamInviteCodeResponse response = service.issue(fixture.member().getUser().getId(), fixture.team().getId()); + entityManager.flush(); + entityManager.clear(); + + assertThat(response.inviteCode()).isNotEqualTo("OLDCOD1"); + assertThat(entityManager.find(TeamInviteLink.class, expired.getId()).getRevokedAt()).isNotNull(); + TeamInviteLink newActive = inviteRepository.findByTeamIdAndRevokedAtIsNull(fixture.team().getId()).orElseThrow(); + assertThat(newActive.getInviteCode()).isEqualTo(response.inviteCode()); + } + + @Test + void issue_reissuesWhenPreviousCodeIsLegacyNullInviteCode() { + // V11 이전 해시 저장 방식에서 넘어온 행 재현 - revoke도 만료도 안 됐지만 평문 코드를 + // 복원할 수 없어 inviteCode가 null이다. isUsable()만 보면 재사용 가능하다고 오판해 + // inviteCode: null을 그대로 응답해버리는 회귀를 잡는 테스트다. + Fixture fixture = persistFixture(TeamStatus.ACTIVE, TeamMemberRole.OWNER); + TeamInviteLink legacyNullCode = TeamInviteLink.builder() + .team(fixture.team()) + .createdBy(fixture.member().getUser()) + .inviteCode(null) + .maxUses(null) + .expiresAt(LocalDateTime.now(clock).plusDays(300)) + .build(); + entityManager.persist(legacyNullCode); + entityManager.flush(); + + TeamInviteCodeResponse response = service.issue(fixture.member().getUser().getId(), fixture.team().getId()); + entityManager.flush(); + entityManager.clear(); + + assertThat(response.inviteCode()).isNotNull(); + assertThat(entityManager.find(TeamInviteLink.class, legacyNullCode.getId()).getRevokedAt()).isNotNull(); + TeamInviteLink newActive = inviteRepository.findByTeamIdAndRevokedAtIsNull(fixture.team().getId()).orElseThrow(); + assertThat(newActive.getInviteCode()).isEqualTo(response.inviteCode()); } @Test