diff --git a/veza-backend-api/internal/api/router.go b/veza-backend-api/internal/api/router.go index f2ed74a43..7ddeb8b03 100644 --- a/veza-backend-api/internal/api/router.go +++ b/veza-backend-api/internal/api/router.go @@ -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 diff --git a/veza-backend-api/internal/api/routes_education.go b/veza-backend-api/internal/api/routes_education.go new file mode 100644 index 000000000..2b081e911 --- /dev/null +++ b/veza-backend-api/internal/api/routes_education.go @@ -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) +} diff --git a/veza-backend-api/internal/core/education/models.go b/veza-backend-api/internal/core/education/models.go new file mode 100644 index 000000000..9b6decdd4 --- /dev/null +++ b/veza-backend-api/internal/core/education/models.go @@ -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 +} diff --git a/veza-backend-api/internal/core/education/service.go b/veza-backend-api/internal/core/education/service.go new file mode 100644 index 000000000..8232454fd --- /dev/null +++ b/veza-backend-api/internal/core/education/service.go @@ -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 +} diff --git a/veza-backend-api/internal/core/education/service_test.go b/veza-backend-api/internal/core/education/service_test.go new file mode 100644 index 000000000..ee60e52ec --- /dev/null +++ b/veza-backend-api/internal/core/education/service_test.go @@ -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) + } +} diff --git a/veza-backend-api/internal/handlers/education_handler.go b/veza-backend-api/internal/handlers/education_handler.go new file mode 100644 index 000000000..acb72864e --- /dev/null +++ b/veza-backend-api/internal/handlers/education_handler.go @@ -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)) + } +} diff --git a/veza-backend-api/internal/handlers/education_handler_test.go b/veza-backend-api/internal/handlers/education_handler_test.go new file mode 100644 index 000000000..f998e34cc --- /dev/null +++ b/veza-backend-api/internal/handlers/education_handler_test.go @@ -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") + } +}