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
261
backend/internal/service/interviews.go
Normal file
261
backend/internal/service/interviews.go
Normal file
|
|
@ -0,0 +1,261 @@
|
|||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"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"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type IUserService interface {
|
||||
List(context.Context) ([]model.User, error)
|
||||
Create(context.Context, string, string, string) (model.User, error)
|
||||
}
|
||||
|
||||
type IScheduledInterviewService interface {
|
||||
Create(context.Context, uint, uint, model.ScheduledInterview, []uint) (model.ScheduledInterview, error)
|
||||
List(context.Context, uint, uint) ([]model.ScheduledInterview, error)
|
||||
Get(context.Context, uint, uint) (model.ScheduledInterview, error)
|
||||
GetParticipantMeeting(context.Context, string) (model.ScheduledInterview, error)
|
||||
UpdateStatus(context.Context, uint, uint, string) (model.ScheduledInterview, error)
|
||||
UpdateDocument(context.Context, uint, uint, string) (model.ScheduledInterview, error)
|
||||
ListNotes(context.Context, uint, uint) ([]model.InterviewNote, error)
|
||||
CreateNote(context.Context, uint, uint, string) (model.InterviewNote, error)
|
||||
ListInbox(context.Context, uint) ([]model.ScheduledInterview, error)
|
||||
UpdateInbox(context.Context, uint, uint, string) (model.ScheduledInterview, error)
|
||||
ListCalendar(context.Context, uint) ([]model.ScheduledInterview, error)
|
||||
Subscribe(context.Context, uint, uint) (<-chan eventbus.InterviewNoteEvent, error)
|
||||
}
|
||||
|
||||
type UserService struct {
|
||||
store repository.IStore
|
||||
}
|
||||
|
||||
func NewUserService(store repository.IStore) *UserService {
|
||||
return &UserService{store: store}
|
||||
}
|
||||
|
||||
func (s *UserService) List(ctx context.Context) ([]model.User, error) {
|
||||
return s.store.ListUsers(ctx)
|
||||
}
|
||||
|
||||
func (s *UserService) Create(ctx context.Context, email, password, displayName string) (model.User, error) {
|
||||
email = strings.ToLower(strings.TrimSpace(email))
|
||||
displayName = strings.TrimSpace(displayName)
|
||||
if email == "" || displayName == "" || len(password) < 8 {
|
||||
return model.User{}, fmt.Errorf("%w: email, displayName, and an 8-character password are required", ErrValidation)
|
||||
}
|
||||
passwordHash, err := security.HashPassword(password)
|
||||
if err != nil {
|
||||
return model.User{}, err
|
||||
}
|
||||
user, err := s.store.CreateUser(ctx, model.User{
|
||||
Email: email, PasswordHash: passwordHash, DisplayName: displayName, Role: "co_interviewer",
|
||||
})
|
||||
if err != nil {
|
||||
return model.User{}, err
|
||||
}
|
||||
user.PasswordHash = ""
|
||||
return user, nil
|
||||
}
|
||||
|
||||
type ScheduledInterviewService struct {
|
||||
store repository.IStore
|
||||
bus eventbus.IEventBus
|
||||
}
|
||||
|
||||
func NewScheduledInterviewService(store repository.IStore, bus eventbus.IEventBus) *ScheduledInterviewService {
|
||||
return &ScheduledInterviewService{store: store, bus: bus}
|
||||
}
|
||||
|
||||
func (s *ScheduledInterviewService) Create(
|
||||
ctx context.Context,
|
||||
userID, jobID uint,
|
||||
value model.ScheduledInterview,
|
||||
interviewerIDs []uint,
|
||||
) (model.ScheduledInterview, error) {
|
||||
invitation, err := s.store.GetInvitation(ctx, jobID, value.InvitationID)
|
||||
if err != nil {
|
||||
return model.ScheduledInterview{}, err
|
||||
}
|
||||
if invitation.Status != "completed" || invitation.Outcome != "passed" {
|
||||
return model.ScheduledInterview{}, fmt.Errorf("%w: a passed completed assessment is required", ErrInvalidState)
|
||||
}
|
||||
value.Location = strings.TrimSpace(value.Location)
|
||||
if value.StartsAt.IsZero() || value.Location == "" {
|
||||
return model.ScheduledInterview{}, fmt.Errorf("%w: startsAt and location are required", ErrValidation)
|
||||
}
|
||||
users, err := s.store.ListUsers(ctx)
|
||||
if err != nil {
|
||||
return model.ScheduledInterview{}, err
|
||||
}
|
||||
validUsers := make(map[uint]struct{}, len(users))
|
||||
for _, user := range users {
|
||||
validUsers[user.ID] = struct{}{}
|
||||
}
|
||||
unique := map[uint]struct{}{userID: {}}
|
||||
for _, id := range interviewerIDs {
|
||||
if _, ok := validUsers[id]; !ok {
|
||||
return model.ScheduledInterview{}, fmt.Errorf("%w: interviewer %d does not exist", ErrValidation, id)
|
||||
}
|
||||
unique[id] = struct{}{}
|
||||
}
|
||||
ids := make([]uint, 0, len(unique))
|
||||
for id := range unique {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
value.JobID = jobID
|
||||
value.ParticipantID = invitation.ParticipantID
|
||||
value.CreatorID = userID
|
||||
value.CandidateToken = uuid.NewString()
|
||||
value.Status = "scheduled"
|
||||
return s.store.CreateScheduledInterview(ctx, value, ids)
|
||||
}
|
||||
|
||||
func (s *ScheduledInterviewService) List(
|
||||
ctx context.Context, userID, jobID uint,
|
||||
) ([]model.ScheduledInterview, error) {
|
||||
values, err := s.store.ListScheduledInterviews(ctx, jobID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := make([]model.ScheduledInterview, 0, len(values))
|
||||
for _, value := range values {
|
||||
if isAttendee(value, userID) {
|
||||
result = append(result, value)
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *ScheduledInterviewService) Get(
|
||||
ctx context.Context, userID, interviewID uint,
|
||||
) (model.ScheduledInterview, error) {
|
||||
value, err := s.store.GetScheduledInterview(ctx, interviewID)
|
||||
if err != nil {
|
||||
return model.ScheduledInterview{}, err
|
||||
}
|
||||
if !isAttendee(value, userID) {
|
||||
return model.ScheduledInterview{}, ErrForbidden
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func (s *ScheduledInterviewService) GetParticipantMeeting(
|
||||
ctx context.Context, token string,
|
||||
) (model.ScheduledInterview, error) {
|
||||
return s.store.GetScheduledInterviewByToken(ctx, token)
|
||||
}
|
||||
|
||||
func (s *ScheduledInterviewService) UpdateStatus(
|
||||
ctx context.Context, userID, interviewID uint, status string,
|
||||
) (model.ScheduledInterview, error) {
|
||||
current, err := s.Get(ctx, userID, interviewID)
|
||||
if err != nil {
|
||||
return model.ScheduledInterview{}, err
|
||||
}
|
||||
valid := (current.Status == "scheduled" && (status == "started" || status == "cancelled")) ||
|
||||
(current.Status == "started" && (status == "completed" || status == "cancelled"))
|
||||
if !valid {
|
||||
return model.ScheduledInterview{}, fmt.Errorf("%w: invalid interview status transition", ErrInvalidState)
|
||||
}
|
||||
return s.store.UpdateScheduledInterviewStatus(ctx, interviewID, status)
|
||||
}
|
||||
|
||||
func (s *ScheduledInterviewService) UpdateDocument(
|
||||
ctx context.Context, userID, interviewID uint, document string,
|
||||
) (model.ScheduledInterview, error) {
|
||||
if _, err := s.Get(ctx, userID, interviewID); err != nil {
|
||||
return model.ScheduledInterview{}, err
|
||||
}
|
||||
return s.store.UpdateScheduledInterviewDocument(ctx, interviewID, document)
|
||||
}
|
||||
|
||||
func (s *ScheduledInterviewService) ListNotes(
|
||||
ctx context.Context, userID, interviewID uint,
|
||||
) ([]model.InterviewNote, error) {
|
||||
if _, err := s.Get(ctx, userID, interviewID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.store.ListInterviewNotes(ctx, interviewID)
|
||||
}
|
||||
|
||||
func (s *ScheduledInterviewService) CreateNote(
|
||||
ctx context.Context, userID, interviewID uint, body string,
|
||||
) (model.InterviewNote, error) {
|
||||
interview, err := s.Get(ctx, userID, interviewID)
|
||||
if err != nil {
|
||||
return model.InterviewNote{}, err
|
||||
}
|
||||
if interview.Status != "started" {
|
||||
return model.InterviewNote{}, fmt.Errorf("%w: notes require a started interview", ErrInvalidState)
|
||||
}
|
||||
body = strings.TrimSpace(body)
|
||||
if body == "" {
|
||||
return model.InterviewNote{}, fmt.Errorf("%w: note body is required", ErrValidation)
|
||||
}
|
||||
note, err := s.store.CreateInterviewNote(ctx, model.InterviewNote{
|
||||
InterviewID: interviewID, AuthorID: userID, Body: body,
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, repository.ErrInterviewNotStarted) {
|
||||
return model.InterviewNote{}, fmt.Errorf("%w: notes require a started interview", ErrInvalidState)
|
||||
}
|
||||
return model.InterviewNote{}, err
|
||||
}
|
||||
if err := s.bus.PublishInterviewNote(ctx, eventbus.InterviewNoteEvent{
|
||||
InterviewID: note.InterviewID, ID: note.ID, AuthorUserID: note.AuthorID,
|
||||
AuthorName: note.Author.DisplayName, Body: note.Body, CreatedAt: note.CreatedAt,
|
||||
}); err != nil {
|
||||
return model.InterviewNote{}, fmt.Errorf("publish persisted interview note: %w", err)
|
||||
}
|
||||
return note, nil
|
||||
}
|
||||
|
||||
func (s *ScheduledInterviewService) ListInbox(
|
||||
ctx context.Context, userID uint,
|
||||
) ([]model.ScheduledInterview, error) {
|
||||
return s.store.ListInbox(ctx, userID)
|
||||
}
|
||||
|
||||
func (s *ScheduledInterviewService) UpdateInbox(
|
||||
ctx context.Context, userID, interviewID uint, status string,
|
||||
) (model.ScheduledInterview, error) {
|
||||
if status != "accepted" && status != "declined" {
|
||||
return model.ScheduledInterview{}, fmt.Errorf("%w: attendee status must be accepted or declined", ErrValidation)
|
||||
}
|
||||
if _, err := s.store.UpdateAttendeeStatus(ctx, interviewID, userID, status); err != nil {
|
||||
return model.ScheduledInterview{}, err
|
||||
}
|
||||
return s.Get(ctx, userID, interviewID)
|
||||
}
|
||||
|
||||
func (s *ScheduledInterviewService) ListCalendar(
|
||||
ctx context.Context, userID uint,
|
||||
) ([]model.ScheduledInterview, error) {
|
||||
return s.store.ListCalendar(ctx, userID)
|
||||
}
|
||||
|
||||
func (s *ScheduledInterviewService) Subscribe(
|
||||
ctx context.Context, userID, interviewID uint,
|
||||
) (<-chan eventbus.InterviewNoteEvent, error) {
|
||||
if _, err := s.Get(ctx, userID, interviewID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.bus.SubscribeInterviewNotes(ctx)
|
||||
}
|
||||
|
||||
func isAttendee(value model.ScheduledInterview, userID uint) bool {
|
||||
for _, attendee := range value.Attendees {
|
||||
if attendee.UserID == userID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@ type User struct {
|
|||
ID uint
|
||||
Email string
|
||||
PasswordHash string
|
||||
DisplayName string
|
||||
Role string
|
||||
}
|
||||
|
||||
|
|
@ -69,12 +70,53 @@ type Invitation struct {
|
|||
ParticipantEmail string
|
||||
Token string
|
||||
Status string
|
||||
Outcome string
|
||||
DurationMinutes int
|
||||
AcceptedAt *time.Time
|
||||
CompletedAt *time.Time
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type ScheduledInterview struct {
|
||||
ID uint
|
||||
JobID uint
|
||||
InvitationID uint
|
||||
ParticipantID uint
|
||||
CreatorID uint
|
||||
StartsAt time.Time
|
||||
Location string
|
||||
CandidateToken string
|
||||
SharedDocument string
|
||||
Status string
|
||||
Job JobPosting
|
||||
Invitation Invitation
|
||||
Participant Participant
|
||||
Creator User
|
||||
Attendees []InterviewAttendee
|
||||
Notes []InterviewNote
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type InterviewAttendee struct {
|
||||
ID uint
|
||||
InterviewID uint
|
||||
UserID uint
|
||||
Status string
|
||||
User User
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type InterviewNote struct {
|
||||
ID uint
|
||||
InterviewID uint
|
||||
AuthorID uint
|
||||
Author User
|
||||
Body string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type Interview struct {
|
||||
Invitation Invitation
|
||||
Job JobPosting
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ var (
|
|||
ErrInvalidQuestion = errors.New("invalid question")
|
||||
ErrValidation = errors.New("validation failed")
|
||||
ErrInvalidState = errors.New("operation is not allowed in the current state")
|
||||
ErrForbidden = errors.New("operation is forbidden")
|
||||
)
|
||||
|
||||
const CandidateThreshold = 60.0
|
||||
|
|
@ -60,6 +61,7 @@ type IInvitationService interface {
|
|||
Accept(context.Context, string) (model.Interview, error)
|
||||
SaveAnswer(context.Context, string, uint, string) error
|
||||
Finish(context.Context, string) (model.Interview, error)
|
||||
SetOutcome(context.Context, uint, uint, string) (model.Invitation, error)
|
||||
}
|
||||
|
||||
type AuthService struct {
|
||||
|
|
@ -460,6 +462,25 @@ func (s *InvitationService) Finish(ctx context.Context, token string) (model.Int
|
|||
return s.GetInterview(ctx, token)
|
||||
}
|
||||
|
||||
func (s *InvitationService) SetOutcome(
|
||||
ctx context.Context, jobID, invitationID uint, outcome string,
|
||||
) (model.Invitation, error) {
|
||||
switch outcome {
|
||||
case "pending":
|
||||
case "passed", "failed":
|
||||
invitation, err := s.store.GetInvitation(ctx, jobID, invitationID)
|
||||
if err != nil {
|
||||
return model.Invitation{}, err
|
||||
}
|
||||
if invitation.Status != "completed" {
|
||||
return model.Invitation{}, fmt.Errorf("%w: assessment must be completed", ErrInvalidState)
|
||||
}
|
||||
default:
|
||||
return model.Invitation{}, fmt.Errorf("%w: unsupported assessment outcome", ErrValidation)
|
||||
}
|
||||
return s.store.UpdateInvitationOutcome(ctx, jobID, invitationID, outcome)
|
||||
}
|
||||
|
||||
func interviewExpired(interview model.Interview, now time.Time) bool {
|
||||
return interview.Invitation.AcceptedAt != nil &&
|
||||
!now.Before(interview.Invitation.AcceptedAt.Add(time.Duration(interview.Job.DurationMinutes)*time.Minute))
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue