feat: add collaborative interview scheduling
Add candidate decisions, co-interviewer accounts, scheduled interview inbox and calendar views, candidate-safe meeting links, shared documents, and private live notes backed by RabbitMQ. Extend the disposable integration environment to validate PostgreSQL migrations, seeded users, interview lifecycle behavior, and RabbitMQ fanout. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
parent
fd06fbbb5d
commit
75e3c73a61
35 changed files with 3621 additions and 58 deletions
|
|
@ -14,6 +14,7 @@ import (
|
|||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
|
|
@ -26,6 +27,7 @@ import (
|
|||
|
||||
"github.com/danyel/go-guess/backend/internal/database/repository"
|
||||
"github.com/danyel/go-guess/backend/internal/database/seed"
|
||||
"github.com/danyel/go-guess/backend/internal/eventbus"
|
||||
"github.com/danyel/go-guess/backend/internal/security"
|
||||
"github.com/danyel/go-guess/backend/internal/service"
|
||||
"github.com/danyel/go-guess/backend/internal/web/handler"
|
||||
|
|
@ -33,12 +35,32 @@ import (
|
|||
"github.com/danyel/go-guess/backend/internal/web/router"
|
||||
)
|
||||
|
||||
var testServer *httptest.Server
|
||||
var (
|
||||
testServer *httptest.Server
|
||||
testBus eventbus.IEventBus
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
rabbitContainer, err := testcontainers.Run(
|
||||
ctx,
|
||||
"rabbitmq:4-management-alpine",
|
||||
testcontainers.WithExposedPorts("5672/tcp"),
|
||||
testcontainers.WithEnv(map[string]string{
|
||||
"RABBITMQ_DEFAULT_USER": "go_guess",
|
||||
"RABBITMQ_DEFAULT_PASS": "go_guess",
|
||||
}),
|
||||
testcontainers.WithWaitStrategy(
|
||||
wait.ForListeningPort("5672/tcp").WithStartupTimeout(90*time.Second),
|
||||
),
|
||||
)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "start RabbitMQ test container:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
container, err := tcpostgres.Run(
|
||||
ctx,
|
||||
"postgres:18-alpine",
|
||||
|
|
@ -53,6 +75,7 @@ func TestMain(m *testing.M) {
|
|||
)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "start PostgreSQL test container:", err)
|
||||
_ = rabbitContainer.Terminate(context.Background())
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
|
|
@ -95,6 +118,19 @@ func TestMain(m *testing.M) {
|
|||
store := repository.New(gormDB)
|
||||
tokens := security.NewTokenManager("integration-test-secret-at-least-32-bytes", time.Hour)
|
||||
invitations := service.NewInvitationService(store, "http://example.test")
|
||||
rabbitEndpoint, err := rabbitContainer.PortEndpoint(ctx, "5672/tcp", "amqp")
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "get RabbitMQ endpoint:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
rabbitURL := strings.Replace(rabbitEndpoint, "amqp://", "amqp://go_guess:go_guess@", 1) + "/"
|
||||
amqpBus, err := eventbus.NewAMQPBus(rabbitURL)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "connect RabbitMQ:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
testBus = amqpBus
|
||||
interviews := service.NewScheduledInterviewService(store, testBus)
|
||||
testServer = httptest.NewServer(router.New(
|
||||
handler.New(
|
||||
service.NewAuthService(store, tokens),
|
||||
|
|
@ -102,6 +138,8 @@ func TestMain(m *testing.M) {
|
|||
service.NewQuestionService(store),
|
||||
service.NewParticipantService(store),
|
||||
invitations,
|
||||
service.NewUserService(store),
|
||||
interviews,
|
||||
10,
|
||||
"http://example.test",
|
||||
),
|
||||
|
|
@ -111,9 +149,11 @@ func TestMain(m *testing.M) {
|
|||
|
||||
code := m.Run()
|
||||
testServer.Close()
|
||||
_ = amqpBus.Close()
|
||||
_ = sqlDB.Close()
|
||||
cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
_ = container.Terminate(cleanupCtx)
|
||||
_ = rabbitContainer.Terminate(cleanupCtx)
|
||||
cleanupCancel()
|
||||
os.Exit(code)
|
||||
}
|
||||
|
|
@ -142,12 +182,12 @@ func TestCompleteInterviewWorkflow(t *testing.T) {
|
|||
var question webmodel.QuestionResponse
|
||||
requestJSON(t, http.MethodPost, "/api/questions", login.Token, map[string]any{
|
||||
"text": "Review this error handling.", "type": "code_review",
|
||||
"codeSnippet": "func run() error {\n execute()\n return nil\n}",
|
||||
"codeSnippet": "func run() error {\n execute()\n return nil\n}",
|
||||
"referenceAnswer": "Return the execute error.", "options": []string{},
|
||||
}, http.StatusCreated, &question)
|
||||
requestJSON(t, http.MethodPut, fmt.Sprintf("/api/questions/%d", question.ID), login.Token, map[string]any{
|
||||
"text": "Review this ignored error.", "type": "code_review",
|
||||
"codeSnippet": "func run() error {\n execute()\n return nil\n}",
|
||||
"codeSnippet": "func run() error {\n execute()\n return nil\n}",
|
||||
"referenceAnswer": "Propagate the execute error.", "options": []string{},
|
||||
}, http.StatusOK, &question)
|
||||
|
||||
|
|
@ -221,6 +261,109 @@ func TestCompleteInterviewWorkflow(t *testing.T) {
|
|||
if !found {
|
||||
t.Fatalf("review did not include interviewer question context: %#v", review.Job.Questions)
|
||||
}
|
||||
|
||||
requestJSON(t, http.MethodPatch,
|
||||
fmt.Sprintf("/api/jobs/%d/invitations/%d/outcome", jobID, invitation.ID),
|
||||
login.Token, map[string]any{"outcome": "passed"}, http.StatusOK, &invitation)
|
||||
if invitation.Outcome != "passed" {
|
||||
t.Fatalf("assessment outcome was not updated: %#v", invitation)
|
||||
}
|
||||
|
||||
var coInterviewer webmodel.User
|
||||
requestJSON(t, http.MethodPost, "/api/users", login.Token, map[string]any{
|
||||
"email": "co.integration@example.com", "password": "integration-password",
|
||||
"displayName": "Co Interviewer",
|
||||
}, http.StatusCreated, &coInterviewer)
|
||||
if coInterviewer.Role != "co_interviewer" {
|
||||
t.Fatalf("unexpected co-interviewer: %#v", coInterviewer)
|
||||
}
|
||||
|
||||
var scheduled webmodel.ScheduledInterviewResponse
|
||||
requestJSON(t, http.MethodPost, fmt.Sprintf("/api/jobs/%d/interviews", jobID),
|
||||
login.Token, map[string]any{
|
||||
"invitationId": invitation.ID, "startsAt": time.Now().Add(time.Hour),
|
||||
"location": "Meeting room", "interviewerIds": []uint{coInterviewer.ID},
|
||||
"sharedDocument": "Collaborative notes",
|
||||
}, http.StatusCreated, &scheduled)
|
||||
if scheduled.CandidateURL != "http://example.test/participant/meeting/"+scheduled.CandidateToken ||
|
||||
len(scheduled.Attendees) != 2 || scheduled.JobTitle == "" || scheduled.ParticipantName == "" {
|
||||
t.Fatalf("unexpected scheduled interview: %#v", scheduled)
|
||||
}
|
||||
|
||||
var coLogin webmodel.LoginResponse
|
||||
requestJSON(t, http.MethodPost, "/api/auth/login", "", map[string]any{
|
||||
"email": "co.integration@example.com", "password": "integration-password",
|
||||
}, http.StatusOK, &coLogin)
|
||||
|
||||
var inbox []webmodel.ScheduledInterviewResponse
|
||||
requestJSON(t, http.MethodGet, "/api/inbox", coLogin.Token, nil, http.StatusOK, &inbox)
|
||||
if len(inbox) != 1 || inbox[0].ID != scheduled.ID {
|
||||
t.Fatalf("scheduled interview missing from co-interviewer inbox: %#v", inbox)
|
||||
}
|
||||
requestJSON(t, http.MethodPatch, fmt.Sprintf("/api/inbox/%d", scheduled.ID),
|
||||
coLogin.Token, map[string]any{"status": "accepted"}, http.StatusOK, &scheduled)
|
||||
var calendar []webmodel.ScheduledInterviewResponse
|
||||
requestJSON(t, http.MethodGet, "/api/calendar", coLogin.Token, nil, http.StatusOK, &calendar)
|
||||
if len(calendar) != 1 || calendar[0].ID != scheduled.ID {
|
||||
t.Fatalf("accepted interview missing from calendar: %#v", calendar)
|
||||
}
|
||||
|
||||
requestJSON(t, http.MethodPost, fmt.Sprintf("/api/scheduled-interviews/%d/notes", scheduled.ID),
|
||||
login.Token, map[string]any{"body": "Too early"}, http.StatusConflict, nil)
|
||||
requestJSON(t, http.MethodPatch, fmt.Sprintf("/api/scheduled-interviews/%d/status", scheduled.ID),
|
||||
login.Token, map[string]any{"status": "started"}, http.StatusOK, &scheduled)
|
||||
|
||||
subscriptionContext, cancelSubscriptions := context.WithCancel(context.Background())
|
||||
defer cancelSubscriptions()
|
||||
firstEvents, err := testBus.SubscribeInterviewNotes(subscriptionContext)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
secondEvents, err := testBus.SubscribeInterviewNotes(subscriptionContext)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var note webmodel.InterviewNoteResponse
|
||||
requestJSON(t, http.MethodPost, fmt.Sprintf("/api/scheduled-interviews/%d/notes", scheduled.ID),
|
||||
coLogin.Token, map[string]any{"body": "Candidate explained the trade-offs clearly."},
|
||||
http.StatusCreated, ¬e)
|
||||
if note.AuthorUserID != coInterviewer.ID || note.AuthorName != "Co Interviewer" {
|
||||
t.Fatalf("unexpected note response: %#v", note)
|
||||
}
|
||||
for index, events := range []<-chan eventbus.InterviewNoteEvent{firstEvents, secondEvents} {
|
||||
select {
|
||||
case received := <-events:
|
||||
if received.ID != note.ID || received.AuthorUserID != coInterviewer.ID {
|
||||
t.Fatalf("subscriber %d received unexpected RabbitMQ event: %#v", index+1, received)
|
||||
}
|
||||
encoded, err := json.Marshal(received)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Contains(encoded, []byte(`"id":`)) || bytes.Contains(encoded, []byte(`"noteId":`)) {
|
||||
t.Fatalf("subscriber %d received an incompatible note contract: %s", index+1, encoded)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatalf("subscriber %d did not receive RabbitMQ note event", index+1)
|
||||
}
|
||||
}
|
||||
var notes []webmodel.InterviewNoteResponse
|
||||
requestJSON(t, http.MethodGet, fmt.Sprintf("/api/scheduled-interviews/%d/notes", scheduled.ID),
|
||||
login.Token, nil, http.StatusOK, ¬es)
|
||||
if len(notes) != 1 || notes[0].Body != note.Body {
|
||||
t.Fatalf("persisted interview note missing: %#v", notes)
|
||||
}
|
||||
|
||||
var meeting webmodel.ParticipantMeetingResponse
|
||||
body := requestJSON(t, http.MethodGet, "/api/participant-meetings/"+scheduled.CandidateToken,
|
||||
"", nil, http.StatusOK, &meeting)
|
||||
if meeting.SharedDocument != "Collaborative notes" ||
|
||||
bytes.Contains(body, []byte("participantEmail")) ||
|
||||
bytes.Contains(body, []byte("referenceAnswer")) ||
|
||||
bytes.Contains(body, []byte(`"notes":`)) {
|
||||
t.Fatalf("invalid candidate-safe meeting response: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func requestJSON(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue