initial commit

This commit is contained in:
Daniel Noulet 2026-09-19 20:31:14 +02:00
commit 6ffc0d2c99
54 changed files with 11900 additions and 0 deletions

View file

@ -0,0 +1,78 @@
package service
import (
"archive/zip"
"bytes"
"encoding/xml"
"fmt"
"io"
"path/filepath"
"strings"
"github.com/ledongthuc/pdf"
)
func extractCVText(filename string, content []byte) (text string, err error) {
if len(content) == 0 {
return "", nil
}
switch strings.ToLower(filepath.Ext(filename)) {
case ".pdf":
defer func() {
if recovered := recover(); recovered != nil {
text = ""
err = fmt.Errorf("unreadable PDF")
}
}()
reader, err := pdf.NewReader(bytes.NewReader(content), int64(len(content)))
if err != nil {
return "", fmt.Errorf("read PDF: %w", err)
}
plainText, err := reader.GetPlainText()
if err != nil {
return "", fmt.Errorf("extract PDF text: %w", err)
}
value, err := io.ReadAll(plainText)
if err != nil {
return "", fmt.Errorf("read PDF text: %w", err)
}
return string(value), nil
case ".docx":
return extractDOCX(content)
default:
return string(content), nil
}
}
func extractDOCX(content []byte) (string, error) {
archive, err := zip.NewReader(bytes.NewReader(content), int64(len(content)))
if err != nil {
return "", fmt.Errorf("open DOCX: %w", err)
}
for _, file := range archive.File {
if file.Name != "word/document.xml" {
continue
}
stream, err := file.Open()
if err != nil {
return "", fmt.Errorf("open DOCX document: %w", err)
}
defer stream.Close()
decoder := xml.NewDecoder(stream)
var text strings.Builder
for {
token, err := decoder.Token()
if err == io.EOF {
return text.String(), nil
}
if err != nil {
return "", fmt.Errorf("parse DOCX document: %w", err)
}
if chars, ok := token.(xml.CharData); ok {
text.Write(chars)
text.WriteByte(' ')
}
}
}
return "", fmt.Errorf("DOCX document body is missing")
}

View file

@ -0,0 +1,30 @@
package service
import (
"archive/zip"
"bytes"
"testing"
)
func TestExtractCVTextFromDOCX(t *testing.T) {
var content bytes.Buffer
archive := zip.NewWriter(&content)
document, err := archive.Create("word/document.xml")
if err != nil {
t.Fatal(err)
}
if _, err := document.Write([]byte(`<w:document xmlns:w="urn:test"><w:body><w:p><w:r><w:t>Go and PostgreSQL</w:t></w:r></w:p></w:body></w:document>`)); err != nil {
t.Fatal(err)
}
if err := archive.Close(); err != nil {
t.Fatal(err)
}
text, err := extractCVText("candidate.docx", content.Bytes())
if err != nil {
t.Fatal(err)
}
if text != "Go and PostgreSQL " {
t.Fatalf("unexpected extracted text %q", text)
}
}

View file

@ -0,0 +1,81 @@
package model
import "time"
type User struct {
ID uint
Email string
PasswordHash string
Role string
}
type Trait struct {
ID uint
Name string
}
type JobPosting struct {
ID uint
Title string
Description string
Seniority string
Position string
Status string
DurationMinutes int
Labels []Trait
RequiredSkills []Trait
AdditionalSkills []Trait
Questions []Question
CreatedAt time.Time
}
type Question struct {
ID uint
Text string
Type string
Options []string
ReferenceAnswer string
Deprecated bool
}
type Participant struct {
ID uint
FirstName string
LastName string
Birthday *time.Time
Email string
ContactInfo string
Photo []byte
PhotoType string
CV []byte
CVFilename string
Traits []Trait
CreatedAt time.Time
}
type CandidateMatch struct {
Participant Participant
Score float64
Matched []string
Missing []string
}
type Invitation struct {
ID uint
JobID uint
ParticipantID uint
ParticipantName string
ParticipantEmail string
Token string
Status string
DurationMinutes int
AcceptedAt *time.Time
CompletedAt *time.Time
CreatedAt time.Time
}
type Interview struct {
Invitation Invitation
Job JobPosting
Answers map[uint]string
}

View file

