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,438 @@
package handler
import (
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"mime/multipart"
"net/http"
"strconv"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/danyel/go-guess/backend/internal/database/repository"
"github.com/danyel/go-guess/backend/internal/service"
servicemodel "github.com/danyel/go-guess/backend/internal/service/model"
webmapper "github.com/danyel/go-guess/backend/internal/web/mapper"
webmodel "github.com/danyel/go-guess/backend/internal/web/model"
)
type Handler struct {
auth service.IAuthService
jobs service.IJobService
questions service.IQuestionService
participants service.IParticipantService
invitations service.IInvitationService
maxUpload int64
frontendURL string
}
func New(
auth service.IAuthService,
jobs service.IJobService,
questions service.IQuestionService,
participants service.IParticipantService,
invitations service.IInvitationService,
maxUploadMB int64,
frontendURL string,
) *Handler {
return &Handler{
auth: auth, jobs: jobs, questions: questions, participants: participants,
invitations: invitations, maxUpload: maxUploadMB << 20,
frontendURL: strings.TrimRight(frontendURL, "/"),
}
}
func (h *Handler) Health(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func (h *Handler) Login(w http.ResponseWriter, r *http.Request) {
var request webmodel.LoginRequest
if err := decodeJSON(r, &request); err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
token, user, err := h.auth.Login(r.Context(), request.Email, request.Password)
if err != nil {
if errors.Is(err, service.ErrInvalidCredentials) {
writeError(w, http.StatusUnauthorized, err)
return
}
internalError(w, r, err)
return
}
writeJSON(w, http.StatusOK, webmodel.LoginResponse{
Token: token, User: webmodel.User{ID: user.ID, Email: user.Email, Role: user.Role},
})
}
func (h *Handler) ListJobs(w http.ResponseWriter, r *http.Request) {
values, err := h.jobs.List(r.Context())
if err != nil {
internalError(w, r, err)
return
}
response := make([]webmodel.JobResponse, len(values))
for i, value := range values {
response[i] = webmapper.JobToWeb(value)
}
writeJSON(w, http.StatusOK, response)
}
func (h *Handler) GetJob(w http.ResponseWriter, r *http.Request) {
value, err := h.jobs.Get(r.Context(), idParam(r))
if handleServiceError(w, r, err) {
return
}
writeJSON(w, http.StatusOK, webmapper.JobToWeb(value))
}
func (h *Handler) CreateJob(w http.ResponseWriter, r *http.Request) {
var request webmodel.JobRequest
if err := decodeJSON(r, &request); err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
value, err := h.jobs.Create(r.Context(), webmapper.JobToService(request))
if err != nil {
handleServiceError(w, r, err)
return
}
writeJSON(w, http.StatusCreated, webmapper.JobToWeb(value))
}
func (h *Handler) UpdateJob(w http.ResponseWriter, r *http.Request) {
var request webmodel.JobStatusRequest
if err := decodeJSON(r, &request); err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
value, err := h.jobs.Update(r.Context(), idParam(r), request.Status, request.DurationMinutes)
if handleServiceError(w, r, err) {
return
}
writeJSON(w, http.StatusOK, webmapper.JobToWeb(value))
}
func (h *Handler) ListQuestions(w http.ResponseWriter, r *http.Request) {
values, err := h.questions.List(r.Context(), r.URL.Query().Get("search"))
if err != nil {
internalError(w, r, err)
return
}
response := make([]webmodel.QuestionResponse, len(values))
for i, value := range values {
response[i] = webmapper.QuestionToWeb(value)
}
writeJSON(w, http.StatusOK, response)
}
func (h *Handler) CreateQuestion(w http.ResponseWriter, r *http.Request) {
var request webmodel.QuestionRequest
if err := decodeJSON(r, &request); err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
value, err := h.questions.Create(r.Context(), webmapper.QuestionToService(request))
if err != nil {
handleServiceError(w, r, err)
return
}
writeJSON(w, http.StatusCreated, webmapper.QuestionToWeb(value))
}
func (h *Handler) UpdateQuestion(w http.ResponseWriter, r *http.Request) {
var request webmodel.QuestionRequest
if err := decodeJSON(r, &request); err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
value, err := h.questions.Update(r.Context(), idParam(r), webmapper.QuestionToService(request))
if handleServiceError(w, r, err) {
return
}
writeJSON(w, http.StatusOK, webmapper.QuestionToWeb(value))
}
func (h *Handler) SetQuestionDeprecated(w http.ResponseWriter, r *http.Request) {
var request webmodel.QuestionStatusRequest
if err := decodeJSON(r, &request); err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
value, err := h.questions.SetDeprecated(r.Context(), idParam(r), request.Deprecated)
if handleServiceError(w, r, err) {
return
}
writeJSON(w, http.StatusOK, webmapper.QuestionToWeb(value))
}
func (h *Handler) AttachQuestion(w http.ResponseWriter, r *http.Request) {
if err := h.jobs.AttachQuestion(r.Context(), idParam(r), secondaryIDParam(r, "questionId")); handleServiceError(w, r, err) {
return
}
w.WriteHeader(http.StatusNoContent)
}
func (h *Handler) DetachQuestion(w http.ResponseWriter, r *http.Request) {
if err := h.jobs.DetachQuestion(r.Context(), idParam(r), secondaryIDParam(r, "questionId")); handleServiceError(w, r, err) {
return
}
w.WriteHeader(http.StatusNoContent)
}
func (h *Handler) Candidates(w http.ResponseWriter, r *http.Request) {
values, err := h.jobs.Candidates(r.Context(), idParam(r))
if handleServiceError(w, r, err) {
return
}
response := make([]webmodel.CandidateResponse, len(values))
for i, value := range values {
response[i] = webmapper.CandidateToWeb(value)
}
writeJSON(w, http.StatusOK, response)
}
func (h *Handler) ListParticipants(w http.ResponseWriter, r *http.Request) {
values, err := h.participants.List(r.Context())
if err != nil {
internalError(w, r, err)
return
}
response := make([]webmodel.ParticipantResponse, len(values))
for i, value := range values {
response[i] = webmapper.ParticipantToWeb(value)
}
writeJSON(w, http.StatusOK, response)
}
func (h *Handler) GetParticipant(w http.ResponseWriter, r *http.Request) {
value, err := h.participants.Get(r.Context(), idParam(r))
if handleServiceError(w, r, err) {
return
}
writeJSON(w, http.StatusOK, webmapper.ParticipantToWeb(value))
}
func (h *Handler) CreateParticipant(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, h.maxUpload)
if err := r.ParseMultipartForm(h.maxUpload); err != nil {
writeError(w, http.StatusBadRequest, fmt.Errorf("invalid multipart form or upload too large"))
return
}
participant := servicemodel.Participant{
FirstName: r.FormValue("firstName"), LastName: r.FormValue("lastName"),
Email: r.FormValue("email"), ContactInfo: r.FormValue("contactInfo"),
}
if raw := r.FormValue("birthday"); raw != "" {
birthday, err := time.Parse("2006-01-02", raw)
if err != nil {
writeError(w, http.StatusBadRequest, fmt.Errorf("birthday must use YYYY-MM-DD"))
return
}
participant.Birthday = &birthday
}
var err error
participant.Photo, participant.PhotoType, _, err = readUpload(r, "photo")
if err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
participant.CV, _, participant.CVFilename, err = readUpload(r, "cv")
if err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
value, err := h.participants.Create(r.Context(), participant)
if err != nil {
handleServiceError(w, r, err)
return
}
writeJSON(w, http.StatusCreated, webmapper.ParticipantToWeb(value))
}
func (h *Handler) ParticipantPhoto(w http.ResponseWriter, r *http.Request) {
value, err := h.participants.Get(r.Context(), idParam(r))
if handleServiceError(w, r, err) {
return
}
if len(value.Photo) == 0 {
http.NotFound(w, r)
return
}
contentType := value.PhotoType
switch contentType {
case "image/jpeg", "image/png", "image/gif", "image/webp":
default:
contentType = "application/octet-stream"
}
w.Header().Set("Content-Type", contentType)
w.Header().Set("Content-Disposition", "inline")
w.Header().Set("X-Content-Type-Options", "nosniff")
_, _ = w.Write(value.Photo)
}
func (h *Handler) ParticipantCV(w http.ResponseWriter, r *http.Request) {
value, err := h.participants.Get(r.Context(), idParam(r))
if handleServiceError(w, r, err) {
return
}
if len(value.CV) == 0 {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", value.CVFilename))
_, _ = w.Write(value.CV)
}
func (h *Handler) ListInvitations(w http.ResponseWriter, r *http.Request) {
values, err := h.invitations.List(r.Context(), idParam(r))
if err != nil {
internalError(w, r, err)
return
}
response := make([]webmodel.InvitationResponse, len(values))
for i, value := range values {
response[i] = webmapper.InvitationToWeb(value, h.frontendURL)
}
writeJSON(w, http.StatusOK, response)
}
func (h *Handler) CreateInvitation(w http.ResponseWriter, r *http.Request) {
var request webmodel.InvitationRequest
if err := decodeJSON(r, &request); err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
value, err := h.invitations.Create(r.Context(), idParam(r), request.ParticipantID)
if handleServiceError(w, r, err) {
return
}
writeJSON(w, http.StatusCreated, webmapper.InvitationToWeb(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) {
return
}
writeJSON(w, http.StatusOK, webmapper.InterviewToWeb(value, h.frontendURL))
}
func (h *Handler) AcceptInterview(w http.ResponseWriter, r *http.Request) {
value, err := h.invitations.Accept(r.Context(), chi.URLParam(r, "token"))
if handleServiceError(w, r, err) {
return
}
writeJSON(w, http.StatusOK, webmapper.InterviewToWeb(value, h.frontendURL))
}
func (h *Handler) SaveAnswer(w http.ResponseWriter, r *http.Request) {
var request webmodel.AnswerRequest
if err := decodeJSON(r, &request); err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
err := h.invitations.SaveAnswer(
r.Context(), chi.URLParam(r, "token"), secondaryIDParam(r, "questionId"), request.Answer,
)
if handleServiceError(w, r, err) {
return
}
w.WriteHeader(http.StatusNoContent)
}
func (h *Handler) FinishInterview(w http.ResponseWriter, r *http.Request) {
value, err := h.invitations.Finish(r.Context(), chi.URLParam(r, "token"))
if handleServiceError(w, r, err) {
return
}
writeJSON(w, http.StatusOK, webmapper.InterviewToWeb(value, h.frontendURL))
}
func readUpload(r *http.Request, field string) ([]byte, string, string, error) {
file, header, err := r.FormFile(field)
if errors.Is(err, http.ErrMissingFile) {
return nil, "", "", nil
}
if err != nil {
return nil, "", "", fmt.Errorf("read %s: %w", field, err)
}
defer file.Close()
content, err := io.ReadAll(file)
if err != nil {
return nil, "", "", fmt.Errorf("read %s: %w", field, err)
}
return content, header.Header.Get("Content-Type"), safeFilename(header), nil
}
func safeFilename(header *multipart.FileHeader) string {
return strings.ReplaceAll(strings.ReplaceAll(header.Filename, "/", "_"), "\\", "_")
}
func idParam(r *http.Request) uint {
id, _ := strconv.ParseUint(chi.URLParam(r, "id"), 10, 64)
return uint(id)
}
func secondaryIDParam(r *http.Request, name string) uint {
id, _ := strconv.ParseUint(chi.URLParam(r, name), 10, 64)
return uint(id)
}
func decodeJSON(r *http.Request, target any) error {
decoder := json.NewDecoder(io.LimitReader(r.Body, 1<<20))
decoder.DisallowUnknownFields()
if err := decoder.Decode(target); err != nil {
return fmt.Errorf("invalid request body: %w", err)
}
if err := decoder.Decode(&struct{}{}); err != io.EOF {
return fmt.Errorf("request body must contain one JSON value")
}
return nil
}
func handleServiceError(w http.ResponseWriter, r *http.Request, err error) bool {
if err == nil {
return false
}
switch {
case errors.Is(err, repository.ErrNotFound):
writeError(w, http.StatusNotFound, err)
case errors.Is(err, repository.ErrConflict):
writeError(w, http.StatusConflict, err)
case errors.Is(err, service.ErrValidation), errors.Is(err, service.ErrInvalidQuestion):
writeError(w, http.StatusBadRequest, err)
case errors.Is(err, service.ErrInvalidState):
writeError(w, http.StatusConflict, err)
case errors.Is(err, service.ErrNotAvailable):
writeError(w, http.StatusNotFound, err)
default:
internalError(w, r, err)
}
return true
}
func internalError(w http.ResponseWriter, r *http.Request, err error) {
slog.ErrorContext(r.Context(), "request failed", "method", r.Method, "path", r.URL.Path, "error", err)
writeError(w, http.StatusInternalServerError, errors.New("internal server error"))
}
func writeError(w http.ResponseWriter, status int, err error) {
writeJSON(w, status, webmodel.ErrorResponse{Error: err.Error()})
}
func writeJSON(w http.ResponseWriter, status int, value any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(value); err != nil {
slog.Error("encode response", "error", err)
}
}

View file

@ -0,0 +1,116 @@
package mapper
import (
"fmt"
servicemodel "github.com/danyel/go-guess/backend/internal/service/model"
webmodel "github.com/danyel/go-guess/backend/internal/web/model"
)
func JobToService(value webmodel.JobRequest) servicemodel.JobPosting {
return servicemodel.JobPosting{
Title: value.Title, Description: value.Description, Seniority: value.Seniority,
Position: value.Position, DurationMinutes: value.DurationMinutes,
Labels: traitsToService(value.Labels), RequiredSkills: traitsToService(value.RequiredSkills),
AdditionalSkills: traitsToService(value.AdditionalSkills),
}
}
func QuestionToService(value webmodel.QuestionRequest) servicemodel.Question {
return servicemodel.Question{
Text: value.Text, Type: value.Type, Options: value.Options,
ReferenceAnswer: value.ReferenceAnswer,
}
}
func JobToWeb(value servicemodel.JobPosting) webmodel.JobResponse {
questions := make([]webmodel.QuestionResponse, len(value.Questions))
for i, question := range value.Questions {
questions[i] = QuestionToWeb(question)
}
return webmodel.JobResponse{
ID: value.ID, Title: value.Title, Description: value.Description,
Seniority: value.Seniority, Position: value.Position, Status: value.Status,
DurationMinutes: value.DurationMinutes,
Labels: traitsToWeb(value.Labels), RequiredSkills: traitsToWeb(value.RequiredSkills),
AdditionalSkills: traitsToWeb(value.AdditionalSkills), Questions: questions,
CreatedAt: value.CreatedAt,
}
}
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,
}
}
func ParticipantToWeb(value servicemodel.Participant) webmodel.ParticipantResponse {
result := webmodel.ParticipantResponse{
ID: value.ID, FirstName: value.FirstName, LastName: value.LastName,
Email: value.Email, ContactInfo: value.ContactInfo, CVFilename: value.CVFilename,
Traits: traitsToWeb(value.Traits), CreatedAt: value.CreatedAt,
}
if value.Birthday != nil {
birthday := value.Birthday.Format("2006-01-02")
result.Birthday = &birthday
}
if len(value.Photo) > 0 {
result.PhotoURL = fmt.Sprintf("/api/participants/%d/photo", value.ID)
}
if len(value.CV) > 0 {
result.CVURL = fmt.Sprintf("/api/participants/%d/cv", value.ID)
}
return result
}
func CandidateToWeb(value servicemodel.CandidateMatch) webmodel.CandidateResponse {
return webmodel.CandidateResponse{
Participant: ParticipantToWeb(value.Participant), Score: value.Score,
Matched: value.Matched, Missing: value.Missing,
}
}
func InvitationToWeb(value servicemodel.Invitation, frontendURL string) webmodel.InvitationResponse {
return webmodel.InvitationResponse{
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,
AcceptedAt: value.AcceptedAt, CompletedAt: value.CompletedAt, CreatedAt: value.CreatedAt,
}
}
func InterviewToWeb(value servicemodel.Interview, frontendURL string) webmodel.InterviewResponse {
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,
}
}
return webmodel.InterviewResponse{
Invitation: InvitationToWeb(value.Invitation, frontendURL),
Job: webmodel.InterviewJobResponse{
ID: value.Job.ID, Title: value.Job.Title, Description: value.Job.Description,
Position: value.Job.Position, Seniority: value.Job.Seniority,
DurationMinutes: value.Job.DurationMinutes, Questions: questions,
},
Answers: value.Answers,
}
}
func traitsToService(values []string) []servicemodel.Trait {
result := make([]servicemodel.Trait, len(values))
for i, value := range values {
result[i] = servicemodel.Trait{Name: value}
}
return result
}
func traitsToWeb(values []servicemodel.Trait) []string {
result := make([]string, len(values))
for i, value := range values {
result[i] = value.Name
}
return result
}

View file

@ -0,0 +1,140 @@
package model
import "time"
type ErrorResponse struct {
Error string `json:"error"`
}
type LoginRequest struct {
Email string `json:"email"`
Password string `json:"password"`
}
type LoginResponse struct {
Token string `json:"token"`
User User `json:"user"`
}
type User struct {
ID uint `json:"id"`
Email string `json:"email"`
Role string `json:"role"`
}
type JobRequest struct {
Title string `json:"title"`
Description string `json:"description"`
Seniority string `json:"seniority"`
Position string `json:"position"`
DurationMinutes int `json:"durationMinutes"`
Labels []string `json:"labels"`
RequiredSkills []string `json:"requiredSkills"`
AdditionalSkills []string `json:"additionalSkills"`
}
type JobResponse struct {
ID uint `json:"id"`
Title string `json:"title"`
Description string `json:"description"`
Seniority string `json:"seniority"`
Position string `json:"position"`
Status string `json:"status"`
DurationMinutes int `json:"durationMinutes"`
Labels []string `json:"labels"`
RequiredSkills []string `json:"requiredSkills"`
AdditionalSkills []string `json:"additionalSkills"`
Questions []QuestionResponse `json:"questions"`
CreatedAt time.Time `json:"createdAt"`
}
type QuestionRequest struct {
Text string `json:"text"`
Type string `json:"type"`
Options []string `json:"options"`
ReferenceAnswer string `json:"referenceAnswer"`
}
type QuestionResponse struct {
ID uint `json:"id"`
Text string `json:"text"`
Type string `json:"type"`
Options []string `json:"options"`
ReferenceAnswer string `json:"referenceAnswer"`
Deprecated bool `json:"deprecated"`
}
type QuestionStatusRequest struct {
Deprecated bool `json:"deprecated"`
}
type JobStatusRequest struct {
Status string `json:"status"`
DurationMinutes int `json:"durationMinutes"`
}
type InvitationRequest struct {
ParticipantID uint `json:"participantId"`
}
type ParticipantResponse struct {
ID uint `json:"id"`
FirstName string `json:"firstName"`
LastName string `json:"lastName"`
Birthday *string `json:"birthday,omitempty"`
Email string `json:"email"`
ContactInfo string `json:"contactInfo"`
PhotoURL string `json:"photoUrl,omitempty"`
CVFilename string `json:"cvFilename,omitempty"`
CVURL string `json:"cvUrl,omitempty"`
Traits []string `json:"traits"`
CreatedAt time.Time `json:"createdAt"`
}
type CandidateResponse struct {
Participant ParticipantResponse `json:"participant"`
Score float64 `json:"score"`
Matched []string `json:"matchedTraits"`
Missing []string `json:"missingTraits"`
}
type InvitationResponse struct {
ID uint `json:"id"`
JobID uint `json:"jobId"`
ParticipantID uint `json:"participantId"`
ParticipantName string `json:"participantName"`
ParticipantEmail string `json:"participantEmail"`
Token string `json:"token"`
Status string `json:"status"`
ParticipantURL string `json:"participantUrl"`
AcceptedAt *time.Time `json:"acceptedAt,omitempty"`
CompletedAt *time.Time `json:"completedAt,omitempty"`
CreatedAt time.Time `json:"createdAt"`
}
type InterviewJobResponse struct {
ID uint `json:"id"`
Title string `json:"title"`
Description string `json:"description"`
Position string `json:"position"`
Seniority string `json:"seniority"`
DurationMinutes int `json:"durationMinutes"`
Questions []InterviewQuestionResponse `json:"questions"`
}
type InterviewQuestionResponse struct {
ID uint `json:"id"`
Text string `json:"text"`
Type string `json:"type"`
Options []string `json:"options"`
}
type InterviewResponse struct {
Invitation InvitationResponse `json:"invitation"`
Job InterviewJobResponse `json:"job"`
Answers map[uint]string `json:"answers"`
}
type AnswerRequest struct {
Answer string `json:"answer"`
}

