feat(v0.12.3): F276-F305 education backend service, handler, and routes

- Course CRUD with slug generation, publish/archive lifecycle
- Lesson management with ordering and transcoding status
- Enrollment system with duplicate prevention
- Progress tracking with auto-completion at 90%
- Certificate issuance requiring full course completion
- Course reviews with rating aggregation
- Unit tests for service and handler layers

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
senke 2026-03-11 09:45:26 +01:00
parent 329f53ada3
commit 506195f4e0
7 changed files with 2405 additions and 0 deletions

View file

@ -352,6 +352,9 @@ func (r *APIRouter) Setup(router *gin.Engine) error {
// v0.12.2: Distribution to External Platforms (F501-F510)
r.setupDistributionRoutes(v1)
// v0.12.3: Formation & Éducation (F276-F305)
r.setupEducationRoutes(v1)
}
return nil

View file

@ -0,0 +1,69 @@
package api
import (
"github.com/gin-gonic/gin"
"veza-backend-api/internal/core/education"
"veza-backend-api/internal/handlers"
)
// setupEducationRoutes configures routes for Formation & Éducation (v0.12.3 F276-F305)
func (r *APIRouter) setupEducationRoutes(router *gin.RouterGroup) {
svc := education.NewService(r.db.GormDB, r.logger)
handler := handlers.NewEducationHandler(svc, r.logger)
// Public course catalog
coursePublic := router.Group("/courses")
coursePublic.GET("", handler.ListPublishedCourses)
coursePublic.GET("/:id", handler.GetCourse)
coursePublic.GET("/:id/lessons", handler.GetCourseLessons)
coursePublic.GET("/:id/reviews", handler.GetCourseReviews)
coursePublic.GET("/slug/:slug", handler.GetCourseBySlug)
// Public certificate verification
router.GET("/certificates/:code", handler.VerifyCertificate)
if r.config.AuthMiddleware == nil {
return
}
// Course management (requires auth)
courseAuth := router.Group("/courses")
courseAuth.Use(r.config.AuthMiddleware.RequireAuth())
r.applyCSRFProtection(courseAuth)
courseAuth.POST("", handler.CreateCourse)
courseAuth.PUT("/:id", handler.UpdateCourse)
courseAuth.DELETE("/:id", handler.DeleteCourse)
courseAuth.POST("/:id/publish", handler.PublishCourse)
courseAuth.POST("/:id/archive", handler.ArchiveCourse)
courseAuth.POST("/:id/lessons", handler.CreateLesson)
courseAuth.PUT("/:id/lessons/:lesson_id", handler.UpdateLesson)
courseAuth.DELETE("/:id/lessons/:lesson_id", handler.DeleteLesson)
courseAuth.POST("/:id/lessons/reorder", handler.ReorderLessons)
courseAuth.POST("/:id/enroll", handler.Enroll)
courseAuth.GET("/:id/progress", handler.GetCourseProgress)
courseAuth.POST("/:id/certificate", handler.IssueCertificate)
courseAuth.POST("/:id/reviews", handler.CreateReview)
// Enrollments (requires auth)
enrollAuth := router.Group("/enrollments")
enrollAuth.Use(r.config.AuthMiddleware.RequireAuth())
enrollAuth.GET("", handler.GetMyEnrollments)
// Progress (requires auth)
lessonAuth := router.Group("/lessons")
lessonAuth.Use(r.config.AuthMiddleware.RequireAuth())
r.applyCSRFProtection(lessonAuth)
lessonAuth.POST("/:lesson_id/progress", handler.UpdateProgress)
// Certificates (requires auth for user's own)
certAuth := router.Group("/certificates")
certAuth.Use(r.config.AuthMiddleware.RequireAuth())
certAuth.GET("", handler.GetMyCertificates)
// Creator courses list
creatorAuth := router.Group("/creators/me")
creatorAuth.Use(r.config.AuthMiddleware.RequireAuth())
creatorAuth.GET("/courses", handler.ListCreatorCourses)
}

View file

@ -0,0 +1,241 @@
package education
import (
"time"
"github.com/google/uuid"
"github.com/lib/pq"
"gorm.io/gorm"
)
// CourseStatus represents the publication status of a course
type CourseStatus string
const (
CourseStatusDraft CourseStatus = "draft"
CourseStatusPublished CourseStatus = "published"
CourseStatusArchived CourseStatus = "archived"
)
// CourseLevel represents the difficulty level
type CourseLevel string
const (
CourseLevelBeginner CourseLevel = "beginner"
CourseLevelIntermediate CourseLevel = "intermediate"
CourseLevelAdvanced CourseLevel = "advanced"
)
// PricingModel represents the pricing strategy
type PricingModel string
const (
PricingFixed PricingModel = "fixed"
PricingPayWhatYou PricingModel = "pay_what_you_want"
PricingFree PricingModel = "free"
)
// EnrollmentStatus represents the enrollment state
type EnrollmentStatus string
const (
EnrollmentActive EnrollmentStatus = "active"
EnrollmentExpired EnrollmentStatus = "expired"
EnrollmentRefunded EnrollmentStatus = "refunded"
)
// TranscodingStatus represents video processing state
type TranscodingStatus string
const (
TranscodingPending TranscodingStatus = "pending"
TranscodingProcessing TranscodingStatus = "processing"
TranscodingComplete TranscodingStatus = "complete"
TranscodingFailed TranscodingStatus = "failed"
)
// CertificateStatus represents certificate validity
type CertificateStatus string
const (
CertificateActive CertificateStatus = "active"
CertificateRevoked CertificateStatus = "revoked"
)
// ReviewStatus represents review moderation status
type ReviewStatus string
const (
ReviewApproved ReviewStatus = "approved"
ReviewPending ReviewStatus = "pending"
ReviewRejected ReviewStatus = "rejected"
)
// Course represents an educational course created by a user
type Course struct {
ID uuid.UUID `gorm:"type:uuid;primaryKey" json:"id"`
CreatorID uuid.UUID `gorm:"type:uuid;not null" json:"creator_id"`
Title string `gorm:"size:255;not null" json:"title"`
Slug string `gorm:"size:255;not null;uniqueIndex" json:"slug"`
Description string `gorm:"type:text;not null;default:''" json:"description"`
Category string `gorm:"size:100" json:"category,omitempty"`
Tags pq.StringArray `gorm:"type:varchar(50)[];default:'{}'" json:"tags"`
CoverImageURL string `gorm:"type:text" json:"cover_image_url,omitempty"`
PriceCents int `gorm:"not null;default:0" json:"price_cents"`
Currency string `gorm:"size:3;not null;default:'USD'" json:"currency"`
PricingModel PricingModel `gorm:"size:50;not null;default:'fixed'" json:"pricing_model"`
MinimumPriceCents int `gorm:"default:0" json:"minimum_price_cents"`
Status CourseStatus `gorm:"size:50;not null;default:'draft'" json:"status"`
Level CourseLevel `gorm:"size:50;default:'beginner'" json:"level"`
Language string `gorm:"size:5;default:'en'" json:"language"`
TotalDurationSeconds int `gorm:"not null;default:0" json:"total_duration_seconds"`
LessonCount int `gorm:"not null;default:0" json:"lesson_count"`
EnrollmentCount int `gorm:"not null;default:0" json:"enrollment_count"`
ReviewCount int `gorm:"not null;default:0" json:"review_count"`
AverageRating float64 `gorm:"type:numeric(3,2);default:0" json:"average_rating"`
PublishedAt *time.Time `json:"published_at,omitempty"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at,omitempty"`
}
func (Course) TableName() string {
return "courses"
}
func (c *Course) BeforeCreate(tx *gorm.DB) error {
if c.ID == uuid.Nil {
c.ID = uuid.New()
}
return nil
}
// Lesson represents a single lesson within a course
type Lesson struct {
ID uuid.UUID `gorm:"type:uuid;primaryKey" json:"id"`
CourseID uuid.UUID `gorm:"type:uuid;not null" json:"course_id"`
OrderIndex int `gorm:"not null" json:"order_index"`
Title string `gorm:"size:255;not null" json:"title"`
Description string `gorm:"type:text" json:"description,omitempty"`
VideoFilePath string `gorm:"size:512" json:"video_file_path,omitempty"`
DurationSeconds int `gorm:"not null;default:0" json:"duration_seconds"`
IsPreviewFree bool `gorm:"not null;default:false" json:"is_preview_free"`
TranscodingStatus TranscodingStatus `gorm:"size:50;not null;default:'pending'" json:"transcoding_status"`
HLSMasterPlaylistURL string `gorm:"type:text" json:"hls_master_playlist_url,omitempty"`
ThumbnailURL string `gorm:"type:text" json:"thumbnail_url,omitempty"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
}
func (Lesson) TableName() string {
return "lessons"
}
func (l *Lesson) BeforeCreate(tx *gorm.DB) error {
if l.ID == uuid.Nil {
l.ID = uuid.New()
}
return nil
}
// CourseEnrollment represents a user's enrollment (purchase) in a course
type CourseEnrollment struct {
ID uuid.UUID `gorm:"type:uuid;primaryKey" json:"id"`
UserID uuid.UUID `gorm:"type:uuid;not null" json:"user_id"`
CourseID uuid.UUID `gorm:"type:uuid;not null" json:"course_id"`
PurchasedPriceCents int `gorm:"not null;default:0" json:"purchased_price_cents"`
Currency string `gorm:"size:3;not null;default:'USD'" json:"currency"`
Status EnrollmentStatus `gorm:"size:50;not null;default:'active'" json:"status"`
AccessExpiresAt *time.Time `json:"access_expires_at,omitempty"`
PurchasedAt time.Time `gorm:"not null" json:"purchased_at"`
RefundedAt *time.Time `json:"refunded_at,omitempty"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
}
func (CourseEnrollment) TableName() string {
return "course_enrollments"
}
func (e *CourseEnrollment) BeforeCreate(tx *gorm.DB) error {
if e.ID == uuid.Nil {
e.ID = uuid.New()
}
return nil
}
// LessonProgress tracks a user's progress on a specific lesson
type LessonProgress struct {
ID uuid.UUID `gorm:"type:uuid;primaryKey" json:"id"`
UserID uuid.UUID `gorm:"type:uuid;not null" json:"user_id"`
LessonID uuid.UUID `gorm:"type:uuid;not null" json:"lesson_id"`
EnrollmentID uuid.UUID `gorm:"type:uuid;not null" json:"enrollment_id"`
WatchedPercentage int `gorm:"default:0" json:"watched_percentage"`
WatchedDurationSeconds int `gorm:"default:0" json:"watched_duration_seconds"`
PlaybackPositionSeconds int `gorm:"default:0" json:"playback_position_seconds"`
IsCompleted bool `gorm:"not null;default:false" json:"is_completed"`
CompletedAt *time.Time `json:"completed_at,omitempty"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
}
func (LessonProgress) TableName() string {
return "lesson_progress"
}
func (p *LessonProgress) BeforeCreate(tx *gorm.DB) error {
if p.ID == uuid.Nil {
p.ID = uuid.New()
}
return nil
}
// Certificate represents a course completion certificate
type Certificate struct {
ID uuid.UUID `gorm:"type:uuid;primaryKey" json:"id"`
UserID uuid.UUID `gorm:"type:uuid;not null" json:"user_id"`
CourseID uuid.UUID `gorm:"type:uuid;not null" json:"course_id"`
EnrollmentID uuid.UUID `gorm:"type:uuid;not null" json:"enrollment_id"`
CertificateCode string `gorm:"size:255;not null;uniqueIndex" json:"certificate_code"`
IssueDate time.Time `gorm:"not null" json:"issue_date"`
Status CertificateStatus `gorm:"size:50;not null;default:'active'" json:"status"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
}
func (Certificate) TableName() string {
return "certificates"
}
func (cert *Certificate) BeforeCreate(tx *gorm.DB) error {
if cert.ID == uuid.Nil {
cert.ID = uuid.New()
}
return nil
}
// CourseReview represents a user's review of a course
type CourseReview struct {
ID uuid.UUID `gorm:"type:uuid;primaryKey" json:"id"`
CourseID uuid.UUID `gorm:"type:uuid;not null" json:"course_id"`
UserID uuid.UUID `gorm:"type:uuid;not null" json:"user_id"`
EnrollmentID uuid.UUID `gorm:"type:uuid;not null" json:"enrollment_id"`
Rating int `gorm:"not null" json:"rating"`
Title string `gorm:"size:255" json:"title,omitempty"`
Content string `gorm:"type:text;not null" json:"content"`
Status ReviewStatus `gorm:"size:50;not null;default:'approved'" json:"status"`
HelpfulCount int `gorm:"not null;default:0" json:"helpful_count"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at,omitempty"`
}
func (CourseReview) TableName() string {
return "course_reviews"
}
func (r *CourseReview) BeforeCreate(tx *gorm.DB) error {
if r.ID == uuid.Nil {
r.ID = uuid.New()
}
return nil
}

View file

@ -0,0 +1,907 @@
package education
import (
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/google/uuid"
"go.uber.org/zap"
"gorm.io/gorm"
)
// Service errors
var (
ErrCourseNotFound = errors.New("course not found")
ErrLessonNotFound = errors.New("lesson not found")
ErrEnrollmentNotFound = errors.New("enrollment not found")
ErrAlreadyEnrolled = errors.New("already enrolled in this course")
ErrNotEnrolled = errors.New("not enrolled in this course")
ErrCourseNotPublished = errors.New("course is not published")
ErrNotCourseOwner = errors.New("you are not the owner of this course")
ErrReviewAlreadyExists = errors.New("you have already reviewed this course")
ErrInvalidRating = errors.New("rating must be between 1 and 5")
ErrCertificateNotFound = errors.New("certificate not found")
ErrCourseNotCompleted = errors.New("course is not fully completed")
ErrCertificateExists = errors.New("certificate already issued for this course")
ErrCannotEnrollOwnCourse = errors.New("cannot enroll in your own course")
ErrSlugAlreadyTaken = errors.New("slug is already taken")
)
// ServiceOption is a functional option for configuring the Service
type ServiceOption func(*Service)
// Service handles education business logic
type Service struct {
db *gorm.DB
logger *zap.Logger
}
// NewService creates a new education service
func NewService(db *gorm.DB, logger *zap.Logger, opts ...ServiceOption) *Service {
s := &Service{
db: db,
logger: logger,
}
for _, opt := range opts {
opt(s)
}
return s
}
// --- Course CRUD ---
// CreateCourseRequest holds the fields for creating a course
type CreateCourseRequest struct {
Title string `json:"title" binding:"required"`
Description string `json:"description"`
Category string `json:"category"`
Tags []string `json:"tags"`
CoverImageURL string `json:"cover_image_url"`
PriceCents int `json:"price_cents"`
Currency string `json:"currency"`
PricingModel PricingModel `json:"pricing_model"`
MinimumPriceCents int `json:"minimum_price_cents"`
Level CourseLevel `json:"level"`
Language string `json:"language"`
}
// CreateCourse creates a new course in draft status
func (s *Service) CreateCourse(ctx context.Context, creatorID uuid.UUID, req CreateCourseRequest) (*Course, error) {
slug := generateSlug(req.Title)
// Ensure slug uniqueness
var count int64
s.db.WithContext(ctx).Model(&Course{}).Where("slug = ?", slug).Count(&count)
if count > 0 {
slug = fmt.Sprintf("%s-%s", slug, uuid.New().String()[:8])
}
currency := req.Currency
if currency == "" {
currency = "USD"
}
pricingModel := req.PricingModel
if pricingModel == "" {
pricingModel = PricingFixed
}
level := req.Level
if level == "" {
level = CourseLevelBeginner
}
language := req.Language
if language == "" {
language = "en"
}
course := &Course{
CreatorID: creatorID,
Title: req.Title,
Slug: slug,
Description: req.Description,
Category: req.Category,
Tags: req.Tags,
CoverImageURL: req.CoverImageURL,
PriceCents: req.PriceCents,
Currency: currency,
PricingModel: pricingModel,
MinimumPriceCents: req.MinimumPriceCents,
Status: CourseStatusDraft,
Level: level,
Language: language,
}
if err := s.db.WithContext(ctx).Create(course).Error; err != nil {
if strings.Contains(err.Error(), "duplicate") && strings.Contains(err.Error(), "slug") {
return nil, ErrSlugAlreadyTaken
}
return nil, fmt.Errorf("failed to create course: %w", err)
}
s.logger.Info("Course created",
zap.String("course_id", course.ID.String()),
zap.String("creator_id", creatorID.String()),
)
return course, nil
}
// UpdateCourseRequest holds updatable course fields
type UpdateCourseRequest struct {
Title *string `json:"title"`
Description *string `json:"description"`
Category *string `json:"category"`
Tags []string `json:"tags"`
CoverImageURL *string `json:"cover_image_url"`
PriceCents *int `json:"price_cents"`
Currency *string `json:"currency"`
PricingModel *PricingModel `json:"pricing_model"`
MinimumPriceCents *int `json:"minimum_price_cents"`
Level *CourseLevel `json:"level"`
Language *string `json:"language"`
}
// UpdateCourse updates an existing course
func (s *Service) UpdateCourse(ctx context.Context, creatorID, courseID uuid.UUID, req UpdateCourseRequest) (*Course, error) {
course, err := s.getCourseByOwner(ctx, courseID, creatorID)
if err != nil {
return nil, err
}
updates := map[string]interface{}{}
if req.Title != nil {
updates["title"] = *req.Title
}
if req.Description != nil {
updates["description"] = *req.Description
}
if req.Category != nil {
updates["category"] = *req.Category
}
if req.Tags != nil {
updates["tags"] = req.Tags
}
if req.CoverImageURL != nil {
updates["cover_image_url"] = *req.CoverImageURL
}
if req.PriceCents != nil {
updates["price_cents"] = *req.PriceCents
}
if req.Currency != nil {
updates["currency"] = *req.Currency
}
if req.PricingModel != nil {
updates["pricing_model"] = *req.PricingModel
}
if req.MinimumPriceCents != nil {
updates["minimum_price_cents"] = *req.MinimumPriceCents
}
if req.Level != nil {
updates["level"] = *req.Level
}
if req.Language != nil {
updates["language"] = *req.Language
}
if len(updates) > 0 {
if err := s.db.WithContext(ctx).Model(course).Updates(updates).Error; err != nil {
return nil, fmt.Errorf("failed to update course: %w", err)
}
}
return course, nil
}
// PublishCourse sets a course to published status
func (s *Service) PublishCourse(ctx context.Context, creatorID, courseID uuid.UUID) (*Course, error) {
course, err := s.getCourseByOwner(ctx, courseID, creatorID)
if err != nil {
return nil, err
}
now := time.Now()
course.Status = CourseStatusPublished
course.PublishedAt = &now
if err := s.db.WithContext(ctx).Save(course).Error; err != nil {
return nil, fmt.Errorf("failed to publish course: %w", err)
}
s.logger.Info("Course published",
zap.String("course_id", courseID.String()),
)
return course, nil
}
// ArchiveCourse sets a course to archived status
func (s *Service) ArchiveCourse(ctx context.Context, creatorID, courseID uuid.UUID) (*Course, error) {
course, err := s.getCourseByOwner(ctx, courseID, creatorID)
if err != nil {
return nil, err
}
course.Status = CourseStatusArchived
if err := s.db.WithContext(ctx).Save(course).Error; err != nil {
return nil, fmt.Errorf("failed to archive course: %w", err)
}
return course, nil
}
// GetCourse returns a course by ID (public, for any user)
func (s *Service) GetCourse(ctx context.Context, courseID uuid.UUID) (*Course, error) {
var course Course
err := s.db.WithContext(ctx).First(&course, "id = ?", courseID).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrCourseNotFound
}
return nil, fmt.Errorf("failed to get course: %w", err)
}
return &course, nil
}
// GetCourseBySlug returns a course by its slug
func (s *Service) GetCourseBySlug(ctx context.Context, slug string) (*Course, error) {
var course Course
err := s.db.WithContext(ctx).First(&course, "slug = ?", slug).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrCourseNotFound
}
return nil, fmt.Errorf("failed to get course by slug: %w", err)
}
return &course, nil
}
// ListPublishedCourses returns published courses with optional filters
func (s *Service) ListPublishedCourses(ctx context.Context, category *string, level *CourseLevel, language *string, limit, offset int) ([]Course, int64, error) {
if limit <= 0 || limit > 100 {
limit = 20
}
if offset < 0 {
offset = 0
}
query := s.db.WithContext(ctx).Where("status = ?", string(CourseStatusPublished))
if category != nil && *category != "" {
query = query.Where("category = ?", *category)
}
if level != nil {
query = query.Where("level = ?", string(*level))
}
if language != nil && *language != "" {
query = query.Where("language = ?", *language)
}
var total int64
query.Model(&Course{}).Count(&total)
var courses []Course
err := query.Order("published_at DESC").Limit(limit).Offset(offset).Find(&courses).Error
if err != nil {
return nil, 0, fmt.Errorf("failed to list courses: %w", err)
}
return courses, total, nil
}
// ListCreatorCourses returns all courses for a creator
func (s *Service) ListCreatorCourses(ctx context.Context, creatorID uuid.UUID, limit, offset int) ([]Course, int64, error) {
if limit <= 0 || limit > 100 {
limit = 20
}
if offset < 0 {
offset = 0
}
query := s.db.WithContext(ctx).Where("creator_id = ?", creatorID)
var total int64
query.Model(&Course{}).Count(&total)
var courses []Course
err := query.Order("created_at DESC").Limit(limit).Offset(offset).Find(&courses).Error
if err != nil {
return nil, 0, fmt.Errorf("failed to list creator courses: %w", err)
}
return courses, total, nil
}
// DeleteCourse soft-deletes a course
func (s *Service) DeleteCourse(ctx context.Context, creatorID, courseID uuid.UUID) error {
course, err := s.getCourseByOwner(ctx, courseID, creatorID)
if err != nil {
return err
}
if err := s.db.WithContext(ctx).Delete(course).Error; err != nil {
return fmt.Errorf("failed to delete course: %w", err)
}
return nil
}
// --- Lesson CRUD ---
// CreateLessonRequest holds the fields for creating a lesson
type CreateLessonRequest struct {
Title string `json:"title" binding:"required"`
Description string `json:"description"`
VideoFilePath string `json:"video_file_path"`
DurationSeconds int `json:"duration_seconds"`
IsPreviewFree bool `json:"is_preview_free"`
}
// CreateLesson adds a lesson to a course
func (s *Service) CreateLesson(ctx context.Context, creatorID, courseID uuid.UUID, req CreateLessonRequest) (*Lesson, error) {
if _, err := s.getCourseByOwner(ctx, courseID, creatorID); err != nil {
return nil, err
}
// Get next order index
var maxOrder int
s.db.WithContext(ctx).Model(&Lesson{}).Where("course_id = ?", courseID).
Select("COALESCE(MAX(order_index), 0)").Scan(&maxOrder)
lesson := &Lesson{
CourseID: courseID,
OrderIndex: maxOrder + 1,
Title: req.Title,
Description: req.Description,
VideoFilePath: req.VideoFilePath,
DurationSeconds: req.DurationSeconds,
IsPreviewFree: req.IsPreviewFree,
}
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Create(lesson).Error; err != nil {
return fmt.Errorf("failed to create lesson: %w", err)
}
// Update course counters
return tx.Model(&Course{}).Where("id = ?", courseID).
UpdateColumns(map[string]interface{}{
"lesson_count": gorm.Expr("lesson_count + 1"),
"total_duration_seconds": gorm.Expr("total_duration_seconds + ?", req.DurationSeconds),
}).Error
})
if err != nil {
return nil, err
}
return lesson, nil
}
// UpdateLessonRequest holds updatable lesson fields
type UpdateLessonRequest struct {
Title *string `json:"title"`
Description *string `json:"description"`
VideoFilePath *string `json:"video_file_path"`
DurationSeconds *int `json:"duration_seconds"`
IsPreviewFree *bool `json:"is_preview_free"`
}
// UpdateLesson updates a lesson
func (s *Service) UpdateLesson(ctx context.Context, creatorID, courseID, lessonID uuid.UUID, req UpdateLessonRequest) (*Lesson, error) {
if _, err := s.getCourseByOwner(ctx, courseID, creatorID); err != nil {
return nil, err
}
var lesson Lesson
err := s.db.WithContext(ctx).First(&lesson, "id = ? AND course_id = ?", lessonID, courseID).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrLessonNotFound
}
return nil, fmt.Errorf("failed to get lesson: %w", err)
}
oldDuration := lesson.DurationSeconds
updates := map[string]interface{}{}
if req.Title != nil {
updates["title"] = *req.Title
}
if req.Description != nil {
updates["description"] = *req.Description
}
if req.VideoFilePath != nil {
updates["video_file_path"] = *req.VideoFilePath
updates["transcoding_status"] = string(TranscodingPending)
}
if req.DurationSeconds != nil {
updates["duration_seconds"] = *req.DurationSeconds
}
if req.IsPreviewFree != nil {
updates["is_preview_free"] = *req.IsPreviewFree
}
if len(updates) > 0 {
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&lesson).Updates(updates).Error; err != nil {
return err
}
// Update course duration if changed
if req.DurationSeconds != nil && *req.DurationSeconds != oldDuration {
diff := *req.DurationSeconds - oldDuration
return tx.Model(&Course{}).Where("id = ?", courseID).
UpdateColumn("total_duration_seconds", gorm.Expr("total_duration_seconds + ?", diff)).Error
}
return nil
})
if err != nil {
return nil, fmt.Errorf("failed to update lesson: %w", err)
}
}
return &lesson, nil
}
// DeleteLesson removes a lesson
func (s *Service) DeleteLesson(ctx context.Context, creatorID, courseID, lessonID uuid.UUID) error {
if _, err := s.getCourseByOwner(ctx, courseID, creatorID); err != nil {
return err
}
var lesson Lesson
err := s.db.WithContext(ctx).First(&lesson, "id = ? AND course_id = ?", lessonID, courseID).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrLessonNotFound
}
return fmt.Errorf("failed to get lesson: %w", err)
}
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Delete(&lesson).Error; err != nil {
return err
}
return tx.Model(&Course{}).Where("id = ?", courseID).
UpdateColumns(map[string]interface{}{
"lesson_count": gorm.Expr("lesson_count - 1"),
"total_duration_seconds": gorm.Expr("total_duration_seconds - ?", lesson.DurationSeconds),
}).Error
})
}
// GetCourseLessons returns all lessons for a course in order
func (s *Service) GetCourseLessons(ctx context.Context, courseID uuid.UUID) ([]Lesson, error) {
var lessons []Lesson
err := s.db.WithContext(ctx).
Where("course_id = ?", courseID).
Order("order_index ASC").
Find(&lessons).Error
if err != nil {
return nil, fmt.Errorf("failed to get lessons: %w", err)
}
return lessons, nil
}
// ReorderLessons updates the order of lessons
type ReorderLessonsRequest struct {
LessonIDs []uuid.UUID `json:"lesson_ids" binding:"required"`
}
func (s *Service) ReorderLessons(ctx context.Context, creatorID, courseID uuid.UUID, req ReorderLessonsRequest) error {
if _, err := s.getCourseByOwner(ctx, courseID, creatorID); err != nil {
return err
}
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
for i, id := range req.LessonIDs {
if err := tx.Model(&Lesson{}).
Where("id = ? AND course_id = ?", id, courseID).
Update("order_index", i+1).Error; err != nil {
return fmt.Errorf("failed to reorder lesson %s: %w", id, err)
}
}
return nil
})
}
// --- Enrollments ---
// EnrollRequest holds the parameters for enrolling in a course
type EnrollRequest struct {
PricePaidCents int `json:"price_paid_cents"`
Currency string `json:"currency"`
}
// Enroll enrolls a user in a course
func (s *Service) Enroll(ctx context.Context, userID, courseID uuid.UUID, req EnrollRequest) (*CourseEnrollment, error) {
// Get course
course, err := s.GetCourse(ctx, courseID)
if err != nil {
return nil, err
}
if course.Status != CourseStatusPublished {
return nil, ErrCourseNotPublished
}
if course.CreatorID == userID {
return nil, ErrCannotEnrollOwnCourse
}
// Check existing enrollment
var existing int64
s.db.WithContext(ctx).Model(&CourseEnrollment{}).
Where("user_id = ? AND course_id = ?", userID, courseID).
Count(&existing)
if existing > 0 {
return nil, ErrAlreadyEnrolled
}
currency := req.Currency
if currency == "" {
currency = course.Currency
}
enrollment := &CourseEnrollment{
UserID: userID,
CourseID: courseID,
PurchasedPriceCents: req.PricePaidCents,
Currency: currency,
Status: EnrollmentActive,
PurchasedAt: time.Now(),
}
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Create(enrollment).Error; err != nil {
if strings.Contains(err.Error(), "duplicate") {
return ErrAlreadyEnrolled
}
return fmt.Errorf("failed to create enrollment: %w", err)
}
return tx.Model(&Course{}).Where("id = ?", courseID).
UpdateColumn("enrollment_count", gorm.Expr("enrollment_count + 1")).Error
})
if err != nil {
return nil, err
}
s.logger.Info("User enrolled in course",
zap.String("user_id", userID.String()),
zap.String("course_id", courseID.String()),
)
return enrollment, nil
}
// GetUserEnrollments returns all enrollments for a user
func (s *Service) GetUserEnrollments(ctx context.Context, userID uuid.UUID, limit, offset int) ([]CourseEnrollment, int64, error) {
if limit <= 0 || limit > 100 {
limit = 20
}
if offset < 0 {
offset = 0
}
query := s.db.WithContext(ctx).Where("user_id = ?", userID)
var total int64
query.Model(&CourseEnrollment{}).Count(&total)
var enrollments []CourseEnrollment
err := query.Order("purchased_at DESC").Limit(limit).Offset(offset).Find(&enrollments).Error
if err != nil {
return nil, 0, fmt.Errorf("failed to get enrollments: %w", err)
}
return enrollments, total, nil
}
// GetEnrollment returns a specific enrollment
func (s *Service) GetEnrollment(ctx context.Context, userID, courseID uuid.UUID) (*CourseEnrollment, error) {
var enrollment CourseEnrollment
err := s.db.WithContext(ctx).First(&enrollment, "user_id = ? AND course_id = ?", userID, courseID).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrNotEnrolled
}
return nil, fmt.Errorf("failed to get enrollment: %w", err)
}
return &enrollment, nil
}
// --- Progress ---
// UpdateProgressRequest holds progress update data
type UpdateProgressRequest struct {
WatchedPercentage int `json:"watched_percentage"`
WatchedDurationSeconds int `json:"watched_duration_seconds"`
PlaybackPositionSeconds int `json:"playback_position_seconds"`
}
// UpdateProgress updates or creates lesson progress
func (s *Service) UpdateProgress(ctx context.Context, userID, lessonID uuid.UUID, req UpdateProgressRequest) (*LessonProgress, error) {
// Get the lesson to find the course
var lesson Lesson
err := s.db.WithContext(ctx).First(&lesson, "id = ?", lessonID).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrLessonNotFound
}
return nil, fmt.Errorf("failed to get lesson: %w", err)
}
// Get enrollment
enrollment, err := s.GetEnrollment(ctx, userID, lesson.CourseID)
if err != nil {
return nil, err
}
// Clamp percentage
percentage := req.WatchedPercentage
if percentage < 0 {
percentage = 0
}
if percentage > 100 {
percentage = 100
}
// Upsert progress
var progress LessonProgress
err = s.db.WithContext(ctx).
Where("user_id = ? AND lesson_id = ?", userID, lessonID).
First(&progress).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
progress = LessonProgress{
UserID: userID,
LessonID: lessonID,
EnrollmentID: enrollment.ID,
WatchedPercentage: percentage,
WatchedDurationSeconds: req.WatchedDurationSeconds,
PlaybackPositionSeconds: req.PlaybackPositionSeconds,
}
if percentage >= 90 {
progress.IsCompleted = true
now := time.Now()
progress.CompletedAt = &now
}
if err := s.db.WithContext(ctx).Create(&progress).Error; err != nil {
return nil, fmt.Errorf("failed to create progress: %w", err)
}
} else if err != nil {
return nil, fmt.Errorf("failed to get progress: %w", err)
} else {
updates := map[string]interface{}{
"watched_percentage": percentage,
"watched_duration_seconds": req.WatchedDurationSeconds,
"playback_position_seconds": req.PlaybackPositionSeconds,
}
if percentage >= 90 && !progress.IsCompleted {
updates["is_completed"] = true
now := time.Now()
updates["completed_at"] = now
}
if err := s.db.WithContext(ctx).Model(&progress).Updates(updates).Error; err != nil {
return nil, fmt.Errorf("failed to update progress: %w", err)
}
}
return &progress, nil
}
// GetCourseProgress returns all lesson progress for a user in a course
func (s *Service) GetCourseProgress(ctx context.Context, userID, courseID uuid.UUID) ([]LessonProgress, error) {
enrollment, err := s.GetEnrollment(ctx, userID, courseID)
if err != nil {
return nil, err
}
var progress []LessonProgress
err = s.db.WithContext(ctx).
Where("enrollment_id = ?", enrollment.ID).
Find(&progress).Error
if err != nil {
return nil, fmt.Errorf("failed to get course progress: %w", err)
}
return progress, nil
}
// --- Certificates ---
// IssueCertificate issues a completion certificate for a fully completed course
func (s *Service) IssueCertificate(ctx context.Context, userID, courseID uuid.UUID) (*Certificate, error) {
enrollment, err := s.GetEnrollment(ctx, userID, courseID)
if err != nil {
return nil, err
}
// Check if certificate already exists
var existingCount int64
s.db.WithContext(ctx).Model(&Certificate{}).
Where("user_id = ? AND course_id = ?", userID, courseID).
Count(&existingCount)
if existingCount > 0 {
return nil, ErrCertificateExists
}
// Check all lessons are completed
var lessonCount int64
s.db.WithContext(ctx).Model(&Lesson{}).Where("course_id = ?", courseID).Count(&lessonCount)
var completedCount int64
s.db.WithContext(ctx).Model(&LessonProgress{}).
Where("enrollment_id = ? AND is_completed = true", enrollment.ID).
Count(&completedCount)
if lessonCount == 0 || completedCount < lessonCount {
return nil, ErrCourseNotCompleted
}
code := fmt.Sprintf("VEZA-CERT-%s-%s", courseID.String()[:8], uuid.New().String()[:8])
cert := &Certificate{
UserID: userID,
CourseID: courseID,
EnrollmentID: enrollment.ID,
CertificateCode: code,
IssueDate: time.Now(),
Status: CertificateActive,
}
if err := s.db.WithContext(ctx).Create(cert).Error; err != nil {
return nil, fmt.Errorf("failed to issue certificate: %w", err)
}
s.logger.Info("Certificate issued",
zap.String("user_id", userID.String()),
zap.String("course_id", courseID.String()),
zap.String("certificate_code", code),
)
return cert, nil
}
// GetCertificate returns a certificate by code (public verification)
func (s *Service) GetCertificate(ctx context.Context, code string) (*Certificate, error) {
var cert Certificate
err := s.db.WithContext(ctx).First(&cert, "certificate_code = ?", code).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrCertificateNotFound
}
return nil, fmt.Errorf("failed to get certificate: %w", err)
}
return &cert, nil
}
// GetUserCertificates returns all certificates for a user
func (s *Service) GetUserCertificates(ctx context.Context, userID uuid.UUID) ([]Certificate, error) {
var certs []Certificate
err := s.db.WithContext(ctx).
Where("user_id = ?", userID).
Order("issue_date DESC").
Find(&certs).Error
if err != nil {
return nil, fmt.Errorf("failed to get certificates: %w", err)
}
return certs, nil
}
// --- Reviews ---
// CreateReviewRequest holds the fields for creating a review
type CreateReviewRequest struct {
Rating int `json:"rating" binding:"required"`
Title string `json:"title"`
Content string `json:"content" binding:"required"`
}
// CreateReview creates a course review
func (s *Service) CreateReview(ctx context.Context, userID, courseID uuid.UUID, req CreateReviewRequest) (*CourseReview, error) {
if req.Rating < 1 || req.Rating > 5 {
return nil, ErrInvalidRating
}
// Must be enrolled
enrollment, err := s.GetEnrollment(ctx, userID, courseID)
if err != nil {
return nil, err
}
// Check existing review
var existing int64
s.db.WithContext(ctx).Model(&CourseReview{}).
Where("user_id = ? AND course_id = ?", userID, courseID).
Count(&existing)
if existing > 0 {
return nil, ErrReviewAlreadyExists
}
review := &CourseReview{
CourseID: courseID,
UserID: userID,
EnrollmentID: enrollment.ID,
Rating: req.Rating,
Title: req.Title,
Content: req.Content,
Status: ReviewApproved,
}
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Create(review).Error; err != nil {
if strings.Contains(err.Error(), "duplicate") {
return ErrReviewAlreadyExists
}
return fmt.Errorf("failed to create review: %w", err)
}
// Update course review count and average
return tx.Model(&Course{}).Where("id = ?", courseID).
UpdateColumns(map[string]interface{}{
"review_count": gorm.Expr("review_count + 1"),
"average_rating": gorm.Expr(
"(SELECT COALESCE(AVG(rating), 0) FROM course_reviews WHERE course_id = ? AND deleted_at IS NULL AND status = 'approved')",
courseID,
),
}).Error
})
if err != nil {
return nil, err
}
return review, nil
}
// GetCourseReviews returns reviews for a course
func (s *Service) GetCourseReviews(ctx context.Context, courseID uuid.UUID, limit, offset int) ([]CourseReview, int64, error) {
if limit <= 0 || limit > 100 {
limit = 20
}
if offset < 0 {
offset = 0
}
query := s.db.WithContext(ctx).Where("course_id = ? AND status = ?", courseID, string(ReviewApproved))
var total int64
query.Model(&CourseReview{}).Count(&total)
var reviews []CourseReview
err := query.Order("created_at DESC").Limit(limit).Offset(offset).Find(&reviews).Error
if err != nil {
return nil, 0, fmt.Errorf("failed to get reviews: %w", err)
}
return reviews, total, nil
}
// --- Helpers ---
func (s *Service) getCourseByOwner(ctx context.Context, courseID, creatorID uuid.UUID) (*Course, error) {
var course Course
err := s.db.WithContext(ctx).First(&course, "id = ?", courseID).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrCourseNotFound
}
return nil, fmt.Errorf("failed to get course: %w", err)
}
if course.CreatorID != creatorID {
return nil, ErrNotCourseOwner
}
return &course, nil
}
func generateSlug(title string) string {
slug := strings.ToLower(title)
slug = strings.Map(func(r rune) rune {
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
return r
}
if r == ' ' || r == '-' || r == '_' {
return '-'
}
return -1
}, slug)
// Remove consecutive dashes
for strings.Contains(slug, "--") {
slug = strings.ReplaceAll(slug, "--", "-")
}
slug = strings.Trim(slug, "-")
if slug == "" {
slug = uuid.New().String()[:8]
}
return slug
}

View file

@ -0,0 +1,279 @@
package education
import (
"testing"
"github.com/google/uuid"
)
func TestCourseTableName(t *testing.T) {
c := Course{}
if c.TableName() != "courses" {
t.Errorf("expected courses, got %s", c.TableName())
}
}
func TestLessonTableName(t *testing.T) {
l := Lesson{}
if l.TableName() != "lessons" {
t.Errorf("expected lessons, got %s", l.TableName())
}
}
func TestCourseEnrollmentTableName(t *testing.T) {
e := CourseEnrollment{}
if e.TableName() != "course_enrollments" {
t.Errorf("expected course_enrollments, got %s", e.TableName())
}
}
func TestLessonProgressTableName(t *testing.T) {
p := LessonProgress{}
if p.TableName() != "lesson_progress" {
t.Errorf("expected lesson_progress, got %s", p.TableName())
}
}
func TestCertificateTableName(t *testing.T) {
cert := Certificate{}
if cert.TableName() != "certificates" {
t.Errorf("expected certificates, got %s", cert.TableName())
}
}
func TestCourseReviewTableName(t *testing.T) {
r := CourseReview{}
if r.TableName() != "course_reviews" {
t.Errorf("expected course_reviews, got %s", r.TableName())
}
}
func TestCourseStatusConstants(t *testing.T) {
tests := []struct {
name string
status CourseStatus
expected string
}{
{"draft", CourseStatusDraft, "draft"},
{"published", CourseStatusPublished, "published"},
{"archived", CourseStatusArchived, "archived"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if string(tt.status) != tt.expected {
t.Errorf("expected %s, got %s", tt.expected, string(tt.status))
}
})
}
}
func TestCourseLevelConstants(t *testing.T) {
tests := []struct {
name string
level CourseLevel
expected string
}{
{"beginner", CourseLevelBeginner, "beginner"},
{"intermediate", CourseLevelIntermediate, "intermediate"},
{"advanced", CourseLevelAdvanced, "advanced"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if string(tt.level) != tt.expected {
t.Errorf("expected %s, got %s", tt.expected, string(tt.level))
}
})
}
}
func TestPricingModelConstants(t *testing.T) {
tests := []struct {
name string
model PricingModel
expected string
}{
{"fixed", PricingFixed, "fixed"},
{"pay_what_you_want", PricingPayWhatYou, "pay_what_you_want"},
{"free", PricingFree, "free"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if string(tt.model) != tt.expected {
t.Errorf("expected %s, got %s", tt.expected, string(tt.model))
}
})
}
}
func TestTranscodingStatusConstants(t *testing.T) {
tests := []struct {
name string
status TranscodingStatus
expected string
}{
{"pending", TranscodingPending, "pending"},
{"processing", TranscodingProcessing, "processing"},
{"complete", TranscodingComplete, "complete"},
{"failed", TranscodingFailed, "failed"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if string(tt.status) != tt.expected {
t.Errorf("expected %s, got %s", tt.expected, string(tt.status))
}
})
}
}
func TestServiceErrors(t *testing.T) {
tests := []struct {
name string
err error
msg string
}{
{"course not found", ErrCourseNotFound, "course not found"},
{"lesson not found", ErrLessonNotFound, "lesson not found"},
{"enrollment not found", ErrEnrollmentNotFound, "enrollment not found"},
{"already enrolled", ErrAlreadyEnrolled, "already enrolled in this course"},
{"not enrolled", ErrNotEnrolled, "not enrolled in this course"},
{"course not published", ErrCourseNotPublished, "course is not published"},
{"not course owner", ErrNotCourseOwner, "you are not the owner of this course"},
{"review exists", ErrReviewAlreadyExists, "you have already reviewed this course"},
{"invalid rating", ErrInvalidRating, "rating must be between 1 and 5"},
{"certificate not found", ErrCertificateNotFound, "certificate not found"},
{"course not completed", ErrCourseNotCompleted, "course is not fully completed"},
{"certificate exists", ErrCertificateExists, "certificate already issued for this course"},
{"cannot enroll own", ErrCannotEnrollOwnCourse, "cannot enroll in your own course"},
{"slug taken", ErrSlugAlreadyTaken, "slug is already taken"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.err.Error() != tt.msg {
t.Errorf("expected %q, got %q", tt.msg, tt.err.Error())
}
})
}
}
func TestGenerateSlug(t *testing.T) {
tests := []struct {
input string
expected string
}{
{"My First Course", "my-first-course"},
{"Introduction à la Musique", "introduction--la-musique"},
{"Hello World 123", "hello-world-123"},
{" spaces ", "spaces"},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
got := generateSlug(tt.input)
if got == "" {
t.Error("slug should not be empty")
}
})
}
}
func TestNewService(t *testing.T) {
svc := NewService(nil, nil)
if svc == nil {
t.Fatal("expected non-nil service")
}
}
func TestCourseFields(t *testing.T) {
course := Course{
ID: uuid.New(),
CreatorID: uuid.New(),
Title: "Test Course",
Slug: "test-course",
PriceCents: 1999,
Currency: "USD",
Status: CourseStatusDraft,
Level: CourseLevelBeginner,
Language: "en",
}
if course.Title != "Test Course" {
t.Errorf("expected Test Course, got %s", course.Title)
}
if course.PriceCents != 1999 {
t.Errorf("expected 1999, got %d", course.PriceCents)
}
if course.Status != CourseStatusDraft {
t.Errorf("expected draft, got %s", course.Status)
}
}
func TestLessonFields(t *testing.T) {
lesson := Lesson{
ID: uuid.New(),
CourseID: uuid.New(),
OrderIndex: 1,
Title: "Lesson 1",
DurationSeconds: 600,
IsPreviewFree: true,
TranscodingStatus: TranscodingPending,
}
if lesson.OrderIndex != 1 {
t.Errorf("expected 1, got %d", lesson.OrderIndex)
}
if !lesson.IsPreviewFree {
t.Error("expected is_preview_free to be true")
}
if lesson.DurationSeconds != 600 {
t.Errorf("expected 600, got %d", lesson.DurationSeconds)
}
}
func TestEnrollmentFields(t *testing.T) {
enrollment := CourseEnrollment{
ID: uuid.New(),
UserID: uuid.New(),
CourseID: uuid.New(),
PurchasedPriceCents: 1999,
Currency: "USD",
Status: EnrollmentActive,
}
if enrollment.PurchasedPriceCents != 1999 {
t.Errorf("expected 1999, got %d", enrollment.PurchasedPriceCents)
}
if enrollment.Status != EnrollmentActive {
t.Errorf("expected active, got %s", enrollment.Status)
}
}
func TestCertificateFields(t *testing.T) {
cert := Certificate{
ID: uuid.New(),
CertificateCode: "VEZA-CERT-abc12345-def67890",
Status: CertificateActive,
}
if cert.Status != CertificateActive {
t.Errorf("expected active, got %s", cert.Status)
}
if cert.CertificateCode == "" {
t.Error("expected non-empty certificate code")
}
}
func TestReviewFields(t *testing.T) {
review := CourseReview{
ID: uuid.New(),
Rating: 4,
Title: "Great course",
Content: "Loved it!",
Status: ReviewApproved,
}
if review.Rating != 4 {
t.Errorf("expected 4, got %d", review.Rating)
}
if review.Status != ReviewApproved {
t.Errorf("expected approved, got %s", review.Status)
}
}

View file

@ -0,0 +1,683 @@
package handlers
import (
"errors"
"net/http"
"strconv"
"veza-backend-api/internal/core/education"
apperrors "veza-backend-api/internal/errors"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"go.uber.org/zap"
)
// EducationHandler handles education HTTP endpoints (v0.12.3 F276-F305)
type EducationHandler struct {
service *education.Service
logger *zap.Logger
}
// NewEducationHandler creates a new EducationHandler
func NewEducationHandler(service *education.Service, logger *zap.Logger) *EducationHandler {
return &EducationHandler{
service: service,
logger: logger,
}
}
// --- Course endpoints ---
// CreateCourse handles POST /courses
func (h *EducationHandler) CreateCourse(c *gin.Context) {
userID, ok := GetUserIDUUID(c)
if !ok {
return
}
var req education.CreateCourseRequest
if err := c.ShouldBindJSON(&req); err != nil {
RespondWithAppError(c, apperrors.NewValidationError("Invalid request: title is required"))
return
}
course, err := h.service.CreateCourse(c.Request.Context(), userID, req)
if err != nil {
if errors.Is(err, education.ErrSlugAlreadyTaken) {
RespondWithAppError(c, apperrors.NewValidationError("Slug is already taken"))
return
}
RespondWithAppError(c, apperrors.NewInternalErrorWrap("Failed to create course", err))
return
}
RespondSuccess(c, http.StatusCreated, course)
}
// UpdateCourse handles PUT /courses/:id
func (h *EducationHandler) UpdateCourse(c *gin.Context) {
userID, ok := GetUserIDUUID(c)
if !ok {
return
}
courseID, err := uuid.Parse(c.Param("id"))
if err != nil {
RespondWithAppError(c, apperrors.NewValidationError("Invalid course ID"))
return
}
var req education.UpdateCourseRequest
if err := c.ShouldBindJSON(&req); err != nil {
RespondWithAppError(c, apperrors.NewValidationError("Invalid request body"))
return
}
course, err := h.service.UpdateCourse(c.Request.Context(), userID, courseID, req)
if err != nil {
h.handleCourseError(c, err)
return
}
RespondSuccess(c, http.StatusOK, course)
}
// PublishCourse handles POST /courses/:id/publish
func (h *EducationHandler) PublishCourse(c *gin.Context) {
userID, ok := GetUserIDUUID(c)
if !ok {
return
}
courseID, err := uuid.Parse(c.Param("id"))
if err != nil {
RespondWithAppError(c, apperrors.NewValidationError("Invalid course ID"))
return
}
course, err := h.service.PublishCourse(c.Request.Context(), userID, courseID)
if err != nil {
h.handleCourseError(c, err)
return
}
RespondSuccess(c, http.StatusOK, course)
}
// ArchiveCourse handles POST /courses/:id/archive
func (h *EducationHandler) ArchiveCourse(c *gin.Context) {
userID, ok := GetUserIDUUID(c)
if !ok {
return
}
courseID, err := uuid.Parse(c.Param("id"))
if err != nil {
RespondWithAppError(c, apperrors.NewValidationError("Invalid course ID"))
return
}
course, err := h.service.ArchiveCourse(c.Request.Context(), userID, courseID)
if err != nil {
h.handleCourseError(c, err)
return
}
RespondSuccess(c, http.StatusOK, course)
}
// GetCourse handles GET /courses/:id
func (h *EducationHandler) GetCourse(c *gin.Context) {
courseID, err := uuid.Parse(c.Param("id"))
if err != nil {
RespondWithAppError(c, apperrors.NewValidationError("Invalid course ID"))
return
}
course, err := h.service.GetCourse(c.Request.Context(), courseID)
if err != nil {
if errors.Is(err, education.ErrCourseNotFound) {
RespondWithAppError(c, apperrors.NewNotFoundError("Course"))
return
}
RespondWithAppError(c, apperrors.NewInternalErrorWrap("Failed to get course", err))
return
}
RespondSuccess(c, http.StatusOK, course)
}
// GetCourseBySlug handles GET /courses/slug/:slug
func (h *EducationHandler) GetCourseBySlug(c *gin.Context) {
slug := c.Param("slug")
if slug == "" {
RespondWithAppError(c, apperrors.NewValidationError("Slug is required"))
return
}
course, err := h.service.GetCourseBySlug(c.Request.Context(), slug)
if err != nil {
if errors.Is(err, education.ErrCourseNotFound) {
RespondWithAppError(c, apperrors.NewNotFoundError("Course"))
return
}
RespondWithAppError(c, apperrors.NewInternalErrorWrap("Failed to get course", err))
return
}
RespondSuccess(c, http.StatusOK, course)
}
// ListPublishedCourses handles GET /courses
func (h *EducationHandler) ListPublishedCourses(c *gin.Context) {
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))
var category *string
if cat := c.Query("category"); cat != "" {
category = &cat
}
var level *education.CourseLevel
if lvl := c.Query("level"); lvl != "" {
l := education.CourseLevel(lvl)
level = &l
}
var language *string
if lang := c.Query("language"); lang != "" {
language = &lang
}
courses, total, err := h.service.ListPublishedCourses(c.Request.Context(), category, level, language, limit, offset)
if err != nil {
RespondWithAppError(c, apperrors.NewInternalErrorWrap("Failed to list courses", err))
return
}
page := (offset / max(limit, 1)) + 1
totalPages := int((total + int64(limit) - 1) / int64(limit))
RespondSuccess(c, http.StatusOK, gin.H{
"data": courses,
"pagination": gin.H{
"page": page,
"limit": limit,
"total": total,
"total_pages": totalPages,
},
})
}
// ListCreatorCourses handles GET /creators/me/courses
func (h *EducationHandler) ListCreatorCourses(c *gin.Context) {
userID, ok := GetUserIDUUID(c)
if !ok {
return
}
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))
courses, total, err := h.service.ListCreatorCourses(c.Request.Context(), userID, limit, offset)
if err != nil {
RespondWithAppError(c, apperrors.NewInternalErrorWrap("Failed to list courses", err))
return
}
page := (offset / max(limit, 1)) + 1
totalPages := int((total + int64(limit) - 1) / int64(limit))
RespondSuccess(c, http.StatusOK, gin.H{
"data": courses,
"pagination": gin.H{
"page": page,
"limit": limit,
"total": total,
"total_pages": totalPages,
},
})
}
// DeleteCourse handles DELETE /courses/:id
func (h *EducationHandler) DeleteCourse(c *gin.Context) {
userID, ok := GetUserIDUUID(c)
if !ok {
return
}
courseID, err := uuid.Parse(c.Param("id"))
if err != nil {
RespondWithAppError(c, apperrors.NewValidationError("Invalid course ID"))
return
}
if err := h.service.DeleteCourse(c.Request.Context(), userID, courseID); err != nil {
h.handleCourseError(c, err)
return
}
RespondSuccess(c, http.StatusOK, gin.H{"message": "Course deleted"})
}
// --- Lesson endpoints ---
// CreateLesson handles POST /courses/:id/lessons
func (h *EducationHandler) CreateLesson(c *gin.Context) {
userID, ok := GetUserIDUUID(c)
if !ok {
return
}
courseID, err := uuid.Parse(c.Param("id"))
if err != nil {
RespondWithAppError(c, apperrors.NewValidationError("Invalid course ID"))
return
}
var req education.CreateLessonRequest
if err := c.ShouldBindJSON(&req); err != nil {
RespondWithAppError(c, apperrors.NewValidationError("Invalid request: title is required"))
return
}
lesson, err := h.service.CreateLesson(c.Request.Context(), userID, courseID, req)
if err != nil {
h.handleCourseError(c, err)
return
}
RespondSuccess(c, http.StatusCreated, lesson)
}
// UpdateLesson handles PUT /courses/:id/lessons/:lesson_id
func (h *EducationHandler) UpdateLesson(c *gin.Context) {
userID, ok := GetUserIDUUID(c)
if !ok {
return
}
courseID, err := uuid.Parse(c.Param("id"))
if err != nil {
RespondWithAppError(c, apperrors.NewValidationError("Invalid course ID"))
return
}
lessonID, err := uuid.Parse(c.Param("lesson_id"))
if err != nil {
RespondWithAppError(c, apperrors.NewValidationError("Invalid lesson ID"))
return
}
var req education.UpdateLessonRequest
if err := c.ShouldBindJSON(&req); err != nil {
RespondWithAppError(c, apperrors.NewValidationError("Invalid request body"))
return
}
lesson, err := h.service.UpdateLesson(c.Request.Context(), userID, courseID, lessonID, req)
if err != nil {
h.handleCourseError(c, err)
return
}
RespondSuccess(c, http.StatusOK, lesson)
}
// DeleteLesson handles DELETE /courses/:id/lessons/:lesson_id
func (h *EducationHandler) DeleteLesson(c *gin.Context) {
userID, ok := GetUserIDUUID(c)
if !ok {
return
}
courseID, err := uuid.Parse(c.Param("id"))
if err != nil {
RespondWithAppError(c, apperrors.NewValidationError("Invalid course ID"))
return
}
lessonID, err := uuid.Parse(c.Param("lesson_id"))
if err != nil {
RespondWithAppError(c, apperrors.NewValidationError("Invalid lesson ID"))
return
}
if err := h.service.DeleteLesson(c.Request.Context(), userID, courseID, lessonID); err != nil {
h.handleCourseError(c, err)
return
}
RespondSuccess(c, http.StatusOK, gin.H{"message": "Lesson deleted"})
}
// GetCourseLessons handles GET /courses/:id/lessons
func (h *EducationHandler) GetCourseLessons(c *gin.Context) {
courseID, err := uuid.Parse(c.Param("id"))
if err != nil {
RespondWithAppError(c, apperrors.NewValidationError("Invalid course ID"))
return
}
lessons, err := h.service.GetCourseLessons(c.Request.Context(), courseID)
if err != nil {
RespondWithAppError(c, apperrors.NewInternalErrorWrap("Failed to get lessons", err))
return
}
RespondSuccess(c, http.StatusOK, gin.H{"lessons": lessons})
}
// ReorderLessons handles POST /courses/:id/lessons/reorder
func (h *EducationHandler) ReorderLessons(c *gin.Context) {
userID, ok := GetUserIDUUID(c)
if !ok {
return
}
courseID, err := uuid.Parse(c.Param("id"))
if err != nil {
RespondWithAppError(c, apperrors.NewValidationError("Invalid course ID"))
return
}
var req education.ReorderLessonsRequest
if err := c.ShouldBindJSON(&req); err != nil {
RespondWithAppError(c, apperrors.NewValidationError("Invalid request: lesson_ids is required"))
return
}
if err := h.service.ReorderLessons(c.Request.Context(), userID, courseID, req); err != nil {
h.handleCourseError(c, err)
return
}
RespondSuccess(c, http.StatusOK, gin.H{"message": "Lessons reordered"})
}
// --- Enrollment endpoints ---
// Enroll handles POST /courses/:id/enroll
func (h *EducationHandler) Enroll(c *gin.Context) {
userID, ok := GetUserIDUUID(c)
if !ok {
return
}
courseID, err := uuid.Parse(c.Param("id"))
if err != nil {
RespondWithAppError(c, apperrors.NewValidationError("Invalid course ID"))
return
}
var req education.EnrollRequest
if err := c.ShouldBindJSON(&req); err != nil {
// Allow empty body for free courses
req = education.EnrollRequest{}
}
enrollment, err := h.service.Enroll(c.Request.Context(), userID, courseID, req)
if err != nil {
switch {
case errors.Is(err, education.ErrCourseNotFound):
RespondWithAppError(c, apperrors.NewNotFoundError("Course"))
case errors.Is(err, education.ErrCourseNotPublished):
RespondWithAppError(c, apperrors.NewValidationError("Course is not published"))
case errors.Is(err, education.ErrAlreadyEnrolled):
RespondWithAppError(c, apperrors.NewValidationError("Already enrolled in this course"))
case errors.Is(err, education.ErrCannotEnrollOwnCourse):
RespondWithAppError(c, apperrors.NewValidationError("Cannot enroll in your own course"))
default:
RespondWithAppError(c, apperrors.NewInternalErrorWrap("Failed to enroll", err))
}
return
}
RespondSuccess(c, http.StatusCreated, enrollment)
}
// GetMyEnrollments handles GET /enrollments
func (h *EducationHandler) GetMyEnrollments(c *gin.Context) {
userID, ok := GetUserIDUUID(c)
if !ok {
return
}
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))
enrollments, total, err := h.service.GetUserEnrollments(c.Request.Context(), userID, limit, offset)
if err != nil {
RespondWithAppError(c, apperrors.NewInternalErrorWrap("Failed to get enrollments", err))
return
}
page := (offset / max(limit, 1)) + 1
totalPages := int((total + int64(limit) - 1) / int64(limit))
RespondSuccess(c, http.StatusOK, gin.H{
"data": enrollments,
"pagination": gin.H{
"page": page,
"limit": limit,
"total": total,
"total_pages": totalPages,
},
})
}
// --- Progress endpoints ---
// UpdateProgress handles POST /lessons/:lesson_id/progress
func (h *EducationHandler) UpdateProgress(c *gin.Context) {
userID, ok := GetUserIDUUID(c)
if !ok {
return
}
lessonID, err := uuid.Parse(c.Param("lesson_id"))
if err != nil {
RespondWithAppError(c, apperrors.NewValidationError("Invalid lesson ID"))
return
}
var req education.UpdateProgressRequest
if err := c.ShouldBindJSON(&req); err != nil {
RespondWithAppError(c, apperrors.NewValidationError("Invalid request body"))
return
}
progress, err := h.service.UpdateProgress(c.Request.Context(), userID, lessonID, req)
if err != nil {
switch {
case errors.Is(err, education.ErrLessonNotFound):
RespondWithAppError(c, apperrors.NewNotFoundError("Lesson"))
case errors.Is(err, education.ErrNotEnrolled):
RespondWithAppError(c, apperrors.NewForbiddenError("Not enrolled in this course"))
default:
RespondWithAppError(c, apperrors.NewInternalErrorWrap("Failed to update progress", err))
}
return
}
RespondSuccess(c, http.StatusOK, progress)
}
// GetCourseProgress handles GET /courses/:id/progress
func (h *EducationHandler) GetCourseProgress(c *gin.Context) {
userID, ok := GetUserIDUUID(c)
if !ok {
return
}
courseID, err := uuid.Parse(c.Param("id"))
if err != nil {
RespondWithAppError(c, apperrors.NewValidationError("Invalid course ID"))
return
}
progress, err := h.service.GetCourseProgress(c.Request.Context(), userID, courseID)
if err != nil {
if errors.Is(err, education.ErrNotEnrolled) {
RespondWithAppError(c, apperrors.NewForbiddenError("Not enrolled in this course"))
return
}
RespondWithAppError(c, apperrors.NewInternalErrorWrap("Failed to get progress", err))
return
}
RespondSuccess(c, http.StatusOK, gin.H{"progress": progress})
}
// --- Certificate endpoints ---
// IssueCertificate handles POST /courses/:id/certificate
func (h *EducationHandler) IssueCertificate(c *gin.Context) {
userID, ok := GetUserIDUUID(c)
if !ok {
return
}
courseID, err := uuid.Parse(c.Param("id"))
if err != nil {
RespondWithAppError(c, apperrors.NewValidationError("Invalid course ID"))
return
}
cert, err := h.service.IssueCertificate(c.Request.Context(), userID, courseID)
if err != nil {
switch {
case errors.Is(err, education.ErrNotEnrolled):
RespondWithAppError(c, apperrors.NewForbiddenError("Not enrolled in this course"))
case errors.Is(err, education.ErrCourseNotCompleted):
RespondWithAppError(c, apperrors.NewValidationError("Course is not fully completed"))
case errors.Is(err, education.ErrCertificateExists):
RespondWithAppError(c, apperrors.NewValidationError("Certificate already issued"))
default:
RespondWithAppError(c, apperrors.NewInternalErrorWrap("Failed to issue certificate", err))
}
return
}
RespondSuccess(c, http.StatusCreated, cert)
}
// VerifyCertificate handles GET /certificates/:code
func (h *EducationHandler) VerifyCertificate(c *gin.Context) {
code := c.Param("code")
if code == "" {
RespondWithAppError(c, apperrors.NewValidationError("Certificate code is required"))
return
}
cert, err := h.service.GetCertificate(c.Request.Context(), code)
if err != nil {
if errors.Is(err, education.ErrCertificateNotFound) {
RespondWithAppError(c, apperrors.NewNotFoundError("Certificate"))
return
}
RespondWithAppError(c, apperrors.NewInternalErrorWrap("Failed to verify certificate", err))
return
}
RespondSuccess(c, http.StatusOK, cert)
}
// GetMyCertificates handles GET /certificates
func (h *EducationHandler) GetMyCertificates(c *gin.Context) {
userID, ok := GetUserIDUUID(c)
if !ok {
return
}
certs, err := h.service.GetUserCertificates(c.Request.Context(), userID)
if err != nil {
RespondWithAppError(c, apperrors.NewInternalErrorWrap("Failed to get certificates", err))
return
}
RespondSuccess(c, http.StatusOK, gin.H{"certificates": certs})
}
// --- Review endpoints ---
// CreateReview handles POST /courses/:id/reviews
func (h *EducationHandler) CreateReview(c *gin.Context) {
userID, ok := GetUserIDUUID(c)
if !ok {
return
}
courseID, err := uuid.Parse(c.Param("id"))
if err != nil {
RespondWithAppError(c, apperrors.NewValidationError("Invalid course ID"))
return
}
var req education.CreateReviewRequest
if err := c.ShouldBindJSON(&req); err != nil {
RespondWithAppError(c, apperrors.NewValidationError("Invalid request: rating and content are required"))
return
}
review, err := h.service.CreateReview(c.Request.Context(), userID, courseID, req)
if err != nil {
switch {
case errors.Is(err, education.ErrNotEnrolled):
RespondWithAppError(c, apperrors.NewForbiddenError("Must be enrolled to review"))
case errors.Is(err, education.ErrReviewAlreadyExists):
RespondWithAppError(c, apperrors.NewValidationError("Already reviewed this course"))
case errors.Is(err, education.ErrInvalidRating):
RespondWithAppError(c, apperrors.NewValidationError("Rating must be between 1 and 5"))
default:
RespondWithAppError(c, apperrors.NewInternalErrorWrap("Failed to create review", err))
}
return
}
RespondSuccess(c, http.StatusCreated, review)
}
// GetCourseReviews handles GET /courses/:id/reviews
func (h *EducationHandler) GetCourseReviews(c *gin.Context) {
courseID, err := uuid.Parse(c.Param("id"))
if err != nil {
RespondWithAppError(c, apperrors.NewValidationError("Invalid course ID"))
return
}
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))
reviews, total, err := h.service.GetCourseReviews(c.Request.Context(), courseID, limit, offset)
if err != nil {
RespondWithAppError(c, apperrors.NewInternalErrorWrap("Failed to get reviews", err))
return
}
page := (offset / max(limit, 1)) + 1
totalPages := int((total + int64(limit) - 1) / int64(limit))
RespondSuccess(c, http.StatusOK, gin.H{
"data": reviews,
"pagination": gin.H{
"page": page,
"limit": limit,
"total": total,
"total_pages": totalPages,
},
})
}
// handleCourseError maps common course errors to HTTP responses
func (h *EducationHandler) handleCourseError(c *gin.Context, err error) {
switch {
case errors.Is(err, education.ErrCourseNotFound):
RespondWithAppError(c, apperrors.NewNotFoundError("Course"))
case errors.Is(err, education.ErrLessonNotFound):
RespondWithAppError(c, apperrors.NewNotFoundError("Lesson"))
case errors.Is(err, education.ErrNotCourseOwner):
RespondWithAppError(c, apperrors.NewForbiddenError("You are not the owner of this course"))
default:
RespondWithAppError(c, apperrors.NewInternalErrorWrap("Operation failed", err))
}
}

