feat(training+controls): interactive video pipeline, training blocks, control generator, CE libraries
Some checks failed
CI/CD / go-lint (push) Has been skipped
CI/CD / python-lint (push) Has been skipped
CI/CD / nodejs-lint (push) Has been skipped
CI/CD / test-go-ai-compliance (push) Failing after 37s
CI/CD / test-python-backend-compliance (push) Successful in 39s
CI/CD / test-python-document-crawler (push) Successful in 26s
CI/CD / test-python-dsms-gateway (push) Successful in 23s
CI/CD / validate-canonical-controls (push) Successful in 12s
CI/CD / Deploy (push) Has been skipped
Some checks failed
CI/CD / go-lint (push) Has been skipped
CI/CD / python-lint (push) Has been skipped
CI/CD / nodejs-lint (push) Has been skipped
CI/CD / test-go-ai-compliance (push) Failing after 37s
CI/CD / test-python-backend-compliance (push) Successful in 39s
CI/CD / test-python-document-crawler (push) Successful in 26s
CI/CD / test-python-dsms-gateway (push) Successful in 23s
CI/CD / validate-canonical-controls (push) Successful in 12s
CI/CD / Deploy (push) Has been skipped
Interactive Training Videos (CP-TRAIN): - DB migration 022: training_checkpoints + checkpoint_progress tables - NarratorScript generation via Anthropic (AI Teacher persona, German) - TTS batch synthesis + interactive video pipeline (slides + checkpoint slides + FFmpeg) - 4 new API endpoints: generate-interactive, interactive-manifest, checkpoint submit, checkpoint progress - InteractiveVideoPlayer component (HTML5 Video, quiz overlay, seek protection, progress tracking) - Learner portal integration with automatic completion on all checkpoints passed - 30 new tests (handler validation + grading logic + manifest/progress + seek protection) Training Blocks: - Block generator, block store, block config CRUD + preview/generate endpoints - Migration 021: training_blocks schema Control Generator + Canonical Library: - Control generator routes + service enhancements - Canonical control library helpers, sidebar entry - Citation backfill service + tests - CE libraries data (hazard, protection, evidence, lifecycle, components) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -25,7 +25,6 @@ func NewRAGHandlers(corpusVersionStore *ucca.CorpusVersionStore) *RAGHandlers {
|
||||
// AllowedCollections is the whitelist of Qdrant collections that can be queried.
|
||||
var AllowedCollections = map[string]bool{
|
||||
"bp_compliance_ce": true,
|
||||
"bp_compliance_recht": true,
|
||||
"bp_compliance_gesetze": true,
|
||||
"bp_compliance_datenschutz": true,
|
||||
"bp_compliance_gdpr": true,
|
||||
|
||||
@@ -3,7 +3,9 @@ package handlers
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/breakpilot/ai-compliance-sdk/internal/academy"
|
||||
"github.com/breakpilot/ai-compliance-sdk/internal/rbac"
|
||||
"github.com/breakpilot/ai-compliance-sdk/internal/training"
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -14,13 +16,17 @@ import (
|
||||
type TrainingHandlers struct {
|
||||
store *training.Store
|
||||
contentGenerator *training.ContentGenerator
|
||||
blockGenerator *training.BlockGenerator
|
||||
ttsClient *training.TTSClient
|
||||
}
|
||||
|
||||
// NewTrainingHandlers creates new training handlers
|
||||
func NewTrainingHandlers(store *training.Store, contentGenerator *training.ContentGenerator) *TrainingHandlers {
|
||||
func NewTrainingHandlers(store *training.Store, contentGenerator *training.ContentGenerator, blockGenerator *training.BlockGenerator, ttsClient *training.TTSClient) *TrainingHandlers {
|
||||
return &TrainingHandlers{
|
||||
store: store,
|
||||
contentGenerator: contentGenerator,
|
||||
blockGenerator: blockGenerator,
|
||||
ttsClient: ttsClient,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -212,6 +218,33 @@ func (h *TrainingHandlers) UpdateModule(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, module)
|
||||
}
|
||||
|
||||
// DeleteModule deletes a training module
|
||||
// DELETE /sdk/v1/training/modules/:id
|
||||
func (h *TrainingHandlers) DeleteModule(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid module ID"})
|
||||
return
|
||||
}
|
||||
|
||||
module, err := h.store.GetModule(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if module == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "module not found"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.store.DeleteModule(c.Request.Context(), id); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"status": "deleted"})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Matrix Endpoints
|
||||
// ============================================================================
|
||||
@@ -459,6 +492,48 @@ func (h *TrainingHandlers) UpdateAssignmentProgress(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"status": string(status), "progress": req.Progress})
|
||||
}
|
||||
|
||||
// UpdateAssignment updates assignment fields (e.g. deadline)
|
||||
// PUT /sdk/v1/training/assignments/:id
|
||||
func (h *TrainingHandlers) UpdateAssignment(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid assignment ID"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Deadline *string `json:"deadline"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
|
||||
return
|
||||
}
|
||||
|
||||
if req.Deadline != nil {
|
||||
deadline, err := time.Parse(time.RFC3339, *req.Deadline)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid deadline format (use RFC3339)"})
|
||||
return
|
||||
}
|
||||
if err := h.store.UpdateAssignmentDeadline(c.Request.Context(), id, deadline); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
assignment, err := h.store.GetAssignment(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if assignment == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "assignment not found"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, assignment)
|
||||
}
|
||||
|
||||
// CompleteAssignment marks an assignment as completed
|
||||
// POST /sdk/v1/training/assignments/:id/complete
|
||||
func (h *TrainingHandlers) CompleteAssignment(c *gin.Context) {
|
||||
@@ -1111,3 +1186,679 @@ func (h *TrainingHandlers) PreviewVideoScript(c *gin.Context) {
|
||||
|
||||
c.JSON(http.StatusOK, script)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Training Block Endpoints (Controls → Schulungsmodule)
|
||||
// ============================================================================
|
||||
|
||||
// ListBlockConfigs returns all block configs for the tenant
|
||||
// GET /sdk/v1/training/blocks
|
||||
func (h *TrainingHandlers) ListBlockConfigs(c *gin.Context) {
|
||||
tenantID := rbac.GetTenantID(c)
|
||||
|
||||
configs, err := h.store.ListBlockConfigs(c.Request.Context(), tenantID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"blocks": configs,
|
||||
"total": len(configs),
|
||||
})
|
||||
}
|
||||
|
||||
// CreateBlockConfig creates a new block configuration
|
||||
// POST /sdk/v1/training/blocks
|
||||
func (h *TrainingHandlers) CreateBlockConfig(c *gin.Context) {
|
||||
tenantID := rbac.GetTenantID(c)
|
||||
|
||||
var req training.CreateBlockConfigRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
config := &training.TrainingBlockConfig{
|
||||
TenantID: tenantID,
|
||||
Name: req.Name,
|
||||
Description: req.Description,
|
||||
DomainFilter: req.DomainFilter,
|
||||
CategoryFilter: req.CategoryFilter,
|
||||
SeverityFilter: req.SeverityFilter,
|
||||
TargetAudienceFilter: req.TargetAudienceFilter,
|
||||
RegulationArea: req.RegulationArea,
|
||||
ModuleCodePrefix: req.ModuleCodePrefix,
|
||||
FrequencyType: req.FrequencyType,
|
||||
DurationMinutes: req.DurationMinutes,
|
||||
PassThreshold: req.PassThreshold,
|
||||
MaxControlsPerModule: req.MaxControlsPerModule,
|
||||
}
|
||||
|
||||
if config.FrequencyType == "" {
|
||||
config.FrequencyType = training.FrequencyAnnual
|
||||
}
|
||||
if config.DurationMinutes == 0 {
|
||||
config.DurationMinutes = 45
|
||||
}
|
||||
if config.PassThreshold == 0 {
|
||||
config.PassThreshold = 70
|
||||
}
|
||||
if config.MaxControlsPerModule == 0 {
|
||||
config.MaxControlsPerModule = 20
|
||||
}
|
||||
|
||||
if err := h.store.CreateBlockConfig(c.Request.Context(), config); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, config)
|
||||
}
|
||||
|
||||
// GetBlockConfig returns a single block config
|
||||
// GET /sdk/v1/training/blocks/:id
|
||||
func (h *TrainingHandlers) GetBlockConfig(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid block config ID"})
|
||||
return
|
||||
}
|
||||
|
||||
config, err := h.store.GetBlockConfig(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if config == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "block config not found"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, config)
|
||||
}
|
||||
|
||||
// UpdateBlockConfig updates a block config
|
||||
// PUT /sdk/v1/training/blocks/:id
|
||||
func (h *TrainingHandlers) UpdateBlockConfig(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid block config ID"})
|
||||
return
|
||||
}
|
||||
|
||||
config, err := h.store.GetBlockConfig(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if config == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "block config not found"})
|
||||
return
|
||||
}
|
||||
|
||||
var req training.UpdateBlockConfigRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if req.Name != nil {
|
||||
config.Name = *req.Name
|
||||
}
|
||||
if req.Description != nil {
|
||||
config.Description = *req.Description
|
||||
}
|
||||
if req.DomainFilter != nil {
|
||||
config.DomainFilter = *req.DomainFilter
|
||||
}
|
||||
if req.CategoryFilter != nil {
|
||||
config.CategoryFilter = *req.CategoryFilter
|
||||
}
|
||||
if req.SeverityFilter != nil {
|
||||
config.SeverityFilter = *req.SeverityFilter
|
||||
}
|
||||
if req.TargetAudienceFilter != nil {
|
||||
config.TargetAudienceFilter = *req.TargetAudienceFilter
|
||||
}
|
||||
if req.MaxControlsPerModule != nil {
|
||||
config.MaxControlsPerModule = *req.MaxControlsPerModule
|
||||
}
|
||||
if req.DurationMinutes != nil {
|
||||
config.DurationMinutes = *req.DurationMinutes
|
||||
}
|
||||
if req.PassThreshold != nil {
|
||||
config.PassThreshold = *req.PassThreshold
|
||||
}
|
||||
if req.IsActive != nil {
|
||||
config.IsActive = *req.IsActive
|
||||
}
|
||||
|
||||
if err := h.store.UpdateBlockConfig(c.Request.Context(), config); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, config)
|
||||
}
|
||||
|
||||
// DeleteBlockConfig deletes a block config
|
||||
// DELETE /sdk/v1/training/blocks/:id
|
||||
func (h *TrainingHandlers) DeleteBlockConfig(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid block config ID"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.store.DeleteBlockConfig(c.Request.Context(), id); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"status": "deleted"})
|
||||
}
|
||||
|
||||
// PreviewBlock performs a dry run showing matching controls and proposed roles
|
||||
// POST /sdk/v1/training/blocks/:id/preview
|
||||
func (h *TrainingHandlers) PreviewBlock(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid block config ID"})
|
||||
return
|
||||
}
|
||||
|
||||
preview, err := h.blockGenerator.Preview(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, preview)
|
||||
}
|
||||
|
||||
// GenerateBlock runs the full generation pipeline
|
||||
// POST /sdk/v1/training/blocks/:id/generate
|
||||
func (h *TrainingHandlers) GenerateBlock(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid block config ID"})
|
||||
return
|
||||
}
|
||||
|
||||
var req training.GenerateBlockRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
// Defaults are fine
|
||||
req.Language = "de"
|
||||
req.AutoMatrix = true
|
||||
}
|
||||
|
||||
result, err := h.blockGenerator.Generate(c.Request.Context(), id, req)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
// GetBlockControls returns control links for a block config
|
||||
// GET /sdk/v1/training/blocks/:id/controls
|
||||
func (h *TrainingHandlers) GetBlockControls(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid block config ID"})
|
||||
return
|
||||
}
|
||||
|
||||
links, err := h.store.GetControlLinksForBlock(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"controls": links,
|
||||
"total": len(links),
|
||||
})
|
||||
}
|
||||
|
||||
// ListCanonicalControls returns filtered canonical controls for browsing
|
||||
// GET /sdk/v1/training/canonical/controls
|
||||
func (h *TrainingHandlers) ListCanonicalControls(c *gin.Context) {
|
||||
domain := c.Query("domain")
|
||||
category := c.Query("category")
|
||||
severity := c.Query("severity")
|
||||
targetAudience := c.Query("target_audience")
|
||||
|
||||
controls, err := h.store.QueryCanonicalControls(c.Request.Context(),
|
||||
domain, category, severity, targetAudience,
|
||||
)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"controls": controls,
|
||||
"total": len(controls),
|
||||
})
|
||||
}
|
||||
|
||||
// GetCanonicalMeta returns aggregated metadata about canonical controls
|
||||
// GET /sdk/v1/training/canonical/meta
|
||||
func (h *TrainingHandlers) GetCanonicalMeta(c *gin.Context) {
|
||||
meta, err := h.store.GetCanonicalControlMeta(c.Request.Context())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, meta)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Media Streaming Endpoint
|
||||
// ============================================================================
|
||||
|
||||
// StreamMedia returns a redirect to a presigned URL for a media file
|
||||
// GET /sdk/v1/training/media/:mediaId/stream
|
||||
func (h *TrainingHandlers) StreamMedia(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid media ID"})
|
||||
return
|
||||
}
|
||||
|
||||
media, err := h.store.GetMedia(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if media == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "media not found"})
|
||||
return
|
||||
}
|
||||
|
||||
if h.ttsClient == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "media streaming not available"})
|
||||
return
|
||||
}
|
||||
|
||||
url, err := h.ttsClient.GetPresignedURL(c.Request.Context(), media.Bucket, media.ObjectKey)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate streaming URL: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.Redirect(http.StatusTemporaryRedirect, url)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Certificate Endpoints
|
||||
// ============================================================================
|
||||
|
||||
// GenerateCertificate generates a certificate for a completed assignment
|
||||
// POST /sdk/v1/training/certificates/generate/:assignmentId
|
||||
func (h *TrainingHandlers) GenerateCertificate(c *gin.Context) {
|
||||
assignmentID, err := uuid.Parse(c.Param("assignmentId"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid assignment ID"})
|
||||
return
|
||||
}
|
||||
tenantID := rbac.GetTenantID(c)
|
||||
|
||||
assignment, err := h.store.GetAssignment(c.Request.Context(), assignmentID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if assignment == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "assignment not found"})
|
||||
return
|
||||
}
|
||||
|
||||
if assignment.Status != training.AssignmentStatusCompleted {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "assignment is not completed"})
|
||||
return
|
||||
}
|
||||
if assignment.QuizPassed == nil || !*assignment.QuizPassed {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "quiz has not been passed"})
|
||||
return
|
||||
}
|
||||
|
||||
// Generate certificate ID
|
||||
certID := uuid.New()
|
||||
if err := h.store.SetCertificateID(c.Request.Context(), assignmentID, certID); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Audit log
|
||||
userID := rbac.GetUserID(c)
|
||||
h.store.LogAction(c.Request.Context(), &training.AuditLogEntry{
|
||||
TenantID: tenantID,
|
||||
UserID: &userID,
|
||||
Action: training.AuditActionCertificateIssued,
|
||||
EntityType: training.AuditEntityCertificate,
|
||||
EntityID: &certID,
|
||||
Details: map[string]interface{}{
|
||||
"assignment_id": assignmentID.String(),
|
||||
"user_name": assignment.UserName,
|
||||
"module_title": assignment.ModuleTitle,
|
||||
},
|
||||
})
|
||||
|
||||
// Reload assignment with certificate_id
|
||||
assignment, _ = h.store.GetAssignment(c.Request.Context(), assignmentID)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"certificate_id": certID,
|
||||
"assignment": assignment,
|
||||
})
|
||||
}
|
||||
|
||||
// DownloadCertificatePDF generates and returns a PDF certificate
|
||||
// GET /sdk/v1/training/certificates/:id/pdf
|
||||
func (h *TrainingHandlers) DownloadCertificatePDF(c *gin.Context) {
|
||||
certID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid certificate ID"})
|
||||
return
|
||||
}
|
||||
|
||||
assignment, err := h.store.GetAssignmentByCertificateID(c.Request.Context(), certID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if assignment == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "certificate not found"})
|
||||
return
|
||||
}
|
||||
|
||||
// Get module for title
|
||||
module, _ := h.store.GetModule(c.Request.Context(), assignment.ModuleID)
|
||||
courseName := assignment.ModuleTitle
|
||||
if module != nil {
|
||||
courseName = module.Title
|
||||
}
|
||||
|
||||
score := 0
|
||||
if assignment.QuizScore != nil {
|
||||
score = int(*assignment.QuizScore)
|
||||
}
|
||||
|
||||
issuedAt := assignment.UpdatedAt
|
||||
if assignment.CompletedAt != nil {
|
||||
issuedAt = *assignment.CompletedAt
|
||||
}
|
||||
|
||||
// Use academy PDF generator
|
||||
pdfBytes, err := academy.GenerateCertificatePDF(academy.CertificateData{
|
||||
CertificateID: certID.String(),
|
||||
UserName: assignment.UserName,
|
||||
CourseName: courseName,
|
||||
Score: score,
|
||||
IssuedAt: issuedAt,
|
||||
ValidUntil: issuedAt.AddDate(1, 0, 0),
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "PDF generation failed: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.Header("Content-Disposition", "attachment; filename=zertifikat-"+certID.String()[:8]+".pdf")
|
||||
c.Data(http.StatusOK, "application/pdf", pdfBytes)
|
||||
}
|
||||
|
||||
// ListCertificates returns all certificates for a tenant
|
||||
// GET /sdk/v1/training/certificates
|
||||
func (h *TrainingHandlers) ListCertificates(c *gin.Context) {
|
||||
tenantID := rbac.GetTenantID(c)
|
||||
|
||||
certificates, err := h.store.ListCertificates(c.Request.Context(), tenantID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"certificates": certificates,
|
||||
"total": len(certificates),
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Interactive Video Endpoints
|
||||
// ============================================================================
|
||||
|
||||
// GenerateInteractiveVideo triggers the full interactive video pipeline
|
||||
// POST /sdk/v1/training/content/:moduleId/generate-interactive
|
||||
func (h *TrainingHandlers) GenerateInteractiveVideo(c *gin.Context) {
|
||||
moduleID, err := uuid.Parse(c.Param("moduleId"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid module ID"})
|
||||
return
|
||||
}
|
||||
|
||||
module, err := h.store.GetModule(c.Request.Context(), moduleID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if module == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "module not found"})
|
||||
return
|
||||
}
|
||||
|
||||
media, err := h.contentGenerator.GenerateInteractiveVideo(c.Request.Context(), *module)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, media)
|
||||
}
|
||||
|
||||
// GetInteractiveManifest returns the interactive video manifest with checkpoints and progress
|
||||
// GET /sdk/v1/training/content/:moduleId/interactive-manifest
|
||||
func (h *TrainingHandlers) GetInteractiveManifest(c *gin.Context) {
|
||||
moduleID, err := uuid.Parse(c.Param("moduleId"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid module ID"})
|
||||
return
|
||||
}
|
||||
|
||||
// Get interactive video media
|
||||
mediaList, err := h.store.GetMediaForModule(c.Request.Context(), moduleID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Find interactive video
|
||||
var interactiveMedia *training.TrainingMedia
|
||||
for i := range mediaList {
|
||||
if mediaList[i].MediaType == training.MediaTypeInteractiveVideo && mediaList[i].Status == training.MediaStatusCompleted {
|
||||
interactiveMedia = &mediaList[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if interactiveMedia == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "no interactive video found for this module"})
|
||||
return
|
||||
}
|
||||
|
||||
// Get checkpoints
|
||||
checkpoints, err := h.store.ListCheckpoints(c.Request.Context(), moduleID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Optional: get assignment ID for progress
|
||||
assignmentIDStr := c.Query("assignment_id")
|
||||
|
||||
// Build manifest entries
|
||||
entries := make([]training.CheckpointManifestEntry, len(checkpoints))
|
||||
for i, cp := range checkpoints {
|
||||
// Get questions for this checkpoint
|
||||
questions, _ := h.store.GetCheckpointQuestions(c.Request.Context(), cp.ID)
|
||||
|
||||
cpQuestions := make([]training.CheckpointQuestion, len(questions))
|
||||
for j, q := range questions {
|
||||
cpQuestions[j] = training.CheckpointQuestion{
|
||||
Question: q.Question,
|
||||
Options: q.Options,
|
||||
CorrectIndex: q.CorrectIndex,
|
||||
Explanation: q.Explanation,
|
||||
}
|
||||
}
|
||||
|
||||
entry := training.CheckpointManifestEntry{
|
||||
CheckpointID: cp.ID,
|
||||
Index: cp.CheckpointIndex,
|
||||
Title: cp.Title,
|
||||
TimestampSeconds: cp.TimestampSeconds,
|
||||
Questions: cpQuestions,
|
||||
}
|
||||
|
||||
// Get progress if assignment_id provided
|
||||
if assignmentIDStr != "" {
|
||||
if assignmentID, err := uuid.Parse(assignmentIDStr); err == nil {
|
||||
progress, _ := h.store.GetCheckpointProgress(c.Request.Context(), assignmentID, cp.ID)
|
||||
entry.Progress = progress
|
||||
}
|
||||
}
|
||||
|
||||
entries[i] = entry
|
||||
}
|
||||
|
||||
// Get stream URL
|
||||
streamURL := ""
|
||||
if h.ttsClient != nil {
|
||||
url, err := h.ttsClient.GetPresignedURL(c.Request.Context(), interactiveMedia.Bucket, interactiveMedia.ObjectKey)
|
||||
if err == nil {
|
||||
streamURL = url
|
||||
}
|
||||
}
|
||||
|
||||
manifest := training.InteractiveVideoManifest{
|
||||
MediaID: interactiveMedia.ID,
|
||||
StreamURL: streamURL,
|
||||
Checkpoints: entries,
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, manifest)
|
||||
}
|
||||
|
||||
// SubmitCheckpointQuiz handles checkpoint quiz submission
|
||||
// POST /sdk/v1/training/checkpoints/:checkpointId/submit
|
||||
func (h *TrainingHandlers) SubmitCheckpointQuiz(c *gin.Context) {
|
||||
checkpointID, err := uuid.Parse(c.Param("checkpointId"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid checkpoint ID"})
|
||||
return
|
||||
}
|
||||
|
||||
var req training.SubmitCheckpointQuizRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
assignmentID, err := uuid.Parse(req.AssignmentID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid assignment ID"})
|
||||
return
|
||||
}
|
||||
|
||||
// Get checkpoint questions
|
||||
questions, err := h.store.GetCheckpointQuestions(c.Request.Context(), checkpointID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if len(questions) == 0 {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "no questions found for this checkpoint"})
|
||||
return
|
||||
}
|
||||
|
||||
// Grade answers
|
||||
correctCount := 0
|
||||
feedback := make([]training.CheckpointQuizFeedback, len(questions))
|
||||
for i, q := range questions {
|
||||
isCorrect := false
|
||||
if i < len(req.Answers) && req.Answers[i] == q.CorrectIndex {
|
||||
isCorrect = true
|
||||
correctCount++
|
||||
}
|
||||
feedback[i] = training.CheckpointQuizFeedback{
|
||||
Question: q.Question,
|
||||
Correct: isCorrect,
|
||||
Explanation: q.Explanation,
|
||||
}
|
||||
}
|
||||
|
||||
score := float64(correctCount) / float64(len(questions)) * 100
|
||||
passed := score >= 70 // 70% threshold for checkpoint
|
||||
|
||||
// Update progress
|
||||
progress := &training.CheckpointProgress{
|
||||
AssignmentID: assignmentID,
|
||||
CheckpointID: checkpointID,
|
||||
Passed: passed,
|
||||
Attempts: 1,
|
||||
}
|
||||
if err := h.store.UpsertCheckpointProgress(c.Request.Context(), progress); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Audit log
|
||||
userID := rbac.GetUserID(c)
|
||||
h.store.LogAction(c.Request.Context(), &training.AuditLogEntry{
|
||||
TenantID: rbac.GetTenantID(c),
|
||||
UserID: &userID,
|
||||
Action: training.AuditAction("checkpoint_submitted"),
|
||||
EntityType: training.AuditEntityType("checkpoint"),
|
||||
EntityID: &checkpointID,
|
||||
Details: map[string]interface{}{
|
||||
"assignment_id": assignmentID.String(),
|
||||
"score": score,
|
||||
"passed": passed,
|
||||
"correct": correctCount,
|
||||
"total": len(questions),
|
||||
},
|
||||
})
|
||||
|
||||
c.JSON(http.StatusOK, training.SubmitCheckpointQuizResponse{
|
||||
Passed: passed,
|
||||
Score: score,
|
||||
Feedback: feedback,
|
||||
})
|
||||
}
|
||||
|
||||
// GetCheckpointProgress returns all checkpoint progress for an assignment
|
||||
// GET /sdk/v1/training/checkpoints/progress/:assignmentId
|
||||
func (h *TrainingHandlers) GetCheckpointProgress(c *gin.Context) {
|
||||
assignmentID, err := uuid.Parse(c.Param("assignmentId"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid assignment ID"})
|
||||
return
|
||||
}
|
||||
|
||||
progress, err := h.store.ListCheckpointProgress(c.Request.Context(), assignmentID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"progress": progress,
|
||||
"total": len(progress),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,691 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// newTestContext, parseResponse, and gin.SetMode are defined in iace_handler_test.go
|
||||
|
||||
// ============================================================================
|
||||
// Module Endpoint Tests
|
||||
// ============================================================================
|
||||
|
||||
func TestGetModule_InvalidID_Returns400(t *testing.T) {
|
||||
h := &TrainingHandlers{}
|
||||
w, c := newTestContext("GET", "/modules/not-a-uuid", nil, nil, gin.Params{{Key: "id", Value: "not-a-uuid"}})
|
||||
h.GetModule(c)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetModule_EmptyID_Returns400(t *testing.T) {
|
||||
h := &TrainingHandlers{}
|
||||
w, c := newTestContext("GET", "/modules/", nil, nil, gin.Params{{Key: "id", Value: ""}})
|
||||
h.GetModule(c)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateModule_EmptyBody_Returns400(t *testing.T) {
|
||||
h := &TrainingHandlers{}
|
||||
w, c := newTestContext("POST", "/modules", nil, nil, nil)
|
||||
h.CreateModule(c)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateModule_MissingTitle_Returns400(t *testing.T) {
|
||||
h := &TrainingHandlers{}
|
||||
body := map[string]interface{}{"module_code": "T01", "regulation_area": "dsgvo", "frequency_type": "annual"}
|
||||
w, c := newTestContext("POST", "/modules", body, nil, nil)
|
||||
h.CreateModule(c)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateModule_MissingModuleCode_Returns400(t *testing.T) {
|
||||
h := &TrainingHandlers{}
|
||||
body := map[string]interface{}{"title": "Test", "regulation_area": "dsgvo", "frequency_type": "annual"}
|
||||
w, c := newTestContext("POST", "/modules", body, nil, nil)
|
||||
h.CreateModule(c)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateModule_MissingRegulationArea_Returns400(t *testing.T) {
|
||||
h := &TrainingHandlers{}
|
||||
body := map[string]interface{}{"module_code": "T01", "title": "Test", "frequency_type": "annual"}
|
||||
w, c := newTestContext("POST", "/modules", body, nil, nil)
|
||||
h.CreateModule(c)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateModule_InvalidID_Returns400(t *testing.T) {
|
||||
h := &TrainingHandlers{}
|
||||
w, c := newTestContext("PUT", "/modules/bad", map[string]interface{}{"title": "x"}, nil, gin.Params{{Key: "id", Value: "bad"}})
|
||||
h.UpdateModule(c)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteModule_InvalidID_Returns400(t *testing.T) {
|
||||
h := &TrainingHandlers{}
|
||||
w, c := newTestContext("DELETE", "/modules/bad", nil, nil, gin.Params{{Key: "id", Value: "bad"}})
|
||||
h.DeleteModule(c)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteModule_EmptyID_Returns400(t *testing.T) {
|
||||
h := &TrainingHandlers{}
|
||||
w, c := newTestContext("DELETE", "/modules/", nil, nil, gin.Params{{Key: "id", Value: ""}})
|
||||
h.DeleteModule(c)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Matrix Endpoint Tests
|
||||
// ============================================================================
|
||||
|
||||
func TestSetMatrixEntry_EmptyBody_Returns400(t *testing.T) {
|
||||
h := &TrainingHandlers{}
|
||||
w, c := newTestContext("POST", "/matrix", nil, nil, nil)
|
||||
h.SetMatrixEntry(c)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetMatrixEntry_MissingRoleCode_Returns400(t *testing.T) {
|
||||
h := &TrainingHandlers{}
|
||||
body := map[string]interface{}{"module_id": "00000000-0000-0000-0000-000000000001"}
|
||||
w, c := newTestContext("POST", "/matrix", body, nil, nil)
|
||||
h.SetMatrixEntry(c)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteMatrixEntry_InvalidModuleID_Returns400(t *testing.T) {
|
||||
h := &TrainingHandlers{}
|
||||
w, c := newTestContext("DELETE", "/matrix/R1/bad", nil, nil, gin.Params{
|
||||
{Key: "role", Value: "R1"},
|
||||
{Key: "moduleId", Value: "bad"},
|
||||
})
|
||||
h.DeleteMatrixEntry(c)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Assignment Endpoint Tests
|
||||
// ============================================================================
|
||||
|
||||
func TestGetAssignment_InvalidID_Returns400(t *testing.T) {
|
||||
h := &TrainingHandlers{}
|
||||
w, c := newTestContext("GET", "/assignments/bad", nil, nil, gin.Params{{Key: "id", Value: "bad"}})
|
||||
h.GetAssignment(c)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartAssignment_InvalidID_Returns400(t *testing.T) {
|
||||
h := &TrainingHandlers{}
|
||||
w, c := newTestContext("POST", "/assignments/bad/start", nil, nil, gin.Params{{Key: "id", Value: "bad"}})
|
||||
h.StartAssignment(c)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateAssignmentProgress_InvalidID_Returns400(t *testing.T) {
|
||||
h := &TrainingHandlers{}
|
||||
w, c := newTestContext("POST", "/assignments/bad/progress", map[string]interface{}{"progress": 50}, nil, gin.Params{{Key: "id", Value: "bad"}})
|
||||
h.UpdateAssignmentProgress(c)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateAssignmentProgress_EmptyBody_Returns400(t *testing.T) {
|
||||
h := &TrainingHandlers{}
|
||||
w, c := newTestContext("POST", "/assignments/00000000-0000-0000-0000-000000000001/progress", nil, nil,
|
||||
gin.Params{{Key: "id", Value: "00000000-0000-0000-0000-000000000001"}})
|
||||
h.UpdateAssignmentProgress(c)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteAssignment_InvalidID_Returns400(t *testing.T) {
|
||||
h := &TrainingHandlers{}
|
||||
w, c := newTestContext("POST", "/assignments/bad/complete", nil, nil, gin.Params{{Key: "id", Value: "bad"}})
|
||||
h.CompleteAssignment(c)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeAssignments_EmptyBody_Returns400(t *testing.T) {
|
||||
h := &TrainingHandlers{}
|
||||
w, c := newTestContext("POST", "/assignments/compute", nil, nil, nil)
|
||||
h.ComputeAssignments(c)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeAssignments_MissingUserID_Returns400(t *testing.T) {
|
||||
h := &TrainingHandlers{}
|
||||
body := map[string]interface{}{"user_name": "Test", "user_email": "test@test.de", "roles": []string{"R1"}}
|
||||
w, c := newTestContext("POST", "/assignments/compute", body, nil, nil)
|
||||
h.ComputeAssignments(c)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Quiz Endpoint Tests
|
||||
// ============================================================================
|
||||
|
||||
func TestGetQuiz_InvalidModuleID_Returns400(t *testing.T) {
|
||||
h := &TrainingHandlers{}
|
||||
w, c := newTestContext("GET", "/quiz/bad", nil, nil, gin.Params{{Key: "moduleId", Value: "bad"}})
|
||||
h.GetQuiz(c)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubmitQuiz_InvalidModuleID_Returns400(t *testing.T) {
|
||||
h := &TrainingHandlers{}
|
||||
w, c := newTestContext("POST", "/quiz/bad/submit", map[string]interface{}{}, nil, gin.Params{{Key: "moduleId", Value: "bad"}})
|
||||
h.SubmitQuiz(c)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubmitQuiz_EmptyBody_Returns400(t *testing.T) {
|
||||
h := &TrainingHandlers{}
|
||||
w, c := newTestContext("POST", "/quiz/00000000-0000-0000-0000-000000000001/submit", nil, nil,
|
||||
gin.Params{{Key: "moduleId", Value: "00000000-0000-0000-0000-000000000001"}})
|
||||
h.SubmitQuiz(c)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetQuizAttempts_InvalidAssignmentID_Returns400(t *testing.T) {
|
||||
h := &TrainingHandlers{}
|
||||
w, c := newTestContext("GET", "/quiz/attempts/bad", nil, nil, gin.Params{{Key: "assignmentId", Value: "bad"}})
|
||||
h.GetQuizAttempts(c)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Content Endpoint Tests
|
||||
// ============================================================================
|
||||
|
||||
func TestGetContent_InvalidModuleID_Returns400(t *testing.T) {
|
||||
h := &TrainingHandlers{}
|
||||
w, c := newTestContext("GET", "/content/bad", nil, nil, gin.Params{{Key: "moduleId", Value: "bad"}})
|
||||
h.GetContent(c)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublishContent_InvalidID_Returns400(t *testing.T) {
|
||||
h := &TrainingHandlers{}
|
||||
w, c := newTestContext("POST", "/content/bad/publish", nil, nil, gin.Params{{Key: "id", Value: "bad"}})
|
||||
h.PublishContent(c)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateContent_EmptyBody_Returns400(t *testing.T) {
|
||||
h := &TrainingHandlers{}
|
||||
w, c := newTestContext("POST", "/content/generate", nil, nil, nil)
|
||||
h.GenerateContent(c)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateQuiz_EmptyBody_Returns400(t *testing.T) {
|
||||
h := &TrainingHandlers{}
|
||||
w, c := newTestContext("POST", "/content/generate-quiz", nil, nil, nil)
|
||||
h.GenerateQuiz(c)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Media Endpoint Tests
|
||||
// ============================================================================
|
||||
|
||||
func TestGetModuleMedia_InvalidModuleID_Returns400(t *testing.T) {
|
||||
h := &TrainingHandlers{}
|
||||
w, c := newTestContext("GET", "/media/module/bad", nil, nil, gin.Params{{Key: "moduleId", Value: "bad"}})
|
||||
h.GetModuleMedia(c)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetMediaURL_InvalidID_Returns400(t *testing.T) {
|
||||
h := &TrainingHandlers{}
|
||||
w, c := newTestContext("GET", "/media/bad/url", nil, nil, gin.Params{{Key: "id", Value: "bad"}})
|
||||
h.GetMediaURL(c)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublishMedia_InvalidID_Returns400(t *testing.T) {
|
||||
h := &TrainingHandlers{}
|
||||
w, c := newTestContext("POST", "/media/bad/publish", nil, nil, gin.Params{{Key: "id", Value: "bad"}})
|
||||
h.PublishMedia(c)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamMedia_InvalidID_Returns400(t *testing.T) {
|
||||
h := &TrainingHandlers{}
|
||||
w, c := newTestContext("GET", "/media/bad/stream", nil, nil, gin.Params{{Key: "id", Value: "bad"}})
|
||||
h.StreamMedia(c)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateAudio_InvalidModuleID_Returns400(t *testing.T) {
|
||||
h := &TrainingHandlers{}
|
||||
w, c := newTestContext("POST", "/content/bad/generate-audio", nil, nil, gin.Params{{Key: "moduleId", Value: "bad"}})
|
||||
h.GenerateAudio(c)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateVideo_InvalidModuleID_Returns400(t *testing.T) {
|
||||
h := &TrainingHandlers{}
|
||||
w, c := newTestContext("POST", "/content/bad/generate-video", nil, nil, gin.Params{{Key: "moduleId", Value: "bad"}})
|
||||
h.GenerateVideo(c)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreviewVideoScript_InvalidModuleID_Returns400(t *testing.T) {
|
||||
h := &TrainingHandlers{}
|
||||
w, c := newTestContext("POST", "/content/bad/preview-script", nil, nil, gin.Params{{Key: "moduleId", Value: "bad"}})
|
||||
h.PreviewVideoScript(c)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Certificate Endpoint Tests
|
||||
// ============================================================================
|
||||
|
||||
func TestGenerateCertificate_InvalidAssignmentID_Returns400(t *testing.T) {
|
||||
h := &TrainingHandlers{}
|
||||
w, c := newTestContext("POST", "/certificates/generate/bad", nil, nil, gin.Params{{Key: "assignmentId", Value: "bad"}})
|
||||
h.GenerateCertificate(c)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadCertificatePDF_InvalidID_Returns400(t *testing.T) {
|
||||
h := &TrainingHandlers{}
|
||||
w, c := newTestContext("GET", "/certificates/bad/pdf", nil, nil, gin.Params{{Key: "id", Value: "bad"}})
|
||||
h.DownloadCertificatePDF(c)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyCertificate_InvalidID_Returns400(t *testing.T) {
|
||||
h := &TrainingHandlers{}
|
||||
w, c := newTestContext("GET", "/certificates/bad/verify", nil, nil, gin.Params{{Key: "id", Value: "bad"}})
|
||||
h.VerifyCertificate(c)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListCertificates_NilStore_Panics(t *testing.T) {
|
||||
// This tests that a nil store doesn't silently succeed
|
||||
defer func() {
|
||||
if r := recover(); r == nil {
|
||||
t.Error("Expected panic with nil store")
|
||||
}
|
||||
}()
|
||||
h := &TrainingHandlers{}
|
||||
_, c := newTestContext("GET", "/certificates", nil, nil, nil)
|
||||
h.ListCertificates(c)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Interactive Video Endpoint Tests (User Journey: Admin generates video)
|
||||
// ============================================================================
|
||||
|
||||
func TestGenerateInteractiveVideo_InvalidModuleID_Returns400(t *testing.T) {
|
||||
h := &TrainingHandlers{}
|
||||
w, c := newTestContext("POST", "/content/bad/generate-interactive", nil, nil, gin.Params{{Key: "moduleId", Value: "bad"}})
|
||||
h.GenerateInteractiveVideo(c)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected 400, got %d", w.Code)
|
||||
}
|
||||
resp := parseResponse(w)
|
||||
if resp["error"] == nil {
|
||||
t.Error("Response should contain 'error' key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateInteractiveVideo_EmptyModuleID_Returns400(t *testing.T) {
|
||||
h := &TrainingHandlers{}
|
||||
w, c := newTestContext("POST", "/content//generate-interactive", nil, nil, gin.Params{{Key: "moduleId", Value: ""}})
|
||||
h.GenerateInteractiveVideo(c)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetInteractiveManifest_InvalidModuleID_Returns400(t *testing.T) {
|
||||
h := &TrainingHandlers{}
|
||||
w, c := newTestContext("GET", "/content/bad/interactive-manifest", nil, nil, gin.Params{{Key: "moduleId", Value: "bad"}})
|
||||
h.GetInteractiveManifest(c)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected 400, got %d", w.Code)
|
||||
}
|
||||
resp := parseResponse(w)
|
||||
if resp["error"] == nil {
|
||||
t.Error("Response should contain 'error' key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetInteractiveManifest_EmptyModuleID_Returns400(t *testing.T) {
|
||||
h := &TrainingHandlers{}
|
||||
w, c := newTestContext("GET", "/content//interactive-manifest", nil, nil, gin.Params{{Key: "moduleId", Value: ""}})
|
||||
h.GetInteractiveManifest(c)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Checkpoint Quiz Endpoint Tests (User Journey: Learner takes quiz)
|
||||
// ============================================================================
|
||||
|
||||
func TestSubmitCheckpointQuiz_InvalidCheckpointID_Returns400(t *testing.T) {
|
||||
h := &TrainingHandlers{}
|
||||
body := map[string]interface{}{
|
||||
"assignment_id": "00000000-0000-0000-0000-000000000001",
|
||||
"answers": []int{0, 1, 2},
|
||||
}
|
||||
w, c := newTestContext("POST", "/checkpoints/bad/submit", body, nil, gin.Params{{Key: "checkpointId", Value: "bad"}})
|
||||
h.SubmitCheckpointQuiz(c)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected 400, got %d", w.Code)
|
||||
}
|
||||
resp := parseResponse(w)
|
||||
if resp["error"] == nil {
|
||||
t.Error("Response should contain 'error' key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubmitCheckpointQuiz_EmptyCheckpointID_Returns400(t *testing.T) {
|
||||
h := &TrainingHandlers{}
|
||||
body := map[string]interface{}{
|
||||
"assignment_id": "00000000-0000-0000-0000-000000000001",
|
||||
"answers": []int{0},
|
||||
}
|
||||
w, c := newTestContext("POST", "/checkpoints//submit", body, nil, gin.Params{{Key: "checkpointId", Value: ""}})
|
||||
h.SubmitCheckpointQuiz(c)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubmitCheckpointQuiz_EmptyBody_Returns400(t *testing.T) {
|
||||
h := &TrainingHandlers{}
|
||||
w, c := newTestContext("POST", "/checkpoints/00000000-0000-0000-0000-000000000001/submit", nil, nil,
|
||||
gin.Params{{Key: "checkpointId", Value: "00000000-0000-0000-0000-000000000001"}})
|
||||
h.SubmitCheckpointQuiz(c)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubmitCheckpointQuiz_InvalidAssignmentID_Returns400(t *testing.T) {
|
||||
h := &TrainingHandlers{}
|
||||
body := map[string]interface{}{
|
||||
"assignment_id": "not-a-uuid",
|
||||
"answers": []int{0},
|
||||
}
|
||||
w, c := newTestContext("POST", "/checkpoints/00000000-0000-0000-0000-000000000001/submit", body, nil,
|
||||
gin.Params{{Key: "checkpointId", Value: "00000000-0000-0000-0000-000000000001"}})
|
||||
h.SubmitCheckpointQuiz(c)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubmitCheckpointQuiz_ValidIDs_NilStore_Panics(t *testing.T) {
|
||||
// When both IDs are valid, handler reaches store → panic with nil store
|
||||
defer func() {
|
||||
if r := recover(); r == nil {
|
||||
t.Error("Expected panic with nil store")
|
||||
}
|
||||
}()
|
||||
h := &TrainingHandlers{}
|
||||
body := map[string]interface{}{
|
||||
"assignment_id": "00000000-0000-0000-0000-000000000001",
|
||||
"answers": []int{0},
|
||||
}
|
||||
_, c := newTestContext("POST", "/checkpoints/00000000-0000-0000-0000-000000000001/submit", body, nil,
|
||||
gin.Params{{Key: "checkpointId", Value: "00000000-0000-0000-0000-000000000001"}})
|
||||
h.SubmitCheckpointQuiz(c)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Checkpoint Progress Endpoint Tests (User Journey: Learner views progress)
|
||||
// ============================================================================
|
||||
|
||||
func TestGetCheckpointProgress_InvalidAssignmentID_Returns400(t *testing.T) {
|
||||
h := &TrainingHandlers{}
|
||||
w, c := newTestContext("GET", "/checkpoints/progress/bad", nil, nil, gin.Params{{Key: "assignmentId", Value: "bad"}})
|
||||
h.GetCheckpointProgress(c)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected 400, got %d", w.Code)
|
||||
}
|
||||
resp := parseResponse(w)
|
||||
if resp["error"] == nil {
|
||||
t.Error("Response should contain 'error' key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetCheckpointProgress_EmptyAssignmentID_Returns400(t *testing.T) {
|
||||
h := &TrainingHandlers{}
|
||||
w, c := newTestContext("GET", "/checkpoints/progress/", nil, nil, gin.Params{{Key: "assignmentId", Value: ""}})
|
||||
h.GetCheckpointProgress(c)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Interactive Video Error Format Tests (table-driven)
|
||||
// ============================================================================
|
||||
|
||||
func TestInteractiveEndpoints_InvalidID_ResponseContainsErrorKey(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
method string
|
||||
handler func(h *TrainingHandlers, c *gin.Context)
|
||||
params gin.Params
|
||||
}{
|
||||
{"GenerateInteractiveVideo", "POST",
|
||||
func(h *TrainingHandlers, c *gin.Context) { h.GenerateInteractiveVideo(c) },
|
||||
gin.Params{{Key: "moduleId", Value: "x"}}},
|
||||
{"GetInteractiveManifest", "GET",
|
||||
func(h *TrainingHandlers, c *gin.Context) { h.GetInteractiveManifest(c) },
|
||||
gin.Params{{Key: "moduleId", Value: "x"}}},
|
||||
{"SubmitCheckpointQuiz", "POST",
|
||||
func(h *TrainingHandlers, c *gin.Context) { h.SubmitCheckpointQuiz(c) },
|
||||
gin.Params{{Key: "checkpointId", Value: "x"}}},
|
||||
{"GetCheckpointProgress", "GET",
|
||||
func(h *TrainingHandlers, c *gin.Context) { h.GetCheckpointProgress(c) },
|
||||
gin.Params{{Key: "assignmentId", Value: "x"}}},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
h := &TrainingHandlers{}
|
||||
w, c := newTestContext(tt.method, "/test", nil, nil, tt.params)
|
||||
tt.handler(h, c)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("%s: Expected 400, got %d", tt.name, w.Code)
|
||||
}
|
||||
resp := parseResponse(w)
|
||||
if resp["error"] == nil {
|
||||
t.Errorf("%s: response should contain 'error' key", tt.name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Block Endpoint Tests
|
||||
// ============================================================================
|
||||
|
||||
func TestGetBlockConfig_InvalidID_Returns400(t *testing.T) {
|
||||
h := &TrainingHandlers{}
|
||||
w, c := newTestContext("GET", "/blocks/bad", nil, nil, gin.Params{{Key: "id", Value: "bad"}})
|
||||
h.GetBlockConfig(c)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateBlockConfig_EmptyBody_Returns400(t *testing.T) {
|
||||
h := &TrainingHandlers{}
|
||||
w, c := newTestContext("POST", "/blocks", nil, nil, nil)
|
||||
h.CreateBlockConfig(c)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateBlockConfig_MissingName_Returns400(t *testing.T) {
|
||||
h := &TrainingHandlers{}
|
||||
body := map[string]interface{}{"regulation_area": "dsgvo", "module_code_prefix": "BLK"}
|
||||
w, c := newTestContext("POST", "/blocks", body, nil, nil)
|
||||
h.CreateBlockConfig(c)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateBlockConfig_InvalidID_Returns400(t *testing.T) {
|
||||
h := &TrainingHandlers{}
|
||||
w, c := newTestContext("PUT", "/blocks/bad", map[string]interface{}{"name": "x"}, nil, gin.Params{{Key: "id", Value: "bad"}})
|
||||
h.UpdateBlockConfig(c)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteBlockConfig_InvalidID_Returns400(t *testing.T) {
|
||||
h := &TrainingHandlers{}
|
||||
w, c := newTestContext("DELETE", "/blocks/bad", nil, nil, gin.Params{{Key: "id", Value: "bad"}})
|
||||
h.DeleteBlockConfig(c)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreviewBlock_InvalidID_Returns400(t *testing.T) {
|
||||
h := &TrainingHandlers{}
|
||||
w, c := newTestContext("POST", "/blocks/bad/preview", nil, nil, gin.Params{{Key: "id", Value: "bad"}})
|
||||
h.PreviewBlock(c)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateBlock_InvalidID_Returns400(t *testing.T) {
|
||||
h := &TrainingHandlers{}
|
||||
w, c := newTestContext("POST", "/blocks/bad/generate", nil, nil, gin.Params{{Key: "id", Value: "bad"}})
|
||||
h.GenerateBlock(c)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetBlockControls_InvalidID_Returns400(t *testing.T) {
|
||||
h := &TrainingHandlers{}
|
||||
w, c := newTestContext("GET", "/blocks/bad/controls", nil, nil, gin.Params{{Key: "id", Value: "bad"}})
|
||||
h.GetBlockControls(c)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Response Error Format Tests
|
||||
// ============================================================================
|
||||
|
||||
func TestInvalidID_ResponseContainsErrorKey(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
method string
|
||||
handler func(h *TrainingHandlers, c *gin.Context)
|
||||
params gin.Params
|
||||
}{
|
||||
{"GetModule", "GET", func(h *TrainingHandlers, c *gin.Context) { h.GetModule(c) }, gin.Params{{Key: "id", Value: "x"}}},
|
||||
{"DeleteModule", "DELETE", func(h *TrainingHandlers, c *gin.Context) { h.DeleteModule(c) }, gin.Params{{Key: "id", Value: "x"}}},
|
||||
{"StreamMedia", "GET", func(h *TrainingHandlers, c *gin.Context) { h.StreamMedia(c) }, gin.Params{{Key: "id", Value: "x"}}},
|
||||
{"GenerateCertificate", "POST", func(h *TrainingHandlers, c *gin.Context) { h.GenerateCertificate(c) }, gin.Params{{Key: "assignmentId", Value: "x"}}},
|
||||
{"DownloadCertificatePDF", "GET", func(h *TrainingHandlers, c *gin.Context) { h.DownloadCertificatePDF(c) }, gin.Params{{Key: "id", Value: "x"}}},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
h := &TrainingHandlers{}
|
||||
w, c := newTestContext(tt.method, "/test", nil, nil, tt.params)
|
||||
tt.handler(h, c)
|
||||
resp := parseResponse(w)
|
||||
if resp["error"] == nil {
|
||||
t.Errorf("%s: response should contain 'error' key", tt.name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user