review
This commit is contained in:
parent
6ffc0d2c99
commit
6153017b62
22 changed files with 835 additions and 100 deletions
|
|
@ -38,6 +38,7 @@ type Question struct {
|
|||
ID uint `gorm:"primaryKey"`
|
||||
Text string `gorm:"type:text;not null"`
|
||||
Type string `gorm:"size:32;not null"`
|
||||
CodeSnippet string `gorm:"type:text;not null;default:''"`
|
||||
ReferenceAnswer string `gorm:"type:text;not null;default:''"`
|
||||
Deprecated bool `gorm:"not null;default:false"`
|
||||
Options []QuestionOption `gorm:"foreignKey:QuestionID;constraint:OnDelete:CASCADE"`
|
||||
|
|
|
|||
|
|
@ -46,8 +46,9 @@ func questionsToService(values []dbmodel.Question) []servicemodel.Question {
|
|||
}
|
||||
result[i] = servicemodel.Question{
|
||||
ID: value.ID, Text: value.Text, Type: value.Type,
|
||||
Options: options, ReferenceAnswer: value.ReferenceAnswer,
|
||||
Deprecated: value.Deprecated,
|
||||
Options: options, CodeSnippet: value.CodeSnippet,
|
||||
ReferenceAnswer: value.ReferenceAnswer,
|
||||
Deprecated: value.Deprecated,
|
||||
}
|
||||
}
|
||||
return result
|
||||
|
|
|
|||
|
|
@ -147,7 +147,8 @@ func (s *Store) CreateQuestion(ctx context.Context, value servicemodel.Question)
|
|||
}
|
||||
entity := dbmodel.Question{
|
||||
Text: value.Text, Type: value.Type, Options: options,
|
||||
ReferenceAnswer: value.ReferenceAnswer, Deprecated: value.Deprecated,
|
||||
CodeSnippet: value.CodeSnippet, ReferenceAnswer: value.ReferenceAnswer,
|
||||
Deprecated: value.Deprecated,
|
||||
}
|
||||
if err := s.db.WithContext(ctx).Create(&entity).Error; err != nil {
|
||||
return servicemodel.Question{}, mapError(err)
|
||||
|
|
@ -159,7 +160,8 @@ func (s *Store) UpdateQuestion(ctx context.Context, id uint, value servicemodel.
|
|||
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
result := tx.Model(&dbmodel.Question{}).Where("id = ?", id).
|
||||
Updates(map[string]any{
|
||||
"text": value.Text, "type": value.Type, "reference_answer": value.ReferenceAnswer,
|
||||
"text": value.Text, "type": value.Type, "code_snippet": value.CodeSnippet,
|
||||
"reference_answer": value.ReferenceAnswer,
|
||||
})
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ type Question struct {
|
|||
Text string
|
||||
Type string
|
||||
Options []string
|
||||
CodeSnippet string
|
||||
ReferenceAnswer string
|
||||
Deprecated bool
|
||||
}
|
||||
|
|
|
|||
|
|
@ -55,6 +55,7 @@ type IParticipantService interface {
|
|||
type IInvitationService interface {
|
||||
List(context.Context, uint) ([]model.Invitation, error)
|
||||
Create(context.Context, uint, uint) (model.Invitation, error)
|
||||
Review(context.Context, uint, uint) (model.Interview, error)
|
||||
GetInterview(context.Context, string) (model.Interview, error)
|
||||
Accept(context.Context, string) (model.Interview, error)
|
||||
SaveAnswer(context.Context, string, uint, string) error
|
||||
|
|
@ -181,6 +182,7 @@ func (s *QuestionService) Update(ctx context.Context, id uint, value model.Quest
|
|||
|
||||
func validateQuestion(value model.Question) (model.Question, error) {
|
||||
value.Text = strings.TrimSpace(value.Text)
|
||||
value.CodeSnippet = strings.TrimSpace(value.CodeSnippet)
|
||||
value.ReferenceAnswer = strings.TrimSpace(value.ReferenceAnswer)
|
||||
switch value.Type {
|
||||
case "open", "multiple_choice", "radio", "code_review":
|
||||
|
|
@ -203,11 +205,17 @@ func validateQuestion(value model.Question) (model.Question, error) {
|
|||
if (value.Type == "open" || value.Type == "code_review") && value.ReferenceAnswer == "" {
|
||||
return model.Question{}, fmt.Errorf("%w: a reference answer is required", ErrInvalidQuestion)
|
||||
}
|
||||
if value.Type == "code_review" && value.CodeSnippet == "" {
|
||||
return model.Question{}, fmt.Errorf("%w: a code snippet is required", ErrInvalidQuestion)
|
||||
}
|
||||
if value.Type == "open" || value.Type == "code_review" {
|
||||
value.Options = nil
|
||||
} else {
|
||||
value.ReferenceAnswer = ""
|
||||
}
|
||||
if value.Type != "code_review" {
|
||||
value.CodeSnippet = ""
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
|
|
@ -376,6 +384,19 @@ func (s *InvitationService) Create(ctx context.Context, jobID, participantID uin
|
|||
})
|
||||
}
|
||||
|
||||
func (s *InvitationService) Review(ctx context.Context, jobID, invitationID uint) (model.Interview, error) {
|
||||
invitations, err := s.store.ListInvitations(ctx, jobID)
|
||||
if err != nil {
|
||||
return model.Interview{}, err
|
||||
}
|
||||
for _, invitation := range invitations {
|
||||
if invitation.ID == invitationID {
|
||||
return s.store.GetInterview(ctx, invitation.Token)
|
||||
}
|
||||
}
|
||||
return model.Interview{}, repository.ErrNotFound
|
||||
}
|
||||
|
||||
func (s *InvitationService) GetInterview(ctx context.Context, token string) (model.Interview, error) {
|
||||
interview, err := s.store.GetInterview(ctx, token)
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import (
|
|||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/danyel/go-guess/backend/internal/database/repository"
|
||||
"github.com/danyel/go-guess/backend/internal/service/model"
|
||||
)
|
||||
|
||||
|
|
@ -17,8 +18,9 @@ type fakeStore struct {
|
|||
|
||||
type invitationStore struct {
|
||||
*fakeStore
|
||||
interview model.Interview
|
||||
saved bool
|
||||
interview model.Interview
|
||||
invitations []model.Invitation
|
||||
saved bool
|
||||
}
|
||||
|
||||
func (f *invitationStore) CreateInvitation(_ context.Context, value model.Invitation) (model.Invitation, error) {
|
||||
|
|
@ -32,6 +34,9 @@ func (f *invitationStore) SaveAnswer(context.Context, string, uint, string) erro
|
|||
f.saved = true
|
||||
return nil
|
||||
}
|
||||
func (f *invitationStore) ListInvitations(context.Context, uint) ([]model.Invitation, error) {
|
||||
return f.invitations, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) FindUserByEmail(context.Context, string) (model.User, error) {
|
||||
return model.User{}, errors.New("unused")
|
||||
|
|
@ -179,4 +184,26 @@ func TestQuestionReferenceAnswerValidation(t *testing.T) {
|
|||
if question.ReferenceAnswer != "" || question.Options[0] != "One" {
|
||||
t.Fatalf("unexpected normalized question: %#v", question)
|
||||
}
|
||||
|
||||
if _, err := service.Create(context.Background(), model.Question{
|
||||
Text: "Review this change", Type: "code_review", ReferenceAnswer: "Spot the race",
|
||||
}); !errors.Is(err, ErrInvalidQuestion) {
|
||||
t.Fatalf("expected missing code snippet error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvitationReviewIsScopedToJob(t *testing.T) {
|
||||
store := &invitationStore{
|
||||
fakeStore: &fakeStore{},
|
||||
invitations: []model.Invitation{{ID: 3, JobID: 8, Token: "opaque-token"}},
|
||||
interview: model.Interview{Invitation: model.Invitation{ID: 3, JobID: 8}},
|
||||
}
|
||||
service := NewInvitationService(store, "")
|
||||
review, err := service.Review(context.Background(), 8, 3)
|
||||
if err != nil || review.Invitation.ID != 3 {
|
||||
t.Fatalf("expected invitation review, got %#v, %v", review, err)
|
||||
}
|
||||
if _, err := service.Review(context.Background(), 8, 4); !errors.Is(err, repository.ErrNotFound) {
|
||||
t.Fatalf("expected missing invitation error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -317,6 +317,14 @@ func (h *Handler) CreateInvitation(w http.ResponseWriter, r *http.Request) {
|
|||
writeJSON(w, http.StatusCreated, webmapper.InvitationToWeb(value, h.frontendURL))
|
||||
}
|
||||
|
||||
func (h *Handler) ReviewInvitation(w http.ResponseWriter, r *http.Request) {
|
||||
value, err := h.invitations.Review(r.Context(), idParam(r), secondaryIDParam(r, "invitationId"))
|
||||
if handleServiceError(w, r, err) {
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, webmapper.InvitationReviewToWeb(value, h.frontendURL))
|
||||
}
|
||||
|
||||
func (h *Handler) GetInterview(w http.ResponseWriter, r *http.Request) {
|
||||
value, err := h.invitations.GetInterview(r.Context(), chi.URLParam(r, "token"))
|
||||
if handleServiceError(w, r, err) {
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ func JobToService(value webmodel.JobRequest) servicemodel.JobPosting {
|
|||
func QuestionToService(value webmodel.QuestionRequest) servicemodel.Question {
|
||||
return servicemodel.Question{
|
||||
Text: value.Text, Type: value.Type, Options: value.Options,
|
||||
ReferenceAnswer: value.ReferenceAnswer,
|
||||
CodeSnippet: value.CodeSnippet, ReferenceAnswer: value.ReferenceAnswer,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -41,7 +41,8 @@ func JobToWeb(value servicemodel.JobPosting) webmodel.JobResponse {
|
|||
func QuestionToWeb(value servicemodel.Question) webmodel.QuestionResponse {
|
||||
return webmodel.QuestionResponse{
|
||||
ID: value.ID, Text: value.Text, Type: value.Type, Options: value.Options,
|
||||
ReferenceAnswer: value.ReferenceAnswer, Deprecated: value.Deprecated,
|
||||
CodeSnippet: value.CodeSnippet, ReferenceAnswer: value.ReferenceAnswer,
|
||||
Deprecated: value.Deprecated,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -76,7 +77,7 @@ func InvitationToWeb(value servicemodel.Invitation, frontendURL string) webmodel
|
|||
ID: value.ID, JobID: value.JobID, ParticipantID: value.ParticipantID,
|
||||
ParticipantName: value.ParticipantName, ParticipantEmail: value.ParticipantEmail,
|
||||
Token: value.Token, Status: value.Status,
|
||||
ParticipantURL: frontendURL + "/interview/" + value.Token,
|
||||
ParticipantURL: frontendURL + "/participant/" + value.Token,
|
||||
AcceptedAt: value.AcceptedAt, CompletedAt: value.CompletedAt, CreatedAt: value.CreatedAt,
|
||||
}
|
||||
}
|
||||
|
|
@ -85,7 +86,8 @@ func InterviewToWeb(value servicemodel.Interview, frontendURL string) webmodel.I
|
|||
questions := make([]webmodel.InterviewQuestionResponse, len(value.Job.Questions))
|
||||
for i, question := range value.Job.Questions {
|
||||
questions[i] = webmodel.InterviewQuestionResponse{
|
||||
ID: question.ID, Text: question.Text, Type: question.Type, Options: question.Options,
|
||||
ID: question.ID, Text: question.Text, Type: question.Type,
|
||||
Options: question.Options, CodeSnippet: question.CodeSnippet,
|
||||
}
|
||||
}
|
||||
return webmodel.InterviewResponse{
|
||||
|
|
@ -99,6 +101,14 @@ func InterviewToWeb(value servicemodel.Interview, frontendURL string) webmodel.I
|
|||
}
|
||||
}
|
||||
|
||||
func InvitationReviewToWeb(value servicemodel.Interview, frontendURL string) webmodel.InvitationReviewResponse {
|
||||
return webmodel.InvitationReviewResponse{
|
||||
Invitation: InvitationToWeb(value.Invitation, frontendURL),
|
||||
Job: JobToWeb(value.Job),
|
||||
Answers: value.Answers,
|
||||
}
|
||||
}
|
||||
|
||||
func traitsToService(values []string) []servicemodel.Trait {
|
||||
result := make([]servicemodel.Trait, len(values))
|
||||
for i, value := range values {
|
||||
|
|
|
|||
21
backend/internal/web/mapper/mapper_test.go
Normal file
21
backend/internal/web/mapper/mapper_test.go
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
package mapper
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
servicemodel "github.com/danyel/go-guess/backend/internal/service/model"
|
||||
)
|
||||
|
||||
func TestInterviewOmitsReferenceAnswer(t *testing.T) {
|
||||
response := InterviewToWeb(servicemodel.Interview{
|
||||
Job: servicemodel.JobPosting{
|
||||
Questions: []servicemodel.Question{{
|
||||
ID: 1, Type: "code_review", Text: "Review this",
|
||||
CodeSnippet: "return nil", ReferenceAnswer: "Should return the error",
|
||||
}},
|
||||
},
|
||||
}, "http://localhost")
|
||||
if len(response.Job.Questions) != 1 || response.Job.Questions[0].CodeSnippet != "return nil" {
|
||||
t.Fatalf("expected participant-safe code question, got %#v", response)
|
||||
}
|
||||
}
|
||||
|
|
@ -52,6 +52,7 @@ type QuestionRequest struct {
|
|||
Text string `json:"text"`
|
||||
Type string `json:"type"`
|
||||
Options []string `json:"options"`
|
||||
CodeSnippet string `json:"codeSnippet"`
|
||||
ReferenceAnswer string `json:"referenceAnswer"`
|
||||
}
|
||||
|
||||
|
|
@ -60,6 +61,7 @@ type QuestionResponse struct {
|
|||
Text string `json:"text"`
|
||||
Type string `json:"type"`
|
||||
Options []string `json:"options"`
|
||||
CodeSnippet string `json:"codeSnippet"`
|
||||
ReferenceAnswer string `json:"referenceAnswer"`
|
||||
Deprecated bool `json:"deprecated"`
|
||||
}
|
||||
|
|
@ -123,10 +125,11 @@ type InterviewJobResponse struct {
|
|||
}
|
||||
|
||||
type InterviewQuestionResponse struct {
|
||||
ID uint `json:"id"`
|
||||
Text string `json:"text"`
|
||||
Type string `json:"type"`
|
||||
Options []string `json:"options"`
|
||||
ID uint `json:"id"`
|
||||
Text string `json:"text"`
|
||||
Type string `json:"type"`
|
||||
Options []string `json:"options"`
|
||||
CodeSnippet string `json:"codeSnippet"`
|
||||
}
|
||||
|
||||
type InterviewResponse struct {
|
||||
|
|
@ -135,6 +138,12 @@ type InterviewResponse struct {
|
|||
Answers map[uint]string `json:"answers"`
|
||||
}
|
||||
|
||||
type InvitationReviewResponse struct {
|
||||
Invitation InvitationResponse `json:"invitation"`
|
||||
Job JobResponse `json:"job"`
|
||||
Answers map[uint]string `json:"answers"`
|
||||
}
|
||||
|
||||
type AnswerRequest struct {
|
||||
Answer string `json:"answer"`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ func New(h *handler.Handler, tokens security.ITokenManager, frontendURL string)
|
|||
protected.Get("/api/jobs/{id}/candidates", h.Candidates)
|
||||
protected.Get("/api/jobs/{id}/invitations", h.ListInvitations)
|
||||
protected.Post("/api/jobs/{id}/invitations", h.CreateInvitation)
|
||||
protected.Get("/api/jobs/{id}/invitations/{invitationId}", h.ReviewInvitation)
|
||||
protected.Get("/api/questions", h.ListQuestions)
|
||||
protected.Post("/api/questions", h.CreateQuestion)
|
||||
protected.Put("/api/questions/{id}", h.UpdateQuestion)
|
||||
|
|
|
|||
|
|
@ -64,6 +64,9 @@ func (invitationStub) List(context.Context, uint) ([]model.Invitation, error) {
|
|||
func (invitationStub) Create(context.Context, uint, uint) (model.Invitation, error) {
|
||||
return model.Invitation{}, nil
|
||||
}
|
||||
func (invitationStub) Review(context.Context, uint, uint) (model.Interview, error) {
|
||||
return model.Interview{}, nil
|
||||
}
|
||||
func (invitationStub) GetInterview(context.Context, string) (model.Interview, error) {
|
||||
return model.Interview{}, nil
|
||||
}
|
||||
|
|
|
|||
10
backend/migrations/00005_question_code_snippet.sql
Normal file
10
backend/migrations/00005_question_code_snippet.sql
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
-- +goose Up
|
||||
ALTER TABLE questions
|
||||
ADD COLUMN code_snippet TEXT NOT NULL DEFAULT '';
|
||||
|
||||
UPDATE questions
|
||||
SET code_snippet = E'function CandidateList({ candidates }) {\n return candidates.map(candidate => (\n <CandidateCard candidate={candidate} />\n ));\n}'
|
||||
WHERE type = 'code_review' AND code_snippet = '';
|
||||
|
||||
-- +goose Down
|
||||
ALTER TABLE questions DROP COLUMN code_snippet;
|
||||
125
backend/test_review_endpoint.go
Normal file
125
backend/test_review_endpoint.go
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/danyel/go-guess/backend/internal/security"
|
||||
"github.com/danyel/go-guess/backend/internal/service/model"
|
||||
"github.com/danyel/go-guess/backend/internal/web/handler"
|
||||
"github.com/danyel/go-guess/backend/internal/web/router"
|
||||
)
|
||||
|
||||
type mockInvitationService struct{}
|
||||
|
||||
func (m mockInvitationService) List(ctx context.Context, jobID uint) ([]model.Invitation, error) {
|
||||
return []model.Invitation{{ID: 1}}, nil
|
||||
}
|
||||
|
||||
func (m mockInvitationService) Create(ctx context.Context, jobID, participantID uint) (model.Invitation, error) {
|
||||
return model.Invitation{}, nil
|
||||
}
|
||||
|
||||
func (m mockInvitationService) Review(ctx context.Context, jobID, invitationID uint) (model.Interview, error) {
|
||||
return model.Interview{
|
||||
Invitation: model.Invitation{ID: 1, ParticipantName: "Test", ParticipantEmail: "test@example.com", Status: "completed"},
|
||||
Job: model.JobPosting{ID: 1, Title: "Job"},
|
||||
Answers: map[uint]string{1: "My answer"},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m mockInvitationService) GetInterview(ctx context.Context, token string) (model.Interview, error) {
|
||||
return model.Interview{}, nil
|
||||
}
|
||||
|
||||
func (m mockInvitationService) Accept(ctx context.Context, token string) (model.Interview, error) {
|
||||
return model.Interview{}, nil
|
||||
}
|
||||
|
||||
func (m mockInvitationService) SaveAnswer(ctx context.Context, token string, questionID uint, answer string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m mockInvitationService) Finish(ctx context.Context, token string) (model.Interview, error) {
|
||||
return model.Interview{}, nil
|
||||
}
|
||||
|
||||
type mockStubs struct{}
|
||||
|
||||
func (mockStubs) Login(context.Context, string, string) (string, model.User, error) {
|
||||
return "", model.User{}, nil
|
||||
}
|
||||
|
||||
func (mockStubs) List(context.Context) ([]model.JobPosting, error) {
|
||||
return []model.JobPosting{}, nil
|
||||
}
|
||||
func (mockStubs) Get(context.Context, uint) (model.JobPosting, error) {
|
||||
return model.JobPosting{}, nil
|
||||
}
|
||||
func (mockStubs) Create(context.Context, model.JobPosting) (model.JobPosting, error) {
|
||||
return model.JobPosting{}, nil
|
||||
}
|
||||
func (mockStubs) Update(context.Context, uint, string, int) (model.JobPosting, error) {
|
||||
return model.JobPosting{}, nil
|
||||
}
|
||||
func (mockStubs) AttachQuestion(context.Context, uint, uint) error { return nil }
|
||||
func (mockStubs) DetachQuestion(context.Context, uint, uint) error { return nil }
|
||||
func (mockStubs) Candidates(context.Context, uint) ([]model.CandidateMatch, error) {
|
||||
return []model.CandidateMatch{}, nil
|
||||
}
|
||||
|
||||
func (mockStubs) List2(context.Context) ([]model.Participant, error) { return nil, nil }
|
||||
func (mockStubs) Get2(context.Context, uint) (model.Participant, error) {
|
||||
return model.Participant{}, nil
|
||||
}
|
||||
func (mockStubs) Create2(context.Context, model.Participant) (model.Participant, error) {
|
||||
return model.Participant{}, nil
|
||||
}
|
||||
|
||||
func (mockStubs) List3(context.Context, string) ([]model.Question, error) { return nil, nil }
|
||||
func (mockStubs) Create3(context.Context, model.Question) (model.Question, error) {
|
||||
return model.Question{}, nil
|
||||
}
|
||||
func (mockStubs) Update3(context.Context, uint, model.Question) (model.Question, error) {
|
||||
return model.Question{}, nil
|
||||
}
|
||||
func (mockStubs) SetDeprecated(context.Context, uint, bool) (model.Question, error) {
|
||||
return model.Question{}, nil
|
||||
}
|
||||
|
||||
func TestReviewEndpoint(t *testing.T) {
|
||||
// Setup
|
||||
mockAuth := mockStubs{}
|
||||
mockJobs := mockStubs{}
|
||||
mockQuestions := mockStubs{}
|
||||
mockParticipants := mockStubs{}
|
||||
mockInvitations := mockInvitationService{}
|
||||
|
||||
h := handler.New(mockAuth, mockJobs, mockQuestions, mockParticipants, mockInvitations, 10, "http://localhost:5173")
|
||||
tokens := security.NewTokenManager("01234567890123456789012345678901", time.Hour)
|
||||
r := router.New(h, tokens, "http://localhost:5173")
|
||||
|
||||
// Create a valid token
|
||||
token, err := tokens.Issue(1, "test@example.com", "interviewer")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create token: %v", err)
|
||||
}
|
||||
|
||||
// Test the review endpoint
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/jobs/1/invitations/1", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("Expected 200, got %d. Response: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
if w.Header().Get("Content-Type") != "application/json" {
|
||||
t.Errorf("Expected JSON content type, got %s", w.Header().Get("Content-Type"))
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue