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:
Daniel Noulet 2026-09-19 21:58:24 +02:00
parent fd06fbbb5d
commit 75e3c73a61
35 changed files with 3621 additions and 58 deletions

View file

@ -7,6 +7,8 @@ import (
"time"
"github.com/danyel/go-guess/backend/internal/database/repository"
"github.com/danyel/go-guess/backend/internal/eventbus"
"github.com/danyel/go-guess/backend/internal/security"
"github.com/danyel/go-guess/backend/internal/service/model"
)
@ -14,6 +16,7 @@ type fakeStore struct {
job model.JobPosting
participants []model.Participant
traits []model.Trait
createdUser model.User
}
type invitationStore struct {
@ -23,6 +26,58 @@ type invitationStore struct {
saved bool
}
type scheduledStore struct {
*fakeStore
invitation model.Invitation
interview model.ScheduledInterview
note model.InterviewNote
createdIDs []uint
status string
document string
}
func (f *scheduledStore) GetInvitation(context.Context, uint, uint) (model.Invitation, error) {
return f.invitation, nil
}
func (f *scheduledStore) ListUsers(context.Context) ([]model.User, error) {
return []model.User{{ID: 1}, {ID: 2}}, nil
}
func (f *scheduledStore) CreateScheduledInterview(
_ context.Context, value model.ScheduledInterview, ids []uint,
) (model.ScheduledInterview, error) {
f.createdIDs = ids
value.ID = 9
value.Attendees = []model.InterviewAttendee{{UserID: value.CreatorID, Status: "accepted"}}
f.interview = value
return value, nil
}
func (f *scheduledStore) GetScheduledInterview(context.Context, uint) (model.ScheduledInterview, error) {
return f.interview, nil
}
func (f *scheduledStore) UpdateScheduledInterviewStatus(
_ context.Context, _ uint, status string,
) (model.ScheduledInterview, error) {
f.status = status
f.interview.Status = status
return f.interview, nil
}
func (f *scheduledStore) UpdateScheduledInterviewDocument(
_ context.Context, _ uint, document string,
) (model.ScheduledInterview, error) {
f.document = document
f.interview.SharedDocument = document
return f.interview, nil
}
func (f *scheduledStore) CreateInterviewNote(
_ context.Context, value model.InterviewNote,
) (model.InterviewNote, error) {
value.ID = 4
value.Author = model.User{ID: value.AuthorID, DisplayName: "Ada"}
value.CreatedAt = time.Now()
f.note = value
return value, nil
}
func (f *invitationStore) CreateInvitation(_ context.Context, value model.Invitation) (model.Invitation, error) {
value.ID = 1
return value, nil
@ -41,6 +96,11 @@ func (f *invitationStore) ListInvitations(context.Context, uint) ([]model.Invita
func (f *fakeStore) FindUserByEmail(context.Context, string) (model.User, error) {
return model.User{}, errors.New("unused")
}
func (f *fakeStore) ListUsers(context.Context) ([]model.User, error) { return nil, nil }
func (f *fakeStore) CreateUser(_ context.Context, value model.User) (model.User, error) {
f.createdUser = value
return value, nil
}
func (f *fakeStore) ListJobs(context.Context) ([]model.JobPosting, error) { return nil, nil }
func (f *fakeStore) GetJob(context.Context, uint) (model.JobPosting, error) { return f.job, nil }
func (f *fakeStore) CreateJob(_ context.Context, value model.JobPosting) (model.JobPosting, error) {
@ -90,6 +150,57 @@ func (f *fakeStore) SaveAnswer(context.Context, string, uint, string) error {
func (f *fakeStore) CompleteInvitation(context.Context, string, time.Time) error {
return nil
}
func (f *fakeStore) GetInvitation(context.Context, uint, uint) (model.Invitation, error) {
return model.Invitation{}, nil
}
func (f *fakeStore) UpdateInvitationOutcome(
_ context.Context, _, _ uint, outcome string,
) (model.Invitation, error) {
return model.Invitation{Outcome: outcome}, nil
}
func (f *fakeStore) CreateScheduledInterview(
_ context.Context, value model.ScheduledInterview, _ []uint,
) (model.ScheduledInterview, error) {
return value, nil
}
func (f *fakeStore) ListScheduledInterviews(context.Context, uint) ([]model.ScheduledInterview, error) {
return nil, nil
}
func (f *fakeStore) GetScheduledInterview(context.Context, uint) (model.ScheduledInterview, error) {
return model.ScheduledInterview{}, nil
}
func (f *fakeStore) GetScheduledInterviewByToken(context.Context, string) (model.ScheduledInterview, error) {
return model.ScheduledInterview{}, nil
}
func (f *fakeStore) UpdateScheduledInterviewStatus(
_ context.Context, _ uint, _ string,
) (model.ScheduledInterview, error) {
return model.ScheduledInterview{}, nil
}
func (f *fakeStore) UpdateScheduledInterviewDocument(
_ context.Context, _ uint, _ string,
) (model.ScheduledInterview, error) {
return model.ScheduledInterview{}, nil
}
func (f *fakeStore) ListInterviewNotes(context.Context, uint) ([]model.InterviewNote, error) {
return nil, nil
}
func (f *fakeStore) CreateInterviewNote(
_ context.Context, value model.InterviewNote,
) (model.InterviewNote, error) {
return value, nil
}
func (f *fakeStore) ListInbox(context.Context, uint) ([]model.ScheduledInterview, error) {
return nil, nil
}
func (f *fakeStore) UpdateAttendeeStatus(
_ context.Context, _, _ uint, _ string,
) (model.InterviewAttendee, error) {
return model.InterviewAttendee{}, nil
}
func (f *fakeStore) ListCalendar(context.Context, uint) ([]model.ScheduledInterview, error) {
return nil, nil
}
func TestCandidatesRequireSixtyPercent(t *testing.T) {
store := &fakeStore{
@ -207,3 +318,107 @@ func TestInvitationReviewIsScopedToJob(t *testing.T) {
t.Fatalf("expected missing invitation error, got %v", err)
}
}
func TestInvitationOutcomeRequiresCompletedAssessment(t *testing.T) {
store := &scheduledStore{
fakeStore: &fakeStore{}, invitation: model.Invitation{Status: "accepted"},
}
invitations := NewInvitationService(store, "")
if _, err := invitations.SetOutcome(context.Background(), 1, 2, "passed"); !errors.Is(err, ErrInvalidState) {
t.Fatalf("expected incomplete assessment rejection, got %v", err)
}
store.invitation.Status = "completed"
outcome, err := invitations.SetOutcome(context.Background(), 1, 2, "passed")
if err != nil || outcome.Outcome != "passed" {
t.Fatalf("expected passed outcome, got %#v, %v", outcome, err)
}
}
func TestScheduledInterviewRequiresPassedAssessmentAndAddsCreator(t *testing.T) {
store := &scheduledStore{
fakeStore: &fakeStore{},
invitation: model.Invitation{
Status: "completed", Outcome: "passed", ParticipantID: 7,
},
}
interviews := NewScheduledInterviewService(store, eventbus.NewMemoryBus())
created, err := interviews.Create(context.Background(), 1, 5, model.ScheduledInterview{
InvitationID: 3, StartsAt: time.Now().Add(time.Hour), Location: "Room 4",
}, []uint{2})
if err != nil {
t.Fatal(err)
}
if created.CreatorID != 1 || created.ParticipantID != 7 || created.CandidateToken == "" ||
len(store.createdIDs) != 2 {
t.Fatalf("unexpected scheduled interview: %#v ids=%v", created, store.createdIDs)
}
store.invitation.Outcome = "failed"
if _, err := interviews.Create(context.Background(), 1, 5, model.ScheduledInterview{
InvitationID: 3, StartsAt: time.Now(), Location: "Room 4",
}, nil); !errors.Is(err, ErrInvalidState) {
t.Fatalf("expected failed assessment rejection, got %v", err)
}
}
func TestScheduledInterviewAuthorizationAndState(t *testing.T) {
store := &scheduledStore{
fakeStore: &fakeStore{},
interview: model.ScheduledInterview{
ID: 9, Status: "scheduled",
Attendees: []model.InterviewAttendee{{UserID: 1, Status: "accepted"}},
},
}
interviews := NewScheduledInterviewService(store, eventbus.NewMemoryBus())
if _, err := interviews.Get(context.Background(), 2, 9); !errors.Is(err, ErrForbidden) {
t.Fatalf("expected forbidden, got %v", err)
}
if _, err := interviews.UpdateStatus(context.Background(), 1, 9, "completed"); !errors.Is(err, ErrInvalidState) {
t.Fatalf("expected invalid transition, got %v", err)
}
if _, err := interviews.UpdateStatus(context.Background(), 1, 9, "started"); err != nil || store.status != "started" {
t.Fatalf("expected started transition, got %v", err)
}
if _, err := interviews.UpdateDocument(context.Background(), 2, 9, "secret"); !errors.Is(err, ErrForbidden) {
t.Fatalf("expected document authorization, got %v", err)
}
}
func TestCreateNotePersistsThenPublishesSafeEvent(t *testing.T) {
store := &scheduledStore{
fakeStore: &fakeStore{},
interview: model.ScheduledInterview{
ID: 9, Status: "started", Attendees: []model.InterviewAttendee{{UserID: 1}},
},
}
bus := eventbus.NewMemoryBus()
events, err := bus.SubscribeInterviewNotes(context.Background())
if err != nil {
t.Fatal(err)
}
interviews := NewScheduledInterviewService(store, bus)
note, err := interviews.CreateNote(context.Background(), 1, 9, " useful note ")
if err != nil {
t.Fatal(err)
}
event := <-events
if note.Body != "useful note" || event.ID != note.ID || event.AuthorName != "Ada" {
t.Fatalf("unexpected note/event: %#v %#v", note, event)
}
}
func TestUserCreationHashesPasswordAndUsesCoInterviewerRole(t *testing.T) {
store := &fakeStore{}
user, err := NewUserService(store).Create(context.Background(), " CO@Example.com ", "password1", " Ada ")
if err != nil {
t.Fatal(err)
}
if user.Email != "co@example.com" || user.DisplayName != "Ada" || user.Role != "co_interviewer" {
t.Fatalf("unexpected user: %#v", user)
}
if user.PasswordHash != "" {
t.Fatal("created user exposed password hash")
}
if !security.CheckPassword(store.createdUser.PasswordHash, "password1") {
t.Fatal("password was not bcrypt hashed before persistence")
}
}