Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

Expand Down Expand Up @@ -167,10 +168,22 @@ public MessageResult submitAnswer(Long userId, Long sessionId, String content, S
.orElseThrow(() -> new DomainException(ApiErrorCode.SESSION_INVALID_STATE));
InterviewMessage parentQuestion = resolveAnswerParent(latest);
int nextSeq = latest.getSequenceNumber() + 1;
InterviewMessage answer = messageRepository.save(
InterviewMessage.interviewee(session, nextSeq, content, parentQuestion,
idempotencyKey != null && !idempotencyKey.isBlank() ? idempotencyKey : null)
);
InterviewMessage answer;
try {
// saveAndFlush — INSERT 를 여기서 터뜨려야 아래에서 잡을 수 있다. 커밋 시점까지
// 미루면 트랜잭션 밖에서 500 으로 새어 나간다.
answer = messageRepository.saveAndFlush(
InterviewMessage.interviewee(session, nextSeq, content, parentQuestion,
idempotencyKey != null && !idempotencyKey.isBlank() ? idempotencyKey : null)
);
} catch (DataIntegrityViolationException e) {
// 같은 질문에 두 요청이 동시에 들어오면 둘 다 같은 nextSeq 를 계산한다.
// (session_id, sequence_number) UNIQUE 가 한쪽을 막는데, 그대로 두면 사용자에겐
// 정체불명의 500 이 된다. 실제 의미는 '이 턴은 이미 답변됐다' 이므로 그렇게 알린다.
log.info("answer sequence conflict — turn already answered. sessionId={}, seq={}",
sessionId, nextSeq);
throw new DomainException(ApiErrorCode.SESSION_INVALID_STATE);
}

events.publishEvent(new AnswerSubmittedEvent(
userId, sessionId, parentQuestion.getId(), answer.getId()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

Expand Down Expand Up @@ -61,10 +62,19 @@ public VoicePlaceholder createVoicePlaceholder(Long userId, Long sessionId, Stri
throw new DomainException(ApiErrorCode.SESSION_INVALID_STATE);
}
int nextSeq = latest.getSequenceNumber() + 1;
InterviewMessage placeholder = messageRepository.save(
InterviewMessage.voiceInterviewee(session, nextSeq, latest,
idempotencyKey != null && !idempotencyKey.isBlank() ? idempotencyKey : null)
);
InterviewMessage placeholder;
try {
// 텍스트 답변(InterviewMessageService.submitAnswer)과 같은 이유로 saveAndFlush +
// 제약 위반 변환 — 같은 턴에 동시 제출이 들어오면 둘 다 같은 seq 를 계산한다.
placeholder = messageRepository.saveAndFlush(
InterviewMessage.voiceInterviewee(session, nextSeq, latest,
idempotencyKey != null && !idempotencyKey.isBlank() ? idempotencyKey : null)
);
} catch (DataIntegrityViolationException e) {
log.info("voice answer sequence conflict — turn already answered. sessionId={}, seq={}",
sessionId, nextSeq);
throw new DomainException(ApiErrorCode.SESSION_INVALID_STATE);
}
return new VoicePlaceholder(session, placeholder, latest);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

import com.stackup.stackup.common.exception.ApiErrorCode;
import com.stackup.stackup.common.exception.DomainException;
import com.stackup.stackup.common.storage.ObjectStorageClient;
import com.stackup.stackup.session.application.dto.MessageResult;
Expand Down Expand Up @@ -81,7 +82,7 @@ void submitAnswer_insertsIntervieweeAndPublishesEvent() {

when(sessionRepository.findByIdAndUser_IdAndDeletedFalse(10L, 1L)).thenReturn(Optional.of(session));
when(messageRepository.findFirstBySession_IdOrderBySequenceNumberDesc(10L)).thenReturn(Optional.of(question));
when(messageRepository.save(any(InterviewMessage.class))).thenAnswer(inv -> {
when(messageRepository.saveAndFlush(any(InterviewMessage.class))).thenAnswer(inv -> {
InterviewMessage m = inv.getArgument(0);
ReflectionTestUtils.setField(m, "id", 200L);
return m;
Expand Down Expand Up @@ -111,6 +112,27 @@ void submitAnswer_returnsExistingWhenIdempotencyKeyHit() {
verify(events, never()).publishEvent(any(AnswerSubmittedEvent.class));
}

// 같은 턴에 두 요청이 동시에 들어오면 둘 다 같은 seq 를 계산한다.
// (session_id, sequence_number) UNIQUE 가 한쪽을 막는데, 500 이 아니라 '이미 답변된 턴'이어야 한다.
@Test
void submitAnswer_translatesSequenceConflictToInvalidState() {
InterviewSession session = sessionInProgress(10L);
InterviewMessage question = InterviewMessage.interviewer(session, 1, "Q1?");
ReflectionTestUtils.setField(question, "id", 100L);

when(sessionRepository.findByIdAndUser_IdAndDeletedFalse(10L, 1L)).thenReturn(Optional.of(session));
when(messageRepository.findFirstBySession_IdOrderBySequenceNumberDesc(10L)).thenReturn(Optional.of(question));
when(messageRepository.saveAndFlush(any(InterviewMessage.class)))
.thenThrow(new org.springframework.dao.DataIntegrityViolationException(
"duplicate key value violates unique constraint"));

assertThatThrownBy(() -> service.submitAnswer(1L, 10L, "answer", null))
.isInstanceOfSatisfying(DomainException.class, e ->
assertThat(e.getErrorCode()).isEqualTo(ApiErrorCode.SESSION_INVALID_STATE));

verify(events, never()).publishEvent(any(AnswerSubmittedEvent.class));
}

@Test
void submitAnswer_rejectsWhenPreviousMessageNotInterviewer() {
InterviewSession session = sessionInProgress(10L);
Expand All @@ -135,7 +157,7 @@ void submitAnswer_allowsTextRetryAfterFailedVoiceAnswer() {

when(sessionRepository.findByIdAndUser_IdAndDeletedFalse(10L, 1L)).thenReturn(Optional.of(session));
when(messageRepository.findFirstBySession_IdOrderBySequenceNumberDesc(10L)).thenReturn(Optional.of(failedVoice));
when(messageRepository.save(any(InterviewMessage.class))).thenAnswer(inv -> {
when(messageRepository.saveAndFlush(any(InterviewMessage.class))).thenAnswer(inv -> {
InterviewMessage m = inv.getArgument(0);
ReflectionTestUtils.setField(m, "id", 201L);
return m;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ void createVoicePlaceholder_insertsAfterLatestQuestion() {
when(sessionRepository.findByIdAndUser_IdAndDeletedFalse(10L, 1L)).thenReturn(Optional.of(session));
when(messageRepository.findFirstBySession_IdOrderBySequenceNumberDesc(10L))
.thenReturn(Optional.of(question));
when(messageRepository.save(any(InterviewMessage.class))).thenAnswer(inv -> {
when(messageRepository.saveAndFlush(any(InterviewMessage.class))).thenAnswer(inv -> {
InterviewMessage m = inv.getArgument(0);
ReflectionTestUtils.setField(m, "id", 200L);
return m;
Expand Down Expand Up @@ -135,6 +135,24 @@ void createVoicePlaceholder_rejectsWhenLastMessageIsNotQuestion() {
verify(messageRepository, never()).save(any());
}

// 텍스트 답변과 동일 — 동시 제출로 seq 가 충돌하면 500 이 아니라 '이미 답변된 턴'.
@Test
void createVoicePlaceholder_translatesSequenceConflictToInvalidState() {
InterviewSession session = sessionInProgress(10L);
InterviewMessage question = InterviewMessage.interviewer(session, 1, "Q1?");
ReflectionTestUtils.setField(question, "id", 100L);

when(sessionRepository.findByIdAndUser_IdAndDeletedFalse(10L, 1L)).thenReturn(Optional.of(session));
when(messageRepository.findFirstBySession_IdOrderBySequenceNumberDesc(10L))
.thenReturn(Optional.of(question));
when(messageRepository.saveAndFlush(any(InterviewMessage.class)))
.thenThrow(new org.springframework.dao.DataIntegrityViolationException("duplicate key"));

assertThatThrownBy(() -> service.createVoicePlaceholder(1L, 10L, null))
.isInstanceOfSatisfying(DomainException.class, e ->
assertThat(e.getErrorCode()).isEqualTo(ApiErrorCode.SESSION_INVALID_STATE));
}

// ── attachAudioAndRequestAnalysis ─────────────────────────────────────────

// analyze.voice 는 여기서 직접 발행하지 않는다 — 이벤트만 내고 AFTER_COMMIT 리스너가 발행한다.
Expand Down
62 changes: 62 additions & 0 deletions frontend/src/features/history/ui/SessionCard.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { describe, it, expect } from 'vitest'
import { render, screen } from '@testing-library/react'
import { MemoryRouter } from 'react-router-dom'
import { SessionCard } from './SessionCard'
import type { Session } from '../api/historyApi'

const base: Session = {
id: 7,
title: '백엔드 모의면접',
status: 'COMPLETED',
mode: 'TECHNICAL',
jobCategory: 'BACKEND',
totalQuestionCount: 5,
createdAt: '2026-08-18T00:00:00Z',
}

function renderCard(session: Partial<Session>) {
return render(
<MemoryRouter>
<SessionCard session={{ ...base, ...session }} />
</MemoryRouter>,
)
}

describe('SessionCard', () => {
it('완료 세션은 피드백 리포트로 간다', () => {
renderCard({ status: 'COMPLETED' })

expect(screen.getByRole('link')).toHaveAttribute('href', '/sessions/7/feedback')
expect(screen.getByText('리포트 →')).toBeInTheDocument()
})

// 중단된 면접은 피드백이 없다. 문답 기록으로 갈 경로가 없으면 한 말이 통째로 사라진다.
it('중단 세션은 세션 화면(기록)으로 간다', () => {
renderCard({ status: 'INTERRUPTED' })

expect(screen.getByRole('link')).toHaveAttribute('href', '/sessions/7')
expect(screen.getByText('기록 보기 →')).toBeInTheDocument()
})

// 진행 중 면접에서 이탈했을 때 돌아갈 경로.
it('진행 중 세션은 이어서 진행할 수 있다', () => {
renderCard({ status: 'IN_PROGRESS' })

expect(screen.getByRole('link')).toHaveAttribute('href', '/sessions/7')
expect(screen.getByText('이어서 →')).toBeInTheDocument()
})

it('준비 상태 세션은 로비로 간다', () => {
renderCard({ status: 'READY' })

expect(screen.getByRole('link')).toHaveAttribute('href', '/sessions/7')
})

// 시작 전 취소라 보여줄 문답이 없다 — 링크를 만들지 않는다.
it('취소 세션은 눌리지 않는다', () => {
renderCard({ status: 'CANCELLED' })

expect(screen.queryByRole('link')).not.toBeInTheDocument()
expect(screen.getByText('취소됨')).toBeInTheDocument()
})
})
24 changes: 19 additions & 5 deletions frontend/src/features/history/ui/SessionCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,21 @@ const JOB: Record<string, string> = {
DBA: 'DBA',
}

/**
* 상태별로 눌렀을 때 갈 곳. 예전에는 COMPLETED 만 링크였고 나머지는 아예 눌리지 않아,
* 중단된 면접의 문답을 다시 볼 방법도 · 진행 중이던 면접으로 돌아갈 방법도 없었다.
* (CANCELLED 는 시작 전 취소라 볼 것이 없어 그대로 둔다.)
*/
const LINK: Record<string, { to: (id: number) => string; cta: string }> = {
COMPLETED: { to: (id) => `/sessions/${id}/feedback`, cta: '리포트 →' },
INTERRUPTED: { to: (id) => `/sessions/${id}`, cta: '기록 보기 →' },
IN_PROGRESS: { to: (id) => `/sessions/${id}`, cta: '이어서 →' },
READY: { to: (id) => `/sessions/${id}`, cta: '시작하기 →' },
}

export function SessionCard({ session }: { session: Session }) {
const status = session.status ? STATUS[session.status] : undefined
const completed = session.status === 'COMPLETED'
const link = session.status ? LINK[session.status] : undefined
const jobs = session.jobCategories?.length
? session.jobCategories
: session.jobCategory
Expand All @@ -48,15 +60,17 @@ export function SessionCard({ session }: { session: Session }) {
</div>
<div className="flex items-center gap-3">
{status && <StatusBadge tone={status.tone}>{status.label}</StatusBadge>}
{completed && (
<span className="shrink-0 text-caption font-medium text-primary-fg">리포트 →</span>
{link && (
<span className="shrink-0 text-caption font-medium text-primary-fg">
{link.cta}
</span>
)}
</div>
</div>
)

return completed ? (
<Link to={`/sessions/${session.id}/feedback`}>{body}</Link>
return link && session.id != null ? (
<Link to={link.to(session.id)}>{body}</Link>
) : (
body
)
Expand Down
47 changes: 31 additions & 16 deletions frontend/src/features/interview/ui/live/SessionEndedPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,20 @@ import { Link } from 'react-router-dom'
import { Button } from '@/shared/ui/Button'
import { Eyebrow } from '@/shared/ui'
import type { SessionStatus } from '@/domain/session'
import { InterviewTranscript } from '../InterviewTranscript'

const messageByStatus: Partial<Record<SessionStatus, string>> = {
COMPLETED: '면접이 종료되었습니다. 피드백을 준비 중입니다.',
INTERRUPTED: '면접이 중단되었습니다.',
CANCELLED: '면접이 취소되었습니다.',
}

// 중단된 면접은 피드백이 만들어지지 않는다. 그렇다고 주고받은 문답까지 못 보게 하면
// 사용자가 한 말이 통째로 사라진 것처럼 보이므로, 여기서 기록을 그대로 노출한다.
// (취소된 세션은 시작 전이라 자기소개 질문 하나뿐 — 보여줄 게 없다.)
const showsTranscript = (status: SessionStatus) =>
status === 'COMPLETED' || status === 'INTERRUPTED'

export function SessionEndedPanel({
status,
sessionId,
Expand All @@ -17,24 +24,32 @@ export function SessionEndedPanel({
sessionId: number
}) {
return (
<div className="flex h-full flex-col items-center justify-center gap-4 px-6 text-center">
<Eyebrow>면접 종료</Eyebrow>
<p
className="font-sans text-[22px] font-bold tracking-[-0.03em] text-fg"
style={{ wordBreak: 'keep-all' }}
>
{messageByStatus[status] ?? '면접이 종료되었습니다.'}
</p>
<div className="mt-2 flex flex-wrap items-center justify-center gap-3">
{status === 'COMPLETED' && (
<Link to={`/sessions/${sessionId}/feedback`}>
<Button>피드백 보기</Button>
<div className="flex h-full flex-col overflow-y-auto">
<div className="flex flex-col items-center justify-center gap-4 px-6 py-16 text-center">
<Eyebrow>면접 종료</Eyebrow>
<p
className="font-sans text-[22px] font-bold tracking-[-0.03em] text-fg"
style={{ wordBreak: 'keep-all' }}
>
{messageByStatus[status] ?? '면접이 종료되었습니다.'}
</p>
<div className="mt-2 flex flex-wrap items-center justify-center gap-3">
{status === 'COMPLETED' && (
<Link to={`/sessions/${sessionId}/feedback`}>
<Button>피드백 보기</Button>
</Link>
)}
<Link to="/workspace">
<Button variant="secondary">워크스페이스로</Button>
</Link>
)}
<Link to="/workspace">
<Button variant="secondary">워크스페이스로</Button>
</Link>
</div>
</div>

{showsTranscript(status) && (
<div className="mx-auto w-full max-w-3xl px-6 pb-16">
<InterviewTranscript sessionId={sessionId} />
</div>
)}
</div>
)
}
Loading