All checks were successful
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) Successful in 38s
CI/CD / test-python-backend-compliance (push) Successful in 34s
CI/CD / test-python-document-crawler (push) Successful in 29s
CI/CD / test-python-dsms-gateway (push) Successful in 20s
CI/CD / validate-canonical-controls (push) Successful in 12s
CI/CD / Deploy (push) Successful in 2s
- Create iace_handler_test.go (22 tests): input validation for InitFromProfile, GenerateSingleSection, ExportTechFile, CheckCompleteness, getTenantID, CreateProject, ListProjects, Component CRUD handlers - Add error-handling tests to tech_file_generator_test.go: nil context, nil project, empty components/hazards/classifications/evidence, unknown section type, all 19 getSystemPrompt types, AI-specific section prompts - Add JSON export tests to document_export_test.go: valid output, empty project, nil project error, special character handling (German text, XML escapes) - Add iace-hazard-library.md to mkdocs.yml navigation - Add TipTap Rich-Text-Editor section to iace.md documentation Total: 181 tests passing (was 165), 0 failures Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
455 lines
15 KiB
Go
455 lines
15 KiB
Go
package iace
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
// createTestExportData builds a complete set of test data for document export tests.
|
|
func createTestExportData() (*Project, []TechFileSection, []Hazard, []RiskAssessment, []Mitigation, []RegulatoryClassification) {
|
|
projectID := uuid.New()
|
|
hazardID1 := uuid.New()
|
|
hazardID2 := uuid.New()
|
|
|
|
project := &Project{
|
|
ID: projectID,
|
|
MachineName: "Robot Arm XY-200",
|
|
MachineType: "industrial_robot",
|
|
Manufacturer: "TestCorp GmbH",
|
|
Description: "6-Achsen Industrieroboter fuer Schweissarbeiten",
|
|
CEMarkingTarget: "2023/1230",
|
|
}
|
|
|
|
sections := []TechFileSection{
|
|
{ID: uuid.New(), ProjectID: projectID, SectionType: "risk_assessment_report", Title: "Risikobeurteilung", Content: "Dies ist der Risikobeurteilungsbericht...", Status: TechFileSectionStatusApproved},
|
|
{ID: uuid.New(), ProjectID: projectID, SectionType: "hazard_log_combined", Title: "Gefaehrdungsprotokoll", Content: "Protokoll aller identifizierten Gefaehrdungen...", Status: TechFileSectionStatusGenerated},
|
|
{ID: uuid.New(), ProjectID: projectID, SectionType: "declaration_of_conformity", Title: "EU-Konformitaetserklaerung", Content: "Hiermit erklaeren wir...", Status: TechFileSectionStatusDraft},
|
|
}
|
|
|
|
hazards := []Hazard{
|
|
{ID: hazardID1, ProjectID: projectID, Name: "Quetschgefahr", Category: "mechanical", Description: "Quetschgefahr durch Roboterarm"},
|
|
{ID: hazardID2, ProjectID: projectID, Name: "Elektrischer Schlag", Category: "electrical", Description: "Gefahr durch freiliegende Kontakte"},
|
|
}
|
|
|
|
assessments := []RiskAssessment{
|
|
{ID: uuid.New(), HazardID: hazardID1, Severity: 4, Exposure: 3, Probability: 3, InherentRisk: 36, CEff: 0.7, ResidualRisk: 10.8, RiskLevel: RiskLevelHigh, IsAcceptable: false},
|
|
{ID: uuid.New(), HazardID: hazardID2, Severity: 2, Exposure: 2, Probability: 2, InherentRisk: 8, CEff: 0.8, ResidualRisk: 1.6, RiskLevel: RiskLevelLow, IsAcceptable: true},
|
|
}
|
|
|
|
mitigations := []Mitigation{
|
|
{ID: uuid.New(), HazardID: hazardID1, ReductionType: ReductionTypeDesign, Name: "Schutzabdeckung", Status: MitigationStatusVerified},
|
|
{ID: uuid.New(), HazardID: hazardID1, ReductionType: ReductionTypeProtective, Name: "Lichtschranke", Status: MitigationStatusVerified},
|
|
{ID: uuid.New(), HazardID: hazardID2, ReductionType: ReductionTypeInformation, Name: "Warnhinweis", Status: MitigationStatusPlanned},
|
|
}
|
|
|
|
classifications := []RegulatoryClassification{
|
|
{Regulation: RegulationMachineryRegulation, ClassificationResult: "Annex I", RiskLevel: RiskLevelHigh},
|
|
{Regulation: RegulationCRA, ClassificationResult: "Important", RiskLevel: RiskLevelMedium},
|
|
}
|
|
|
|
return project, sections, hazards, assessments, mitigations, classifications
|
|
}
|
|
|
|
// createEmptyExportData builds minimal test data with no hazards, sections, or mitigations.
|
|
func createEmptyExportData() (*Project, []TechFileSection, []Hazard, []RiskAssessment, []Mitigation, []RegulatoryClassification) {
|
|
project := &Project{
|
|
ID: uuid.New(),
|
|
MachineName: "Empty Machine",
|
|
MachineType: "test",
|
|
Manufacturer: "TestCorp",
|
|
Description: "",
|
|
CEMarkingTarget: "",
|
|
}
|
|
return project, nil, nil, nil, nil, nil
|
|
}
|
|
|
|
// ============================================================================
|
|
// PDF Export Tests
|
|
// ============================================================================
|
|
|
|
func TestExportPDF_ValidOutput(t *testing.T) {
|
|
exporter := NewDocumentExporter()
|
|
project, sections, hazards, assessments, mitigations, classifications := createTestExportData()
|
|
|
|
data, err := exporter.ExportPDF(project, sections, hazards, assessments, mitigations, classifications)
|
|
if err != nil {
|
|
t.Fatalf("ExportPDF returned error: %v", err)
|
|
}
|
|
|
|
if len(data) == 0 {
|
|
t.Fatal("ExportPDF returned empty bytes")
|
|
}
|
|
|
|
// Valid PDF files start with %PDF-
|
|
if !bytes.HasPrefix(data, []byte("%PDF-")) {
|
|
t.Errorf("ExportPDF output does not start with %%PDF-, got first 10 bytes: %q", data[:min(10, len(data))])
|
|
}
|
|
}
|
|
|
|
func TestExportPDF_EmptyProject(t *testing.T) {
|
|
exporter := NewDocumentExporter()
|
|
project, sections, hazards, assessments, mitigations, classifications := createEmptyExportData()
|
|
|
|
data, err := exporter.ExportPDF(project, sections, hazards, assessments, mitigations, classifications)
|
|
if err != nil {
|
|
t.Fatalf("ExportPDF with empty project returned error: %v", err)
|
|
}
|
|
|
|
if len(data) == 0 {
|
|
t.Fatal("ExportPDF with empty project returned empty bytes")
|
|
}
|
|
|
|
// Should still produce a valid PDF even with no content
|
|
if !bytes.HasPrefix(data, []byte("%PDF-")) {
|
|
t.Errorf("ExportPDF output does not start with %%PDF-, got first 10 bytes: %q", data[:min(10, len(data))])
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// Excel Export Tests
|
|
// ============================================================================
|
|
|
|
func TestExportExcel_ValidOutput(t *testing.T) {
|
|
exporter := NewDocumentExporter()
|
|
project, sections, hazards, assessments, mitigations, _ := createTestExportData()
|
|
|
|
data, err := exporter.ExportExcel(project, sections, hazards, assessments, mitigations)
|
|
if err != nil {
|
|
t.Fatalf("ExportExcel returned error: %v", err)
|
|
}
|
|
|
|
if len(data) == 0 {
|
|
t.Fatal("ExportExcel returned empty bytes")
|
|
}
|
|
|
|
// xlsx is a zip archive, which starts with PK (0x50, 0x4b)
|
|
if !bytes.HasPrefix(data, []byte("PK")) {
|
|
t.Errorf("ExportExcel output does not start with PK (zip signature), got first 4 bytes: %x", data[:min(4, len(data))])
|
|
}
|
|
}
|
|
|
|
func TestExportExcel_EmptyProject(t *testing.T) {
|
|
exporter := NewDocumentExporter()
|
|
project, sections, hazards, assessments, mitigations, _ := createEmptyExportData()
|
|
|
|
data, err := exporter.ExportExcel(project, sections, hazards, assessments, mitigations)
|
|
if err != nil {
|
|
t.Fatalf("ExportExcel with empty project returned error: %v", err)
|
|
}
|
|
|
|
if len(data) == 0 {
|
|
t.Fatal("ExportExcel with empty project returned empty bytes")
|
|
}
|
|
|
|
// Should still produce a valid xlsx (zip) even with no data
|
|
if !bytes.HasPrefix(data, []byte("PK")) {
|
|
t.Errorf("ExportExcel output does not start with PK (zip signature), got first 4 bytes: %x", data[:min(4, len(data))])
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// Markdown Export Tests
|
|
// ============================================================================
|
|
|
|
func TestExportMarkdown_ContainsSections(t *testing.T) {
|
|
exporter := NewDocumentExporter()
|
|
project, sections, _, _, _, _ := createTestExportData()
|
|
|
|
data, err := exporter.ExportMarkdown(project, sections)
|
|
if err != nil {
|
|
t.Fatalf("ExportMarkdown returned error: %v", err)
|
|
}
|
|
|
|
if len(data) == 0 {
|
|
t.Fatal("ExportMarkdown returned empty bytes")
|
|
}
|
|
|
|
content := string(data)
|
|
|
|
// Should contain the project name
|
|
if !strings.Contains(content, project.MachineName) {
|
|
t.Errorf("ExportMarkdown output does not contain project name %q", project.MachineName)
|
|
}
|
|
|
|
// Should contain each section title
|
|
for _, section := range sections {
|
|
if !strings.Contains(content, section.Title) {
|
|
t.Errorf("ExportMarkdown output does not contain section title %q", section.Title)
|
|
}
|
|
}
|
|
|
|
// Should contain markdown header syntax
|
|
if !strings.Contains(content, "#") {
|
|
t.Error("ExportMarkdown output does not contain any markdown headers")
|
|
}
|
|
}
|
|
|
|
func TestExportMarkdown_EmptyProject(t *testing.T) {
|
|
exporter := NewDocumentExporter()
|
|
project, _, _, _, _, _ := createEmptyExportData()
|
|
|
|
data, err := exporter.ExportMarkdown(project, nil)
|
|
if err != nil {
|
|
t.Fatalf("ExportMarkdown with empty project returned error: %v", err)
|
|
}
|
|
|
|
if len(data) == 0 {
|
|
t.Fatal("ExportMarkdown with empty project returned empty bytes")
|
|
}
|
|
|
|
content := string(data)
|
|
|
|
// Should still contain the project name as a header even without sections
|
|
if !strings.Contains(content, project.MachineName) {
|
|
t.Errorf("ExportMarkdown output does not contain project name %q for empty project", project.MachineName)
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// DOCX Export Tests
|
|
// ============================================================================
|
|
|
|
func TestExportDOCX_ValidOutput(t *testing.T) {
|
|
exporter := NewDocumentExporter()
|
|
project, sections, _, _, _, _ := createTestExportData()
|
|
|
|
data, err := exporter.ExportDOCX(project, sections)
|
|
if err != nil {
|
|
t.Fatalf("ExportDOCX returned error: %v", err)
|
|
}
|
|
|
|
if len(data) == 0 {
|
|
t.Fatal("ExportDOCX returned empty bytes")
|
|
}
|
|
|
|
// docx is a zip archive, which starts with PK (0x50, 0x4b)
|
|
if !bytes.HasPrefix(data, []byte("PK")) {
|
|
t.Errorf("ExportDOCX output does not start with PK (zip signature), got first 4 bytes: %x", data[:min(4, len(data))])
|
|
}
|
|
}
|
|
|
|
func TestExportDOCX_EmptyProject(t *testing.T) {
|
|
exporter := NewDocumentExporter()
|
|
project, _, _, _, _, _ := createEmptyExportData()
|
|
|
|
data, err := exporter.ExportDOCX(project, nil)
|
|
if err != nil {
|
|
t.Fatalf("ExportDOCX with empty project returned error: %v", err)
|
|
}
|
|
|
|
if len(data) == 0 {
|
|
t.Fatal("ExportDOCX with empty project returned empty bytes")
|
|
}
|
|
|
|
// Should still produce a valid docx (zip) even with no sections
|
|
if !bytes.HasPrefix(data, []byte("PK")) {
|
|
t.Errorf("ExportDOCX output does not start with PK (zip signature), got first 4 bytes: %x", data[:min(4, len(data))])
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// Helper Function Tests
|
|
// ============================================================================
|
|
|
|
// ============================================================================
|
|
// JSON Export Tests
|
|
// ============================================================================
|
|
|
|
func TestExportJSON_ValidOutput(t *testing.T) {
|
|
exporter := NewDocumentExporter()
|
|
project, sections, hazards, assessments, mitigations, classifications := createTestExportData()
|
|
|
|
data, err := exporter.ExportJSON(project, sections, hazards, assessments, mitigations, classifications)
|
|
if err != nil {
|
|
t.Fatalf("ExportJSON returned error: %v", err)
|
|
}
|
|
|
|
if len(data) == 0 {
|
|
t.Fatal("ExportJSON returned empty bytes")
|
|
}
|
|
|
|
// Must be valid JSON
|
|
var parsed map[string]interface{}
|
|
if err := json.Unmarshal(data, &parsed); err != nil {
|
|
t.Fatalf("ExportJSON output is not valid JSON: %v", err)
|
|
}
|
|
|
|
// Check required top-level keys
|
|
requiredKeys := []string{"project", "sections", "hazards", "assessments", "mitigations", "classifications", "exported_at", "format_version"}
|
|
for _, key := range requiredKeys {
|
|
if _, ok := parsed[key]; !ok {
|
|
t.Errorf("ExportJSON output missing key %q", key)
|
|
}
|
|
}
|
|
|
|
// format_version should be "1.0"
|
|
if fv, _ := parsed["format_version"].(string); fv != "1.0" {
|
|
t.Errorf("Expected format_version '1.0', got %q", fv)
|
|
}
|
|
}
|
|
|
|
func TestExportJSON_EmptyProject(t *testing.T) {
|
|
exporter := NewDocumentExporter()
|
|
project, sections, hazards, assessments, mitigations, classifications := createEmptyExportData()
|
|
|
|
data, err := exporter.ExportJSON(project, sections, hazards, assessments, mitigations, classifications)
|
|
if err != nil {
|
|
t.Fatalf("ExportJSON with empty project returned error: %v", err)
|
|
}
|
|
|
|
var parsed map[string]interface{}
|
|
if err := json.Unmarshal(data, &parsed); err != nil {
|
|
t.Fatalf("ExportJSON output is not valid JSON: %v", err)
|
|
}
|
|
|
|
// Project should still be present
|
|
if parsed["project"] == nil {
|
|
t.Error("ExportJSON should include project even for empty project")
|
|
}
|
|
}
|
|
|
|
func TestExportJSON_NilProject_ReturnsError(t *testing.T) {
|
|
exporter := NewDocumentExporter()
|
|
|
|
_, err := exporter.ExportJSON(nil, nil, nil, nil, nil, nil)
|
|
if err == nil {
|
|
t.Error("ExportJSON should return error for nil project")
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// Special Character Tests
|
|
// ============================================================================
|
|
|
|
func TestExportMarkdown_GermanUmlauts(t *testing.T) {
|
|
exporter := NewDocumentExporter()
|
|
project := &Project{
|
|
ID: uuid.New(),
|
|
MachineName: "Pruefgeraet fuer Sicherheitsueberwachung",
|
|
MachineType: "Pruefstand",
|
|
Manufacturer: "Mueller & Soehne GmbH",
|
|
}
|
|
sections := []TechFileSection{
|
|
{SectionType: "general_description", Title: "Allgemeine Beschreibung", Content: "Aenderungen und Ergaenzungen"},
|
|
}
|
|
|
|
data, err := exporter.ExportMarkdown(project, sections)
|
|
if err != nil {
|
|
t.Fatalf("ExportMarkdown with German text returned error: %v", err)
|
|
}
|
|
|
|
content := string(data)
|
|
if !strings.Contains(content, "Pruefgeraet") {
|
|
t.Error("Markdown should preserve German text")
|
|
}
|
|
if !strings.Contains(content, "Mueller") {
|
|
t.Error("Markdown should preserve manufacturer name with special chars")
|
|
}
|
|
}
|
|
|
|
func TestExportDOCX_SpecialCharacters(t *testing.T) {
|
|
exporter := NewDocumentExporter()
|
|
project := &Project{
|
|
ID: uuid.New(),
|
|
MachineName: "Test <Machine> & \"Quotes\"",
|
|
MachineType: "test",
|
|
Manufacturer: "Corp <&>",
|
|
}
|
|
sections := []TechFileSection{
|
|
{SectionType: "general_description", Title: "Title with <angle> & \"quotes\"", Content: "Content with <special> & chars"},
|
|
}
|
|
|
|
data, err := exporter.ExportDOCX(project, sections)
|
|
if err != nil {
|
|
t.Fatalf("ExportDOCX with special characters returned error: %v", err)
|
|
}
|
|
|
|
if len(data) == 0 {
|
|
t.Fatal("ExportDOCX with special characters returned empty bytes")
|
|
}
|
|
|
|
// Should still produce a valid zip
|
|
if !bytes.HasPrefix(data, []byte("PK")) {
|
|
t.Error("ExportDOCX output should still be valid zip even with special characters")
|
|
}
|
|
}
|
|
|
|
func TestExportPDF_GermanText(t *testing.T) {
|
|
exporter := NewDocumentExporter()
|
|
project := &Project{
|
|
ID: uuid.New(),
|
|
MachineName: "Sicherheits-Pruefstand SP-400",
|
|
Manufacturer: "Deutsche Prueftechnik AG",
|
|
}
|
|
sections := []TechFileSection{
|
|
{SectionType: "general_description", Title: "Beschreibung", Content: "Technische Dokumentation fuer den Sicherheits-Pruefstand"},
|
|
}
|
|
|
|
data, err := exporter.ExportPDF(project, sections, nil, nil, nil, nil)
|
|
if err != nil {
|
|
t.Fatalf("ExportPDF with German text returned error: %v", err)
|
|
}
|
|
|
|
if !bytes.HasPrefix(data, []byte("%PDF-")) {
|
|
t.Error("ExportPDF should produce valid PDF with German text")
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// Helper Function Tests
|
|
// ============================================================================
|
|
|
|
func TestRiskLevelLabel_AllLevels(t *testing.T) {
|
|
levels := []RiskLevel{
|
|
RiskLevelCritical,
|
|
RiskLevelHigh,
|
|
RiskLevelMedium,
|
|
RiskLevelLow,
|
|
RiskLevelNegligible,
|
|
RiskLevelNotAcceptable,
|
|
RiskLevelVeryHigh,
|
|
}
|
|
|
|
for _, level := range levels {
|
|
t.Run(string(level), func(t *testing.T) {
|
|
label := riskLevelLabel(level)
|
|
if label == "" {
|
|
t.Errorf("riskLevelLabel(%q) returned empty string", level)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRiskLevelColor_AllLevels(t *testing.T) {
|
|
levels := []RiskLevel{
|
|
RiskLevelCritical,
|
|
RiskLevelHigh,
|
|
RiskLevelMedium,
|
|
RiskLevelLow,
|
|
RiskLevelNegligible,
|
|
RiskLevelNotAcceptable,
|
|
RiskLevelVeryHigh,
|
|
}
|
|
|
|
for _, level := range levels {
|
|
t.Run(string(level), func(t *testing.T) {
|
|
r, g, b := riskLevelColor(level)
|
|
// RGB values must be in valid range 0-255
|
|
if r < 0 || r > 255 {
|
|
t.Errorf("riskLevelColor(%q) red value %d out of range [0,255]", level, r)
|
|
}
|
|
if g < 0 || g > 255 {
|
|
t.Errorf("riskLevelColor(%q) green value %d out of range [0,255]", level, g)
|
|
}
|
|
if b < 0 || b > 255 {
|
|
t.Errorf("riskLevelColor(%q) blue value %d out of range [0,255]", level, b)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|