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>
553 lines
15 KiB
Go
553 lines
15 KiB
Go
package training
|
|
|
|
import (
|
|
"testing"
|
|
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
// =============================================================================
|
|
// buildContentPrompt Tests
|
|
// =============================================================================
|
|
|
|
func TestBuildContentPrompt_ContainsModuleCode(t *testing.T) {
|
|
module := TrainingModule{
|
|
ModuleCode: "CP-TRAIN-001",
|
|
Title: "DSGVO Grundlagen",
|
|
Description: "Basis-Schulung",
|
|
RegulationArea: RegulationDSGVO,
|
|
DurationMinutes: 30,
|
|
}
|
|
|
|
prompt := buildContentPrompt(module, "de")
|
|
|
|
if !containsSubstring(prompt, "CP-TRAIN-001") {
|
|
t.Error("Prompt should contain module code")
|
|
}
|
|
}
|
|
|
|
func TestBuildContentPrompt_ContainsTitle(t *testing.T) {
|
|
module := TrainingModule{
|
|
ModuleCode: "CP-001",
|
|
Title: "DSGVO Grundlagen",
|
|
RegulationArea: RegulationDSGVO,
|
|
DurationMinutes: 30,
|
|
}
|
|
|
|
prompt := buildContentPrompt(module, "de")
|
|
|
|
if !containsSubstring(prompt, "DSGVO Grundlagen") {
|
|
t.Error("Prompt should contain module title")
|
|
}
|
|
}
|
|
|
|
func TestBuildContentPrompt_ContainsRegulationLabel(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
area RegulationArea
|
|
expected string
|
|
}{
|
|
{"DSGVO", RegulationDSGVO, "Datenschutz-Grundverordnung"},
|
|
{"NIS2", RegulationNIS2, "NIS-2-Richtlinie"},
|
|
{"ISO27001", RegulationISO27001, "ISO 27001"},
|
|
{"AIAct", RegulationAIAct, "AI Act"},
|
|
{"GeschGehG", RegulationGeschGehG, "Geschaeftsgeheimnisgesetz"},
|
|
{"HinSchG", RegulationHinSchG, "Hinweisgeberschutzgesetz"},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
module := TrainingModule{
|
|
ModuleCode: "CP-001",
|
|
Title: "Test Module",
|
|
RegulationArea: tt.area,
|
|
DurationMinutes: 30,
|
|
}
|
|
|
|
prompt := buildContentPrompt(module, "de")
|
|
|
|
if !containsSubstring(prompt, tt.expected) {
|
|
t.Errorf("Prompt should contain regulation label '%s' for area '%s'", tt.expected, tt.area)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestBuildContentPrompt_ContainsDuration(t *testing.T) {
|
|
module := TrainingModule{
|
|
ModuleCode: "CP-001",
|
|
Title: "Test",
|
|
RegulationArea: RegulationDSGVO,
|
|
DurationMinutes: 45,
|
|
}
|
|
|
|
prompt := buildContentPrompt(module, "de")
|
|
|
|
if !containsSubstring(prompt, "45 Minuten") {
|
|
t.Error("Prompt should contain duration in minutes")
|
|
}
|
|
}
|
|
|
|
func TestBuildContentPrompt_UnknownRegulationArea(t *testing.T) {
|
|
module := TrainingModule{
|
|
ModuleCode: "CP-001",
|
|
Title: "Test",
|
|
RegulationArea: RegulationArea("custom_regulation"),
|
|
DurationMinutes: 30,
|
|
}
|
|
|
|
prompt := buildContentPrompt(module, "de")
|
|
|
|
if !containsSubstring(prompt, "custom_regulation") {
|
|
t.Error("Unknown regulation area should fall back to raw string")
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// buildQuizPrompt Tests
|
|
// =============================================================================
|
|
|
|
func TestBuildQuizPrompt_ContainsQuestionCount(t *testing.T) {
|
|
module := TrainingModule{
|
|
ModuleCode: "CP-001",
|
|
Title: "Test Module",
|
|
RegulationArea: RegulationDSGVO,
|
|
}
|
|
|
|
prompt := buildQuizPrompt(module, "", 10)
|
|
|
|
if !containsSubstring(prompt, "10") {
|
|
t.Error("Quiz prompt should contain question count")
|
|
}
|
|
}
|
|
|
|
func TestBuildQuizPrompt_ContainsContentContext(t *testing.T) {
|
|
module := TrainingModule{
|
|
ModuleCode: "CP-001",
|
|
Title: "Test",
|
|
RegulationArea: RegulationDSGVO,
|
|
}
|
|
|
|
prompt := buildQuizPrompt(module, "This is the module content about DSGVO.", 5)
|
|
|
|
if !containsSubstring(prompt, "This is the module content about DSGVO.") {
|
|
t.Error("Quiz prompt should include content context")
|
|
}
|
|
}
|
|
|
|
func TestBuildQuizPrompt_TruncatesLongContent(t *testing.T) {
|
|
module := TrainingModule{
|
|
ModuleCode: "CP-001",
|
|
Title: "Test",
|
|
RegulationArea: RegulationDSGVO,
|
|
}
|
|
|
|
// Create content longer than 3000 chars
|
|
longContent := ""
|
|
for i := 0; i < 400; i++ {
|
|
longContent += "ABCDEFGHIJ" // 10 chars * 400 = 4000 chars
|
|
}
|
|
|
|
prompt := buildQuizPrompt(module, longContent, 5)
|
|
|
|
if containsSubstring(prompt, longContent) {
|
|
t.Error("Quiz prompt should truncate content longer than 3000 chars")
|
|
}
|
|
if !containsSubstring(prompt, "...") {
|
|
t.Error("Truncated content should end with '...'")
|
|
}
|
|
}
|
|
|
|
func TestBuildQuizPrompt_EmptyContent(t *testing.T) {
|
|
module := TrainingModule{
|
|
ModuleCode: "CP-001",
|
|
Title: "Test",
|
|
RegulationArea: RegulationDSGVO,
|
|
}
|
|
|
|
prompt := buildQuizPrompt(module, "", 5)
|
|
|
|
if containsSubstring(prompt, "Schulungsinhalt als Kontext") {
|
|
t.Error("Empty content should not add context section")
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// parseQuizResponse Tests
|
|
// =============================================================================
|
|
|
|
func TestParseQuizResponse_ValidJSON(t *testing.T) {
|
|
moduleID := uuid.New()
|
|
response := `[
|
|
{
|
|
"question": "Was ist die DSGVO?",
|
|
"options": ["EU-Verordnung", "Bundesgesetz", "Landesgesetz", "Internationale Konvention"],
|
|
"correct_index": 0,
|
|
"explanation": "Die DSGVO ist eine EU-Verordnung.",
|
|
"difficulty": "easy"
|
|
}
|
|
]`
|
|
|
|
questions, err := parseQuizResponse(response, moduleID)
|
|
if err != nil {
|
|
t.Fatalf("Expected no error, got %v", err)
|
|
}
|
|
if len(questions) != 1 {
|
|
t.Fatalf("Expected 1 question, got %d", len(questions))
|
|
}
|
|
if questions[0].Question != "Was ist die DSGVO?" {
|
|
t.Errorf("Expected question text, got '%s'", questions[0].Question)
|
|
}
|
|
if questions[0].CorrectIndex != 0 {
|
|
t.Errorf("Expected correct_index 0, got %d", questions[0].CorrectIndex)
|
|
}
|
|
if questions[0].Difficulty != DifficultyEasy {
|
|
t.Errorf("Expected difficulty 'easy', got '%s'", questions[0].Difficulty)
|
|
}
|
|
if questions[0].ModuleID != moduleID {
|
|
t.Error("Module ID should be set on parsed question")
|
|
}
|
|
if !questions[0].IsActive {
|
|
t.Error("Parsed questions should be active by default")
|
|
}
|
|
}
|
|
|
|
func TestParseQuizResponse_InvalidJSON(t *testing.T) {
|
|
moduleID := uuid.New()
|
|
_, err := parseQuizResponse("not valid json at all", moduleID)
|
|
if err == nil {
|
|
t.Error("Expected error for invalid JSON")
|
|
}
|
|
}
|
|
|
|
func TestParseQuizResponse_JSONWithSurroundingText(t *testing.T) {
|
|
moduleID := uuid.New()
|
|
response := `Here are the questions:
|
|
[
|
|
{
|
|
"question": "Test?",
|
|
"options": ["A", "B", "C", "D"],
|
|
"correct_index": 1,
|
|
"explanation": "B is correct.",
|
|
"difficulty": "medium"
|
|
}
|
|
]
|
|
I hope these are helpful!`
|
|
|
|
questions, err := parseQuizResponse(response, moduleID)
|
|
if err != nil {
|
|
t.Fatalf("Expected no error, got %v", err)
|
|
}
|
|
if len(questions) != 1 {
|
|
t.Fatalf("Expected 1 question, got %d", len(questions))
|
|
}
|
|
}
|
|
|
|
func TestParseQuizResponse_SkipsMalformedOptions(t *testing.T) {
|
|
moduleID := uuid.New()
|
|
response := `[
|
|
{
|
|
"question": "Good question?",
|
|
"options": ["A", "B", "C", "D"],
|
|
"correct_index": 0,
|
|
"explanation": "A is correct.",
|
|
"difficulty": "easy"
|
|
},
|
|
{
|
|
"question": "Bad question?",
|
|
"options": ["A", "B"],
|
|
"correct_index": 0,
|
|
"explanation": "Only 2 options.",
|
|
"difficulty": "easy"
|
|
}
|
|
]`
|
|
|
|
questions, err := parseQuizResponse(response, moduleID)
|
|
if err != nil {
|
|
t.Fatalf("Expected no error, got %v", err)
|
|
}
|
|
if len(questions) != 1 {
|
|
t.Errorf("Expected 1 valid question (malformed should be skipped), got %d", len(questions))
|
|
}
|
|
}
|
|
|
|
func TestParseQuizResponse_SkipsInvalidCorrectIndex(t *testing.T) {
|
|
moduleID := uuid.New()
|
|
response := `[
|
|
{
|
|
"question": "Bad index?",
|
|
"options": ["A", "B", "C", "D"],
|
|
"correct_index": 5,
|
|
"explanation": "Index out of range.",
|
|
"difficulty": "medium"
|
|
}
|
|
]`
|
|
|
|
questions, err := parseQuizResponse(response, moduleID)
|
|
if err != nil {
|
|
t.Fatalf("Expected no error, got %v", err)
|
|
}
|
|
if len(questions) != 0 {
|
|
t.Errorf("Expected 0 questions (invalid index should be skipped), got %d", len(questions))
|
|
}
|
|
}
|
|
|
|
func TestParseQuizResponse_NegativeCorrectIndex(t *testing.T) {
|
|
moduleID := uuid.New()
|
|
response := `[
|
|
{
|
|
"question": "Negative index?",
|
|
"options": ["A", "B", "C", "D"],
|
|
"correct_index": -1,
|
|
"explanation": "Negative index.",
|
|
"difficulty": "easy"
|
|
}
|
|
]`
|
|
|
|
questions, err := parseQuizResponse(response, moduleID)
|
|
if err != nil {
|
|
t.Fatalf("Expected no error, got %v", err)
|
|
}
|
|
if len(questions) != 0 {
|
|
t.Errorf("Expected 0 questions (negative index should be skipped), got %d", len(questions))
|
|
}
|
|
}
|
|
|
|
func TestParseQuizResponse_DefaultsDifficultyToMedium(t *testing.T) {
|
|
moduleID := uuid.New()
|
|
response := `[
|
|
{
|
|
"question": "Test?",
|
|
"options": ["A", "B", "C", "D"],
|
|
"correct_index": 0,
|
|
"explanation": "A is correct.",
|
|
"difficulty": "unknown_difficulty"
|
|
}
|
|
]`
|
|
|
|
questions, err := parseQuizResponse(response, moduleID)
|
|
if err != nil {
|
|
t.Fatalf("Expected no error, got %v", err)
|
|
}
|
|
if len(questions) != 1 {
|
|
t.Fatalf("Expected 1 question, got %d", len(questions))
|
|
}
|
|
if questions[0].Difficulty != DifficultyMedium {
|
|
t.Errorf("Expected difficulty to default to 'medium', got '%s'", questions[0].Difficulty)
|
|
}
|
|
}
|
|
|
|
func TestParseQuizResponse_MultipleQuestions(t *testing.T) {
|
|
moduleID := uuid.New()
|
|
response := `[
|
|
{"question":"Q1?","options":["A","B","C","D"],"correct_index":0,"explanation":"","difficulty":"easy"},
|
|
{"question":"Q2?","options":["A","B","C","D"],"correct_index":1,"explanation":"","difficulty":"medium"},
|
|
{"question":"Q3?","options":["A","B","C","D"],"correct_index":2,"explanation":"","difficulty":"hard"}
|
|
]`
|
|
|
|
questions, err := parseQuizResponse(response, moduleID)
|
|
if err != nil {
|
|
t.Fatalf("Expected no error, got %v", err)
|
|
}
|
|
if len(questions) != 3 {
|
|
t.Errorf("Expected 3 questions, got %d", len(questions))
|
|
}
|
|
}
|
|
|
|
func TestParseQuizResponse_EmptyArray(t *testing.T) {
|
|
moduleID := uuid.New()
|
|
questions, err := parseQuizResponse("[]", moduleID)
|
|
if err != nil {
|
|
t.Fatalf("Expected no error, got %v", err)
|
|
}
|
|
if len(questions) != 0 {
|
|
t.Errorf("Expected 0 questions, got %d", len(questions))
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// truncateText Tests
|
|
// =============================================================================
|
|
|
|
func TestTruncateText_ShortText(t *testing.T) {
|
|
result := truncateText("hello", 100)
|
|
if result != "hello" {
|
|
t.Errorf("Short text should not be truncated, got '%s'", result)
|
|
}
|
|
}
|
|
|
|
func TestTruncateText_ExactLength(t *testing.T) {
|
|
result := truncateText("12345", 5)
|
|
if result != "12345" {
|
|
t.Errorf("Text at exact max length should not be truncated, got '%s'", result)
|
|
}
|
|
}
|
|
|
|
func TestTruncateText_LongText(t *testing.T) {
|
|
result := truncateText("1234567890", 5)
|
|
if result != "12345..." {
|
|
t.Errorf("Expected '12345...', got '%s'", result)
|
|
}
|
|
}
|
|
|
|
func TestTruncateText_EmptyString(t *testing.T) {
|
|
result := truncateText("", 10)
|
|
if result != "" {
|
|
t.Errorf("Empty string should remain empty, got '%s'", result)
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// System Prompt Tests
|
|
// =============================================================================
|
|
|
|
func TestGetContentSystemPrompt_German(t *testing.T) {
|
|
prompt := getContentSystemPrompt("de")
|
|
if !containsSubstring(prompt, "Compliance-Schulungsinhalte") {
|
|
t.Error("German system prompt should mention Compliance-Schulungsinhalte")
|
|
}
|
|
if !containsSubstring(prompt, "Markdown") {
|
|
t.Error("System prompt should mention Markdown format")
|
|
}
|
|
}
|
|
|
|
func TestGetContentSystemPrompt_English(t *testing.T) {
|
|
prompt := getContentSystemPrompt("en")
|
|
if !containsSubstring(prompt, "compliance training content") {
|
|
t.Error("English system prompt should mention compliance training content")
|
|
}
|
|
}
|
|
|
|
func TestGetQuizSystemPrompt_ContainsJSONFormat(t *testing.T) {
|
|
prompt := getQuizSystemPrompt()
|
|
if !containsSubstring(prompt, "JSON") {
|
|
t.Error("Quiz system prompt should mention JSON format")
|
|
}
|
|
if !containsSubstring(prompt, "correct_index") {
|
|
t.Error("Quiz system prompt should show correct_index field")
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// buildBlockContentPrompt Tests
|
|
// =============================================================================
|
|
|
|
func TestBuildBlockContentPrompt_ContainsModuleInfo(t *testing.T) {
|
|
module := TrainingModule{
|
|
ModuleCode: "BLK-AUTH-001",
|
|
Title: "Authentication Controls",
|
|
DurationMinutes: 45,
|
|
}
|
|
controls := []CanonicalControlSummary{
|
|
{
|
|
ControlID: "AUTH-001",
|
|
Title: "Multi-Factor Authentication",
|
|
Objective: "Ensure MFA is enabled",
|
|
Requirements: []string{"Enable MFA for all users"},
|
|
},
|
|
}
|
|
|
|
prompt := buildBlockContentPrompt(module, controls, "de")
|
|
|
|
if !containsSubstring(prompt, "BLK-AUTH-001") {
|
|
t.Error("Block prompt should contain module code")
|
|
}
|
|
if !containsSubstring(prompt, "Authentication Controls") {
|
|
t.Error("Block prompt should contain module title")
|
|
}
|
|
if !containsSubstring(prompt, "45 Minuten") {
|
|
t.Error("Block prompt should contain duration")
|
|
}
|
|
}
|
|
|
|
func TestBuildBlockContentPrompt_ContainsControlDetails(t *testing.T) {
|
|
module := TrainingModule{
|
|
ModuleCode: "BLK-001",
|
|
Title: "Test",
|
|
DurationMinutes: 30,
|
|
}
|
|
controls := []CanonicalControlSummary{
|
|
{
|
|
ControlID: "CTRL-001",
|
|
Title: "Test Control",
|
|
Objective: "Test objective",
|
|
Requirements: []string{"Req 1", "Req 2"},
|
|
},
|
|
}
|
|
|
|
prompt := buildBlockContentPrompt(module, controls, "de")
|
|
|
|
if !containsSubstring(prompt, "CTRL-001") {
|
|
t.Error("Prompt should contain control ID")
|
|
}
|
|
if !containsSubstring(prompt, "Test Control") {
|
|
t.Error("Prompt should contain control title")
|
|
}
|
|
if !containsSubstring(prompt, "Test objective") {
|
|
t.Error("Prompt should contain control objective")
|
|
}
|
|
if !containsSubstring(prompt, "Req 1") {
|
|
t.Error("Prompt should contain control requirements")
|
|
}
|
|
}
|
|
|
|
func TestBuildBlockContentPrompt_EnglishVersion(t *testing.T) {
|
|
module := TrainingModule{
|
|
ModuleCode: "BLK-001",
|
|
Title: "Test",
|
|
DurationMinutes: 30,
|
|
}
|
|
controls := []CanonicalControlSummary{}
|
|
|
|
prompt := buildBlockContentPrompt(module, controls, "en")
|
|
|
|
if !containsSubstring(prompt, "Create training material") {
|
|
t.Error("English prompt should use English text")
|
|
}
|
|
}
|
|
|
|
func TestBuildBlockContentPrompt_MultipleControls(t *testing.T) {
|
|
module := TrainingModule{
|
|
ModuleCode: "BLK-001",
|
|
Title: "Test",
|
|
DurationMinutes: 30,
|
|
}
|
|
controls := []CanonicalControlSummary{
|
|
{ControlID: "CTRL-001", Title: "First Control", Objective: "Obj 1"},
|
|
{ControlID: "CTRL-002", Title: "Second Control", Objective: "Obj 2"},
|
|
{ControlID: "CTRL-003", Title: "Third Control", Objective: "Obj 3"},
|
|
}
|
|
|
|
prompt := buildBlockContentPrompt(module, controls, "de")
|
|
|
|
if !containsSubstring(prompt, "3 Sicherheits-Controls") {
|
|
t.Error("Prompt should mention the count of controls")
|
|
}
|
|
if !containsSubstring(prompt, "Control 1") {
|
|
t.Error("Prompt should number controls")
|
|
}
|
|
if !containsSubstring(prompt, "Control 3") {
|
|
t.Error("Prompt should include all controls")
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Helpers
|
|
// =============================================================================
|
|
|
|
func containsSubstring(s, substr string) bool {
|
|
return len(s) >= len(substr) && searchSubstring(s, substr)
|
|
}
|
|
|
|
func searchSubstring(s, substr string) bool {
|
|
if len(substr) == 0 {
|
|
return true
|
|
}
|
|
for i := 0; i <= len(s)-len(substr); i++ {
|
|
if s[i:i+len(substr)] == substr {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|