View file

@ -0,0 +1,223 @@
package handlers
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"go.uber.org/zap"
"veza-backend-api/internal/core/education"
)
func TestNewEducationHandler(t *testing.T) {
logger := zap.NewNop()
svc := education.NewService(nil, logger)
handler := NewEducationHandler(svc, logger)
if handler == nil {
t.Fatal("expected non-nil handler")
}
if handler.service == nil {
t.Error("expected non-nil service")
}
}
func TestEducationHandler_CreateCourse_NoAuth(t *testing.T) {
gin.SetMode(gin.TestMode)
logger := zap.NewNop()
svc := education.NewService(nil, logger)
handler := NewEducationHandler(svc, logger)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodPost, "/courses", strings.NewReader(`{}`))
c.Request.Header.Set("Content-Type", "application/json")
handler.CreateCourse(c)
if w.Code != http.StatusUnauthorized {
t.Errorf("expected 401, got %d", w.Code)
}
}
func TestEducationHandler_CreateCourse_InvalidBody(t *testing.T) {
gin.SetMode(gin.TestMode)
logger := zap.NewNop()
svc := education.NewService(nil, logger)
handler := NewEducationHandler(svc, logger)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Set("user_id", uuid.New())
c.Request = httptest.NewRequest(http.MethodPost, "/courses", strings.NewReader(`{}`))
c.Request.Header.Set("Content-Type", "application/json")
handler.CreateCourse(c)
var resp map[string]interface{}
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if resp["success"] != false {
t.Error("expected success=false for invalid request")
}
}
func TestEducationHandler_GetCourse_InvalidID(t *testing.T) {
gin.SetMode(gin.TestMode)
logger := zap.NewNop()
svc := education.NewService(nil, logger)
handler := NewEducationHandler(svc, logger)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "not-a-uuid"}}
c.Request = httptest.NewRequest(http.MethodGet, "/courses/not-a-uuid", nil)
handler.GetCourse(c)
var resp map[string]interface{}
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if resp["success"] != false {
t.Error("expected success=false for invalid ID")
}
}
func TestEducationHandler_GetCourseBySlug_EmptySlug(t *testing.T) {
gin.SetMode(gin.TestMode)
logger := zap.NewNop()
svc := education.NewService(nil, logger)
handler := NewEducationHandler(svc, logger)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "slug", Value: ""}}
c.Request = httptest.NewRequest(http.MethodGet, "/courses/slug/", nil)
handler.GetCourseBySlug(c)
var resp map[string]interface{}
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if resp["success"] != false {
t.Error("expected success=false for empty slug")
}
}
func TestEducationHandler_Enroll_NoAuth(t *testing.T) {
gin.SetMode(gin.TestMode)
logger := zap.NewNop()
svc := education.NewService(nil, logger)
handler := NewEducationHandler(svc, logger)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodPost, "/courses/123/enroll", strings.NewReader(`{}`))
c.Request.Header.Set("Content-Type", "application/json")
handler.Enroll(c)
if w.Code != http.StatusUnauthorized {
t.Errorf("expected 401, got %d", w.Code)
}
}
func TestEducationHandler_UpdateProgress_NoAuth(t *testing.T) {
gin.SetMode(gin.TestMode)
logger := zap.NewNop()
svc := education.NewService(nil, logger)
handler := NewEducationHandler(svc, logger)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodPost, "/lessons/123/progress", strings.NewReader(`{}`))
c.Request.Header.Set("Content-Type", "application/json")
handler.UpdateProgress(c)
if w.Code != http.StatusUnauthorized {
t.Errorf("expected 401, got %d", w.Code)
}
}
func TestEducationHandler_IssueCertificate_NoAuth(t *testing.T) {
gin.SetMode(gin.TestMode)
logger := zap.NewNop()
svc := education.NewService(nil, logger)
handler := NewEducationHandler(svc, logger)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodPost, "/courses/123/certificate", nil)
handler.IssueCertificate(c)
if w.Code != http.StatusUnauthorized {
t.Errorf("expected 401, got %d", w.Code)
}
}
func TestEducationHandler_CreateReview_NoAuth(t *testing.T) {
gin.SetMode(gin.TestMode)
logger := zap.NewNop()
svc := education.NewService(nil, logger)
handler := NewEducationHandler(svc, logger)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodPost, "/courses/123/reviews", strings.NewReader(`{}`))
c.Request.Header.Set("Content-Type", "application/json")
handler.CreateReview(c)
if w.Code != http.StatusUnauthorized {
t.Errorf("expected 401, got %d", w.Code)
}
}
func TestEducationHandler_GetMyEnrollments_NoAuth(t *testing.T) {
gin.SetMode(gin.TestMode)
logger := zap.NewNop()
svc := education.NewService(nil, logger)
handler := NewEducationHandler(svc, logger)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodGet, "/enrollments", nil)
handler.GetMyEnrollments(c)
if w.Code != http.StatusUnauthorized {
t.Errorf("expected 401, got %d", w.Code)
}
}
func TestEducationHandler_VerifyCertificate_EmptyCode(t *testing.T) {
gin.SetMode(gin.TestMode)
logger := zap.NewNop()
svc := education.NewService(nil, logger)
handler := NewEducationHandler(svc, logger)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "code", Value: ""}}
c.Request = httptest.NewRequest(http.MethodGet, "/certificates/", nil)
handler.VerifyCertificate(c)
var resp map[string]interface{}
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if resp["success"] != false {
t.Error("expected success=false for empty code")
}
}