View file

@ -0,0 +1,93 @@
package router
import (
"context"
"encoding/json"
"net/http"
"strings"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/danyel/go-guess/backend/internal/security"
"github.com/danyel/go-guess/backend/internal/web/handler"
)
type contextKey string
const claimsKey contextKey = "claims"
func New(h *handler.Handler, tokens security.ITokenManager, frontendURL string) http.Handler {
router := chi.NewRouter()
router.Use(middleware.RequestID, middleware.RealIP, middleware.Recoverer, middleware.Compress(5))
router.Use(cors(frontendURL))
router.Get("/api/health", h.Health)
router.Post("/api/auth/login", h.Login)
router.Get("/api/interviews/{token}", h.GetInterview)
router.Post("/api/interviews/{token}/accept", h.AcceptInterview)
router.Put("/api/interviews/{token}/answers/{questionId}", h.SaveAnswer)
router.Post("/api/interviews/{token}/finish", h.FinishInterview)
router.Group(func(protected chi.Router) {
protected.Use(authenticate(tokens))
protected.Get("/api/jobs", h.ListJobs)
protected.Post("/api/jobs", h.CreateJob)
protected.Get("/api/jobs/{id}", h.GetJob)
protected.Patch("/api/jobs/{id}", h.UpdateJob)
protected.Post("/api/jobs/{id}/questions/{questionId}", h.AttachQuestion)
protected.Delete("/api/jobs/{id}/questions/{questionId}", h.DetachQuestion)
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/questions", h.ListQuestions)
protected.Post("/api/questions", h.CreateQuestion)
protected.Put("/api/questions/{id}", h.UpdateQuestion)
protected.Patch("/api/questions/{id}", h.SetQuestionDeprecated)
protected.Get("/api/participants", h.ListParticipants)
protected.Post("/api/participants", h.CreateParticipant)
protected.Get("/api/participants/{id}", h.GetParticipant)
protected.Get("/api/participants/{id}/photo", h.ParticipantPhoto)
protected.Get("/api/participants/{id}/cv", h.ParticipantCV)
})
return router
}
func writeUnauthorized(w http.ResponseWriter, message string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
_ = json.NewEncoder(w).Encode(map[string]string{"error": message})
}
func authenticate(tokens security.ITokenManager) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
scheme, raw, ok := strings.Cut(r.Header.Get("Authorization"), " ")
if !ok || !strings.EqualFold(scheme, "Bearer") {
writeUnauthorized(w, "missing bearer token")
return
}
claims, err := tokens.Parse(raw)
if err != nil {
writeUnauthorized(w, "invalid bearer token")
return
}
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), claimsKey, claims)))
})
}
}
func cors(origin string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
w.Header().Add("Vary", "Origin")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}
}