@ -0,0 +1,454 @@
package service
import (
"context"
"errors"
"fmt"
"math"
"sort"
"strings"
"time"
"unicode"
"github.com/danyel/go-guess/backend/internal/database/repository"
"github.com/danyel/go-guess/backend/internal/security"
"github.com/danyel/go-guess/backend/internal/service/model"
"github.com/google/uuid"
)
var (
ErrInvalidCredentials = errors.New("invalid email or password")
ErrInvalidQuestion = errors.New("invalid question")
ErrValidation = errors.New("validation failed")
ErrInvalidState = errors.New("operation is not allowed in the current state")
)
const CandidateThreshold = 60.0
type IAuthService interface {
Login(context.Context, string, string) (string, model.User, error)
}
type IJobService interface {
List(context.Context) ([]model.JobPosting, error)
Get(context.Context, uint) (model.JobPosting, error)
Create(context.Context, model.JobPosting) (model.JobPosting, error)
Update(context.Context, uint, string, int) (model.JobPosting, error)
AttachQuestion(context.Context, uint, uint) error
DetachQuestion(context.Context, uint, uint) error
Candidates(context.Context, uint) ([]model.CandidateMatch, error)
}
type IQuestionService interface {
List(context.Context, string) ([]model.Question, error)
Create(context.Context, model.Question) (model.Question, error)
Update(context.Context, uint, model.Question) (model.Question, error)
SetDeprecated(context.Context, uint, bool) (model.Question, error)
}
type IParticipantService interface {
List(context.Context) ([]model.Participant, error)
Get(context.Context, uint) (model.Participant, error)
Create(context.Context, model.Participant) (model.Participant, error)
}
type IInvitationService interface {
List(context.Context, uint) ([]model.Invitation, error)
Create(context.Context, uint, uint) (model.Invitation, error)
GetInterview(context.Context, string) (model.Interview, error)
Accept(context.Context, string) (model.Interview, error)
SaveAnswer(context.Context, string, uint, string) error
Finish(context.Context, string) (model.Interview, error)
}
type AuthService struct {
store repository.IStore
tokens security.ITokenManager
}
func NewAuthService(store repository.IStore, tokens security.ITokenManager) *AuthService {
return &AuthService{store: store, tokens: tokens}
}
func (s *AuthService) Login(ctx context.Context, email, password string) (string, model.User, error) {
user, err := s.store.FindUserByEmail(ctx, strings.TrimSpace(email))
if err != nil || !security.CheckPassword(user.PasswordHash, password) {
return "", model.User{}, ErrInvalidCredentials
}
token, err := s.tokens.Issue(user.ID, user.Email, user.Role)
if err != nil {
return "", model.User{}, err
}
user.PasswordHash = ""
return token, user, nil
}
type JobService struct {
store repository.IStore
}
func NewJobService(store repository.IStore) *JobService {
return &JobService{store: store}
}
func (s *JobService) List(ctx context.Context) ([]model.JobPosting, error) {
return s.store.ListJobs(ctx)
}
func (s *JobService) Get(ctx context.Context, id uint) (model.JobPosting, error) {
return s.store.GetJob(ctx, id)
}
func (s *JobService) Create(ctx context.Context, value model.JobPosting) (model.JobPosting, error) {
value.Title = strings.TrimSpace(value.Title)
value.Description = strings.TrimSpace(value.Description)
value.Position = strings.TrimSpace(value.Position)
value.Seniority = strings.TrimSpace(value.Seniority)
if value.Title == "" || value.Description == "" || value.Position == "" || value.Seniority == "" {
return model.JobPosting{}, fmt.Errorf("%w: title, description, position, and seniority are required", ErrValidation)
}
value.Status = "draft"
if value.DurationMinutes == 0 {
value.DurationMinutes = 60
}
if value.DurationMinutes < 1 || value.DurationMinutes > 480 {
return model.JobPosting{}, fmt.Errorf("%w: duration must be between 1 and 480 minutes", ErrValidation)
}
return s.store.CreateJob(ctx, value)
}
func (s *JobService) Update(ctx context.Context, id uint, status string, durationMinutes int) (model.JobPosting, error) {
if !validJobStatus(status) {
return model.JobPosting{}, fmt.Errorf("%w: unsupported job status", ErrValidation)
}
if durationMinutes < 1 || durationMinutes > 480 {
return model.JobPosting{}, fmt.Errorf("%w: duration must be between 1 and 480 minutes", ErrValidation)
}
if status == "published" {
job, err := s.store.GetJob(ctx, id)
if err != nil {
return model.JobPosting{}, err
}
if len(job.Questions) == 0 {
return model.JobPosting{}, fmt.Errorf("%w: a published job requires at least one question", ErrInvalidState)
}
}
return s.store.UpdateJob(ctx, id, status, durationMinutes)
}
func (s *JobService) AttachQuestion(ctx context.Context, jobID, questionID uint) error {
question, err := s.store.GetQuestion(ctx, questionID)
if err != nil {
return err
}
if question.Deprecated {
return fmt.Errorf("%w: deprecated questions cannot be newly attached", ErrInvalidState)
}
return s.store.AttachQuestion(ctx, jobID, questionID)
}
func (s *JobService) DetachQuestion(ctx context.Context, jobID, questionID uint) error {
return s.store.DetachQuestion(ctx, jobID, questionID)
}
type QuestionService struct {
store repository.IStore
}
func NewQuestionService(store repository.IStore) *QuestionService {
return &QuestionService{store: store}
}
func (s *QuestionService) List(ctx context.Context, search string) ([]model.Question, error) {
return s.store.ListQuestions(ctx, search)
}
func (s *QuestionService) Create(ctx context.Context, value model.Question) (model.Question, error) {
value, err := validateQuestion(value)
if err != nil {
return model.Question{}, err
}
return s.store.CreateQuestion(ctx, value)
}
func (s *QuestionService) Update(ctx context.Context, id uint, value model.Question) (model.Question, error) {
value, err := validateQuestion(value)
if err != nil {
return model.Question{}, err
}
return s.store.UpdateQuestion(ctx, id, value)
}
func validateQuestion(value model.Question) (model.Question, error) {
value.Text = strings.TrimSpace(value.Text)
value.ReferenceAnswer = strings.TrimSpace(value.ReferenceAnswer)
switch value.Type {
case "open", "multiple_choice", "radio", "code_review":
default:
return model.Question{}, fmt.Errorf("%w: unsupported type", ErrInvalidQuestion)
}
if value.Text == "" {
return model.Question{}, fmt.Errorf("%w: text is required", ErrInvalidQuestion)
}
options := make([]string, 0, len(value.Options))
for _, option := range value.Options {
if option = strings.TrimSpace(option); option != "" {
options = append(options, option)
}
}
value.Options = options
if (value.Type == "multiple_choice" || value.Type == "radio") && len(value.Options) < 2 {
return model.Question{}, fmt.Errorf("%w: at least two options are required", ErrInvalidQuestion)
}
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 == "open" || value.Type == "code_review" {
value.Options = nil
} else {
value.ReferenceAnswer = ""
}
return value, nil
}
func (s *QuestionService) SetDeprecated(ctx context.Context, id uint, deprecated bool) (model.Question, error) {
return s.store.SetQuestionDeprecated(ctx, id, deprecated)
}
func (s *JobService) Candidates(ctx context.Context, jobID uint) ([]model.CandidateMatch, error) {
job, err := s.store.GetJob(ctx, jobID)
if err != nil {
return nil, err
}
participants, err := s.store.ListParticipants(ctx)
if err != nil {
return nil, err
}
required := uniqueTraits(job.Labels, job.RequiredSkills, job.AdditionalSkills)
if len(required) == 0 {
return []model.CandidateMatch{}, nil
}
result := make([]model.CandidateMatch, 0)
for _, participant := range participants {
owned := make(map[string]struct{}, len(participant.Traits))
for _, trait := range participant.Traits {
owned[normalize(trait.Name)] = struct{}{}
}
text, err := extractCVText(participant.CVFilename, participant.CV)
if err != nil {
return nil, fmt.Errorf("extract participant %d CV text: %w", participant.ID, err)
}
for name := range required {
if containsTerm(normalize(text), name) {
owned[name] = struct{}{}
}
}
match := model.CandidateMatch{Participant: participant}
for name, display := range required {
if _, ok := owned[name]; ok {
match.Matched = append(match.Matched, display)
} else {
match.Missing = append(match.Missing, display)
}
}
match.Score = math.Round(float64(len(match.Matched))/float64(len(required))*1000) / 10
if match.Score >= CandidateThreshold {
sort.Strings(match.Matched)
sort.Strings(match.Missing)
result = append(result, match)
}
}
sort.Slice(result, func(i, j int) bool { return result[i].Score > result[j].Score })
return result, nil
}
type ParticipantService struct {
store repository.IStore
}
func NewParticipantService(store repository.IStore) *ParticipantService {
return &ParticipantService{store: store}
}
func (s *ParticipantService) List(ctx context.Context) ([]model.Participant, error) {
return s.store.ListParticipants(ctx)
}
func (s *ParticipantService) Get(ctx context.Context, id uint) (model.Participant, error) {
return s.store.GetParticipant(ctx, id)
}
func (s *ParticipantService) Create(ctx context.Context, value model.Participant) (model.Participant, error) {
value.FirstName = strings.TrimSpace(value.FirstName)
value.LastName = strings.TrimSpace(value.LastName)
value.Email = strings.ToLower(strings.TrimSpace(value.Email))
if value.FirstName == "" || value.LastName == "" || value.Email == "" {
return model.Participant{}, fmt.Errorf("%w: first name, last name, and email are required", ErrValidation)
}
allTraits, err := s.store.AllTraits(ctx)
if err != nil {
return model.Participant{}, err
}
text, err := extractCVText(value.CVFilename, value.CV)
if err != nil {
return model.Participant{}, fmt.Errorf("%w: extract CV text: %v", ErrValidation, err)
}
value.Traits = extractTraits(text, allTraits)
return s.store.CreateParticipant(ctx, value)
}
func extractTraits(content string, traits []model.Trait) []model.Trait {
text := normalize(content)
result := make([]model.Trait, 0)
for _, trait := range traits {
name := normalize(trait.Name)
if name != "" && containsTerm(text, name) {
result = append(result, trait)
}
}
return result
}
func containsTerm(text, term string) bool {
return strings.Contains(" "+text+" ", " "+term+" ")
}
func uniqueTraits(groups ...[]model.Trait) map[string]string {
result := make(map[string]string)
for _, group := range groups {
for _, trait := range group {
if name := normalize(trait.Name); name != "" {
result[name] = trait.Name
}
}
}
return result
}
func normalize(value string) string {
return strings.Join(strings.FieldsFunc(strings.ToLower(value), func(r rune) bool {
return !unicode.IsLetter(r) && !unicode.IsNumber(r) && r != '+' && r != '#'
}), " ")
}
type InvitationService struct {
store repository.IStore
frontendURL string
now func() time.Time
}
func NewInvitationService(store repository.IStore, frontendURL string) *InvitationService {
return &InvitationService{store: store, frontendURL: strings.TrimRight(frontendURL, "/"), now: time.Now}
}
func (s *InvitationService) List(ctx context.Context, jobID uint) ([]model.Invitation, error) {
return s.store.ListInvitations(ctx, jobID)
}
func (s *InvitationService) Create(ctx context.Context, jobID, participantID uint) (model.Invitation, error) {
job, err := s.store.GetJob(ctx, jobID)
if err != nil {
return model.Invitation{}, err
}
if job.Status != "published" {
return model.Invitation{}, fmt.Errorf("%w: invitations require a published job", ErrInvalidState)
}
if _, err := s.store.GetParticipant(ctx, participantID); err != nil {
return model.Invitation{}, err
}
matches, err := NewJobService(s.store).Candidates(ctx, jobID)
if err != nil {
return model.Invitation{}, err
}
eligible := false
for _, match := range matches {
if match.Participant.ID == participantID {
eligible = true
break
}
}
if !eligible {
return model.Invitation{}, fmt.Errorf("%w: participant does not meet the %.0f%% threshold", ErrInvalidState, CandidateThreshold)
}
return s.store.CreateInvitation(ctx, model.Invitation{
JobID: jobID, ParticipantID: participantID, Token: uuid.NewString(),
Status: "pending", DurationMinutes: job.DurationMinutes,
})
}
func (s *InvitationService) GetInterview(ctx context.Context, token string) (model.Interview, error) {
interview, err := s.store.GetInterview(ctx, token)
if err != nil {
return model.Interview{}, err
}
if interview.Job.Status != "published" && interview.Invitation.Status == "pending" {
return model.Interview{}, ErrNotAvailable
}
interview.Job.DurationMinutes = interview.Invitation.DurationMinutes
return interview, nil
}
var ErrNotAvailable = errors.New("interview is not available")
func (s *InvitationService) Accept(ctx context.Context, token string) (model.Interview, error) {
interview, err := s.GetInterview(ctx, token)
if err != nil {
return model.Interview{}, err
}
if interview.Invitation.Status != "pending" {
return model.Interview{}, ErrInvalidState
}
if err := s.store.AcceptInvitation(ctx, token, s.now()); err != nil {
return model.Interview{}, err
}
return s.GetInterview(ctx, token)
}
func (s *InvitationService) SaveAnswer(ctx context.Context, token string, questionID uint, answer string) error {
interview, err := s.GetInterview(ctx, token)
if err != nil {
return err
}
if interview.Invitation.Status != "accepted" || interviewExpired(interview, s.now()) {
return ErrInvalidState
}
found := false
for _, question := range interview.Job.Questions {
if question.ID == questionID {
found = true
break
}
}
if !found {
return repository.ErrNotFound
}
return s.store.SaveAnswer(ctx, token, questionID, strings.TrimSpace(answer))
}
func (s *InvitationService) Finish(ctx context.Context, token string) (model.Interview, error) {
interview, err := s.GetInterview(ctx, token)
if err != nil {
return model.Interview{}, err
}
if interview.Invitation.Status != "accepted" {
return model.Interview{}, ErrInvalidState
}
if err := s.store.CompleteInvitation(ctx, token, s.now()); err != nil {
return model.Interview{}, err
}
return s.GetInterview(ctx, token)
}
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))
}
func validJobStatus(status string) bool {
switch status {
case "draft", "published", "deprecated":
return true
default:
return false
}
}

View file

@ -0,0 +1,182 @@
package service
import (
"context"
"errors"
"testing"
"time"
"github.com/danyel/go-guess/backend/internal/service/model"
)
type fakeStore struct {
job model.JobPosting
participants []model.Participant
traits []model.Trait
}
type invitationStore struct {
*fakeStore
interview model.Interview
saved bool
}
func (f *invitationStore) CreateInvitation(_ context.Context, value model.Invitation) (model.Invitation, error) {
value.ID = 1
return value, nil
}
func (f *invitationStore) GetInterview(context.Context, string) (model.Interview, error) {
return f.interview, nil
}
func (f *invitationStore) SaveAnswer(context.Context, string, uint, string) error {
f.saved = true
return nil
}
func (f *fakeStore) FindUserByEmail(context.Context, string) (model.User, error) {
return model.User{}, errors.New("unused")
}
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) {
return value, nil
}
func (f *fakeStore) CreateQuestion(_ context.Context, value model.Question) (model.Question, error) {
return value, nil
}
func (f *fakeStore) UpdateQuestion(_ context.Context, _ uint, value model.Question) (model.Question, error) {
return value, nil
}
func (f *fakeStore) ListParticipants(context.Context) ([]model.Participant, error) {
return f.participants, nil
}
func (f *fakeStore) GetParticipant(context.Context, uint) (model.Participant, error) {
return model.Participant{}, nil
}
func (f *fakeStore) CreateParticipant(_ context.Context, value model.Participant) (model.Participant, error) {
return value, nil
}
func (f *fakeStore) AllTraits(context.Context) ([]model.Trait, error) { return f.traits, nil }
func (f *fakeStore) UpdateJob(_ context.Context, _ uint, _ string, _ int) (model.JobPosting, error) {
return f.job, nil
}
func (f *fakeStore) ListQuestions(context.Context, string) ([]model.Question, error) {
return nil, nil
}
func (f *fakeStore) GetQuestion(context.Context, uint) (model.Question, error) {
return model.Question{}, nil
}
func (f *fakeStore) SetQuestionDeprecated(_ context.Context, _ uint, _ bool) (model.Question, error) {
return model.Question{}, nil
}
func (f *fakeStore) AttachQuestion(context.Context, uint, uint) error { return nil }
func (f *fakeStore) DetachQuestion(context.Context, uint, uint) error { return nil }
func (f *fakeStore) ListInvitations(context.Context, uint) ([]model.Invitation, error) {
return nil, nil
}
func (f *fakeStore) CreateInvitation(_ context.Context, value model.Invitation) (model.Invitation, error) {
return value, nil
}
func (f *fakeStore) GetInterview(context.Context, string) (model.Interview, error) {
return model.Interview{}, nil
}
func (f *fakeStore) AcceptInvitation(context.Context, string, time.Time) error { return nil }
func (f *fakeStore) SaveAnswer(context.Context, string, uint, string) error { return nil }
func (f *fakeStore) CompleteInvitation(context.Context, string, time.Time) error {
return nil
}
func TestCandidatesRequireSixtyPercent(t *testing.T) {
store := &fakeStore{
job: model.JobPosting{
Labels: []model.Trait{{Name: "Backend"}},
RequiredSkills: []model.Trait{{Name: "Go"}, {Name: "PostgreSQL"}, {Name: "Docker"}, {Name: "React"}},
},
participants: []model.Participant{
{ID: 1, Traits: []model.Trait{{Name: "Go"}, {Name: "PostgreSQL"}, {Name: "Docker"}}},
{ID: 2, Traits: []model.Trait{{Name: "Go"}, {Name: "React"}}},
},
}
matches, err := NewJobService(store).Candidates(context.Background(), 1)
if err != nil {
t.Fatal(err)
}
if len(matches) != 1 || matches[0].Participant.ID != 1 || matches[0].Score != 60 {
t.Fatalf("expected only participant 1 at 60%%, got %#v", matches)
}
}
func TestParticipantCreateExtractsKnownTraits(t *testing.T) {
store := &fakeStore{traits: []model.Trait{{Name: "Go"}, {Name: "React"}, {Name: "C++"}}}
value, err := NewParticipantService(store).Create(context.Background(), model.Participant{
FirstName: "Ada", LastName: "Lovelace", Email: "ADA@EXAMPLE.COM",
CV: []byte("Backend engineer using Go and C++ daily."), CVFilename: "cv.txt",
})
if err != nil {
t.Fatal(err)
}
if value.Email != "ada@example.com" || len(value.Traits) != 2 {
t.Fatalf("unexpected participant: %#v", value)
}
}
func TestInvitationRequiresPublishedMatchingJob(t *testing.T) {
store := &invitationStore{fakeStore: &fakeStore{
job: model.JobPosting{
ID: 1, Status: "published",
RequiredSkills: []model.Trait{{Name: "Go"}},
},
participants: []model.Participant{{ID: 7, Traits: []model.Trait{{Name: "Go"}}}},
}}
invitation, err := NewInvitationService(store, "http://localhost:5173").Create(context.Background(), 1, 7)
if err != nil {
t.Fatal(err)
}
if invitation.Status != "pending" || invitation.Token == "" {
t.Fatalf("unexpected invitation: %#v", invitation)
}
store.job.Status = "draft"
if _, err := NewInvitationService(store, "").Create(context.Background(), 1, 7); !errors.Is(err, ErrInvalidState) {
t.Fatalf("expected invalid state for draft job, got %v", err)
}
}
func TestExpiredInterviewRejectsAnswers(t *testing.T) {
acceptedAt := time.Now().Add(-61 * time.Minute)
store := &invitationStore{
fakeStore: &fakeStore{},
interview: model.Interview{
Invitation: model.Invitation{Status: "accepted", AcceptedAt: &acceptedAt},
Job: model.JobPosting{
Status: "published", DurationMinutes: 60,
Questions: []model.Question{{ID: 9}},
},
},
}
err := NewInvitationService(store, "").SaveAnswer(context.Background(), "token", 9, "answer")
if !errors.Is(err, ErrInvalidState) || store.saved {
t.Fatalf("expected expired answer to be rejected, got %v", err)
}
}
func TestQuestionReferenceAnswerValidation(t *testing.T) {
service := NewQuestionService(&fakeStore{})
if _, err := service.Create(context.Background(), model.Question{
Text: "Explain cancellation", Type: "open",
}); !errors.Is(err, ErrInvalidQuestion) {
t.Fatalf("expected missing reference answer error, got %v", err)
}
question, err := service.Create(context.Background(), model.Question{
Text: "Choose", Type: "radio", Options: []string{" One ", "Two"},
ReferenceAnswer: "must be cleared",
})
if err != nil {
t.Fatal(err)
}
if question.ReferenceAnswer != "" || question.Options[0] != "One" {
t.Fatalf("unexpected normalized question: %#v", question)
}
}