View file

@ -0,0 +1,112 @@
package router
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"
"github.com/danyel/go-guess/backend/internal/service/model"
"github.com/danyel/go-guess/backend/internal/web/handler"
)
type authStub struct{}
func (authStub) Login(context.Context, string, string) (string, model.User, error) {
return "", model.User{}, service.ErrInvalidCredentials
}
type jobStub struct{}
func (jobStub) List(context.Context) ([]model.JobPosting, error) { return []model.JobPosting{}, nil }
func (jobStub) Get(context.Context, uint) (model.JobPosting, error) { return model.JobPosting{}, nil }
func (jobStub) Create(context.Context, model.JobPosting) (model.JobPosting, error) {
return model.JobPosting{}, nil
}
func (jobStub) Update(context.Context, uint, string, int) (model.JobPosting, error) {
return model.JobPosting{}, nil
}
func (jobStub) AttachQuestion(context.Context, uint, uint) error { return nil }
func (jobStub) DetachQuestion(context.Context, uint, uint) error { return nil }
func (jobStub) Candidates(context.Context, uint) ([]model.CandidateMatch, error) {
return []model.CandidateMatch{}, nil
}
type participantStub struct{}
func (participantStub) List(context.Context) ([]model.Participant, error) { return nil, nil }
func (participantStub) Get(context.Context, uint) (model.Participant, error) {
return model.Participant{}, nil
}
func (participantStub) Create(context.Context, model.Participant) (model.Participant, error) {
return model.Participant{}, nil
}
type questionStub struct{}
func (questionStub) List(context.Context, string) ([]model.Question, error) { return nil, nil }
func (questionStub) Create(context.Context, model.Question) (model.Question, error) {
return model.Question{}, nil
}
func (questionStub) Update(context.Context, uint, model.Question) (model.Question, error) {
return model.Question{}, nil
}
func (questionStub) SetDeprecated(context.Context, uint, bool) (model.Question, error) {
return model.Question{}, nil
}
type invitationStub struct{}
func (invitationStub) List(context.Context, uint) ([]model.Invitation, error) { return nil, nil }
func (invitationStub) Create(context.Context, uint, uint) (model.Invitation, error) {
return model.Invitation{}, nil
}
func (invitationStub) GetInterview(context.Context, string) (model.Interview, error) {
return model.Interview{}, nil
}
func (invitationStub) Accept(context.Context, string) (model.Interview, error) {
return model.Interview{}, nil
}
func (invitationStub) SaveAnswer(context.Context, string, uint, string) error { return nil }
func (invitationStub) Finish(context.Context, string) (model.Interview, error) {
return model.Interview{}, nil
}
func TestHealthDoesNotRequireAuthentication(t *testing.T) {
h := handler.New(authStub{}, jobStub{}, questionStub{}, participantStub{}, invitationStub{}, 10, "http://localhost:5173")
tokens := security.NewTokenManager("01234567890123456789012345678901", time.Hour)
router := New(h, tokens, "http://localhost:5173")
request := httptest.NewRequest(http.MethodGet, "/api/health", nil)
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", response.Code)
}
}
func TestProtectedRouteRequiresValidToken(t *testing.T) {
h := handler.New(authStub{}, jobStub{}, questionStub{}, participantStub{}, invitationStub{}, 10, "http://localhost:5173")
tokens := security.NewTokenManager("01234567890123456789012345678901", time.Hour)
router := New(h, tokens, "http://localhost:5173")
unauthorized := httptest.NewRecorder()
router.ServeHTTP(unauthorized, httptest.NewRequest(http.MethodGet, "/api/jobs", nil))
if unauthorized.Code != http.StatusUnauthorized || unauthorized.Header().Get("Content-Type") != "application/json" {
t.Fatalf("expected JSON 401, got %d %q", unauthorized.Code, unauthorized.Header().Get("Content-Type"))
}
token, err := tokens.Issue(1, "interviewer@example.com", "interviewer")
if err != nil {
t.Fatal(err)
}
request := httptest.NewRequest(http.MethodGet, "/api/jobs", nil)
request.Header.Set("Authorization", "Bearer "+token)
authorized := httptest.NewRecorder()
router.ServeHTTP(authorized, request)
if authorized.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", authorized.Code)
}
}