diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..8c17a94 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,77 @@ +// Package config provides configuration management for the articulate-parser application. +// It supports loading configuration from environment variables and command-line flags. +package config + +import ( + "log/slog" + "os" + "strconv" + "time" +) + +// Config holds all configuration values for the application. +type Config struct { + // Parser configuration + BaseURL string + RequestTimeout time.Duration + + // Logging configuration + LogLevel slog.Level + LogFormat string // "json" or "text" +} + +// Default configuration values. +const ( + DefaultBaseURL = "https://rise.articulate.com" + DefaultRequestTimeout = 30 * time.Second + DefaultLogLevel = slog.LevelInfo + DefaultLogFormat = "text" +) + +// Load creates a new Config with values from environment variables. +// Falls back to defaults if environment variables are not set. +func Load() *Config { + return &Config{ + BaseURL: getEnv("ARTICULATE_BASE_URL", DefaultBaseURL), + RequestTimeout: getDurationEnv("ARTICULATE_REQUEST_TIMEOUT", DefaultRequestTimeout), + LogLevel: getLogLevelEnv("LOG_LEVEL", DefaultLogLevel), + LogFormat: getEnv("LOG_FORMAT", DefaultLogFormat), + } +} + +// getEnv retrieves an environment variable or returns the default value. +func getEnv(key, defaultValue string) string { + if value := os.Getenv(key); value != "" { + return value + } + return defaultValue +} + +// getDurationEnv retrieves a duration from environment variable or returns default. +// The environment variable should be in seconds (e.g., "30" for 30 seconds). +func getDurationEnv(key string, defaultValue time.Duration) time.Duration { + if value := os.Getenv(key); value != "" { + if seconds, err := strconv.Atoi(value); err == nil { + return time.Duration(seconds) * time.Second + } + } + return defaultValue +} + +// getLogLevelEnv retrieves a log level from environment variable or returns default. +// Accepts: "debug", "info", "warn", "error" (case-insensitive). +func getLogLevelEnv(key string, defaultValue slog.Level) slog.Level { + value := os.Getenv(key) + switch value { + case "debug", "DEBUG": + return slog.LevelDebug + case "info", "INFO": + return slog.LevelInfo + case "warn", "WARN", "warning", "WARNING": + return slog.LevelWarn + case "error", "ERROR": + return slog.LevelError + default: + return defaultValue + } +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..49894b8 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,118 @@ +// Package config_test provides tests for the config package. +package config + +import ( + "log/slog" + "os" + "testing" + "time" +) + +func TestLoad(t *testing.T) { + // Clear environment + os.Clearenv() + + cfg := Load() + + if cfg.BaseURL != DefaultBaseURL { + t.Errorf("Expected BaseURL '%s', got '%s'", DefaultBaseURL, cfg.BaseURL) + } + + if cfg.RequestTimeout != DefaultRequestTimeout { + t.Errorf("Expected timeout %v, got %v", DefaultRequestTimeout, cfg.RequestTimeout) + } + + if cfg.LogLevel != DefaultLogLevel { + t.Errorf("Expected log level %v, got %v", DefaultLogLevel, cfg.LogLevel) + } + + if cfg.LogFormat != DefaultLogFormat { + t.Errorf("Expected log format '%s', got '%s'", DefaultLogFormat, cfg.LogFormat) + } +} + +func TestLoad_WithEnvironmentVariables(t *testing.T) { + // Set environment variables + os.Setenv("ARTICULATE_BASE_URL", "https://test.example.com") + os.Setenv("ARTICULATE_REQUEST_TIMEOUT", "60") + os.Setenv("LOG_LEVEL", "debug") + os.Setenv("LOG_FORMAT", "json") + defer os.Clearenv() + + cfg := Load() + + if cfg.BaseURL != "https://test.example.com" { + t.Errorf("Expected BaseURL 'https://test.example.com', got '%s'", cfg.BaseURL) + } + + if cfg.RequestTimeout != 60*time.Second { + t.Errorf("Expected timeout 60s, got %v", cfg.RequestTimeout) + } + + if cfg.LogLevel != slog.LevelDebug { + t.Errorf("Expected log level Debug, got %v", cfg.LogLevel) + } + + if cfg.LogFormat != "json" { + t.Errorf("Expected log format 'json', got '%s'", cfg.LogFormat) + } +} + +func TestGetLogLevelEnv(t *testing.T) { + tests := []struct { + name string + value string + expected slog.Level + }{ + {"debug lowercase", "debug", slog.LevelDebug}, + {"debug uppercase", "DEBUG", slog.LevelDebug}, + {"info lowercase", "info", slog.LevelInfo}, + {"info uppercase", "INFO", slog.LevelInfo}, + {"warn lowercase", "warn", slog.LevelWarn}, + {"warn uppercase", "WARN", slog.LevelWarn}, + {"warning lowercase", "warning", slog.LevelWarn}, + {"error lowercase", "error", slog.LevelError}, + {"error uppercase", "ERROR", slog.LevelError}, + {"invalid value", "invalid", slog.LevelInfo}, + {"empty value", "", slog.LevelInfo}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + os.Clearenv() + if tt.value != "" { + os.Setenv("TEST_LOG_LEVEL", tt.value) + } + result := getLogLevelEnv("TEST_LOG_LEVEL", slog.LevelInfo) + if result != tt.expected { + t.Errorf("Expected %v, got %v", tt.expected, result) + } + }) + } +} + +func TestGetDurationEnv(t *testing.T) { + tests := []struct { + name string + value string + expected time.Duration + }{ + {"valid duration", "45", 45 * time.Second}, + {"zero duration", "0", 0}, + {"invalid duration", "invalid", 30 * time.Second}, + {"empty value", "", 30 * time.Second}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + os.Clearenv() + if tt.value != "" { + os.Setenv("TEST_DURATION", tt.value) + } + result := getDurationEnv("TEST_DURATION", 30*time.Second) + if result != tt.expected { + t.Errorf("Expected %v, got %v", tt.expected, result) + } + }) + } +} diff --git a/internal/exporters/bench_test.go b/internal/exporters/bench_test.go new file mode 100644 index 0000000..f60f353 --- /dev/null +++ b/internal/exporters/bench_test.go @@ -0,0 +1,201 @@ +// Package exporters_test provides benchmarks for all exporters. +package exporters + +import ( + "path/filepath" + "testing" + + "github.com/kjanat/articulate-parser/internal/models" + "github.com/kjanat/articulate-parser/internal/services" +) + +// BenchmarkFactory_CreateExporter_Markdown benchmarks markdown exporter creation. +func BenchmarkFactory_CreateExporter_Markdown(b *testing.B) { + htmlCleaner := services.NewHTMLCleaner() + factory := NewFactory(htmlCleaner) + + b.ResetTimer() + for b.Loop() { + _, _ = factory.CreateExporter("markdown") + } +} + +// BenchmarkFactory_CreateExporter_All benchmarks creating all exporter types. +func BenchmarkFactory_CreateExporter_All(b *testing.B) { + htmlCleaner := services.NewHTMLCleaner() + factory := NewFactory(htmlCleaner) + formats := []string{"markdown", "docx", "html"} + + b.ResetTimer() + for b.Loop() { + for _, format := range formats { + _, _ = factory.CreateExporter(format) + } + } +} + +// BenchmarkAllExporters_Export benchmarks all exporters with the same course. +func BenchmarkAllExporters_Export(b *testing.B) { + htmlCleaner := services.NewHTMLCleaner() + course := createBenchmarkCourse() + + exporters := map[string]struct { + exporter any + ext string + }{ + "Markdown": {NewMarkdownExporter(htmlCleaner), ".md"}, + "Docx": {NewDocxExporter(htmlCleaner), ".docx"}, + "HTML": {NewHTMLExporter(htmlCleaner), ".html"}, + } + + for name, exp := range exporters { + b.Run(name, func(b *testing.B) { + tempDir := b.TempDir() + exporter := exp.exporter.(interface { + Export(*models.Course, string) error + }) + + b.ResetTimer() + for b.Loop() { + outputPath := filepath.Join(tempDir, "benchmark"+exp.ext) + _ = exporter.Export(course, outputPath) + } + }) + } +} + +// BenchmarkExporters_LargeCourse benchmarks exporters with large course data. +func BenchmarkExporters_LargeCourse(b *testing.B) { + htmlCleaner := services.NewHTMLCleaner() + course := createLargeBenchmarkCourse() + + b.Run("Markdown_Large", func(b *testing.B) { + exporter := NewMarkdownExporter(htmlCleaner) + tempDir := b.TempDir() + + b.ResetTimer() + for b.Loop() { + outputPath := filepath.Join(tempDir, "large.md") + _ = exporter.Export(course, outputPath) + } + }) + + b.Run("Docx_Large", func(b *testing.B) { + exporter := NewDocxExporter(htmlCleaner) + tempDir := b.TempDir() + + b.ResetTimer() + for b.Loop() { + outputPath := filepath.Join(tempDir, "large.docx") + _ = exporter.Export(course, outputPath) + } + }) + + b.Run("HTML_Large", func(b *testing.B) { + exporter := NewHTMLExporter(htmlCleaner) + tempDir := b.TempDir() + + b.ResetTimer() + for b.Loop() { + outputPath := filepath.Join(tempDir, "large.html") + _ = exporter.Export(course, outputPath) + } + }) +} + +// createBenchmarkCourse creates a standard-sized course for benchmarking. +func createBenchmarkCourse() *models.Course { + return &models.Course{ + ShareID: "benchmark-id", + Author: "Benchmark Author", + Course: models.CourseInfo{ + ID: "bench-course", + Title: "Benchmark Course", + Description: "Performance testing course", + NavigationMode: "menu", + Lessons: []models.Lesson{ + { + ID: "lesson1", + Title: "Introduction", + Type: "lesson", + Items: []models.Item{ + { + Type: "text", + Items: []models.SubItem{ + { + Heading: "Welcome", + Paragraph: "

This is a test paragraph with HTML content.

", + }, + }, + }, + { + Type: "list", + Items: []models.SubItem{ + {Paragraph: "Item 1"}, + {Paragraph: "Item 2"}, + {Paragraph: "Item 3"}, + }, + }, + }, + }, + }, + }, + } +} + +// createLargeBenchmarkCourse creates a large course for stress testing. +func createLargeBenchmarkCourse() *models.Course { + lessons := make([]models.Lesson, 50) + for i := 0; i < 50; i++ { + lessons[i] = models.Lesson{ + ID: string(rune(i)), + Title: "Lesson " + string(rune(i)), + Type: "lesson", + Description: "

This is lesson description with formatting.

", + Items: []models.Item{ + { + Type: "text", + Items: []models.SubItem{ + { + Heading: "Section Heading", + Paragraph: "

Content with bold and italic text.

", + }, + }, + }, + { + Type: "list", + Items: []models.SubItem{ + {Paragraph: "Point 1"}, + {Paragraph: "Point 2"}, + {Paragraph: "Point 3"}, + }, + }, + { + Type: "knowledgeCheck", + Items: []models.SubItem{ + { + Title: "Quiz Question", + Answers: []models.Answer{ + {Title: "Answer A", Correct: false}, + {Title: "Answer B", Correct: true}, + {Title: "Answer C", Correct: false}, + }, + Feedback: "Good job!", + }, + }, + }, + }, + } + } + + return &models.Course{ + ShareID: "large-benchmark-id", + Author: "Benchmark Author", + Course: models.CourseInfo{ + ID: "large-bench-course", + Title: "Large Benchmark Course", + Description: "Large performance testing course", + Lessons: lessons, + }, + } +} diff --git a/internal/exporters/example_test.go b/internal/exporters/example_test.go new file mode 100644 index 0000000..a897b80 --- /dev/null +++ b/internal/exporters/example_test.go @@ -0,0 +1,101 @@ +// Package exporters_test provides examples for the exporters package. +package exporters_test + +import ( + "fmt" + "log" + + "github.com/kjanat/articulate-parser/internal/exporters" + "github.com/kjanat/articulate-parser/internal/models" + "github.com/kjanat/articulate-parser/internal/services" +) + +// ExampleNewFactory demonstrates creating an exporter factory. +func ExampleNewFactory() { + htmlCleaner := services.NewHTMLCleaner() + factory := exporters.NewFactory(htmlCleaner) + + // Get supported formats + formats := factory.SupportedFormats() + fmt.Printf("Supported formats: %d\n", len(formats)) + // Output: Supported formats: 6 +} + +// ExampleFactory_CreateExporter demonstrates creating exporters. +func ExampleFactory_CreateExporter() { + htmlCleaner := services.NewHTMLCleaner() + factory := exporters.NewFactory(htmlCleaner) + + // Create a markdown exporter + exporter, err := factory.CreateExporter("markdown") + if err != nil { + log.Fatal(err) + } + + fmt.Printf("Created: %s exporter\n", exporter.SupportedFormat()) + // Output: Created: markdown exporter +} + +// ExampleFactory_CreateExporter_caseInsensitive demonstrates case-insensitive format names. +func ExampleFactory_CreateExporter_caseInsensitive() { + htmlCleaner := services.NewHTMLCleaner() + factory := exporters.NewFactory(htmlCleaner) + + // All these work (case-insensitive) + formats := []string{"MARKDOWN", "Markdown", "markdown", "MD"} + + for _, format := range formats { + exporter, _ := factory.CreateExporter(format) + fmt.Printf("%s -> %s\n", format, exporter.SupportedFormat()) + } + // Output: + // MARKDOWN -> markdown + // Markdown -> markdown + // markdown -> markdown + // MD -> markdown +} + +// ExampleMarkdownExporter_Export demonstrates exporting to Markdown. +func ExampleMarkdownExporter_Export() { + htmlCleaner := services.NewHTMLCleaner() + exporter := exporters.NewMarkdownExporter(htmlCleaner) + + course := &models.Course{ + ShareID: "example-id", + Course: models.CourseInfo{ + Title: "Example Course", + Description: "

Course description

", + }, + } + + // Export to markdown file + err := exporter.Export(course, "output.md") + if err != nil { + log.Fatal(err) + } + + fmt.Println("Export complete") + // Output: Export complete +} + +// ExampleDocxExporter_Export demonstrates exporting to DOCX. +func ExampleDocxExporter_Export() { + htmlCleaner := services.NewHTMLCleaner() + exporter := exporters.NewDocxExporter(htmlCleaner) + + course := &models.Course{ + ShareID: "example-id", + Course: models.CourseInfo{ + Title: "Example Course", + }, + } + + // Export to Word document + err := exporter.Export(course, "output.docx") + if err != nil { + log.Fatal(err) + } + + fmt.Println("DOCX export complete") + // Output: DOCX export complete +} diff --git a/internal/exporters/factory.go b/internal/exporters/factory.go index c68ed79..82f4560 100644 --- a/internal/exporters/factory.go +++ b/internal/exporters/factory.go @@ -33,15 +33,7 @@ func NewFactory(htmlCleaner *services.HTMLCleaner) interfaces.ExporterFactory { } // CreateExporter creates an exporter for the specified format. -// It returns an appropriate exporter implementation based on the format string. -// Format strings are case-insensitive. -// -// Parameters: -// - format: The desired export format (e.g., "markdown", "docx") -// -// Returns: -// - An implementation of the Exporter interface if the format is supported -// - An error if the format is not supported +// Format strings are case-insensitive (e.g., "markdown", "DOCX"). func (f *Factory) CreateExporter(format string) (interfaces.Exporter, error) { switch strings.ToLower(format) { case "markdown", "md": @@ -55,11 +47,8 @@ func (f *Factory) CreateExporter(format string) (interfaces.Exporter, error) { } } -// SupportedFormats returns a list of all supported export formats. -// This includes both primary format names and their aliases. -// -// Returns: -// - A string slice containing all supported format names +// SupportedFormats returns a list of all supported export formats, +// including both primary format names and their aliases. func (f *Factory) SupportedFormats() []string { return []string{"markdown", "md", "docx", "word", "html", "htm"} } diff --git a/internal/exporters/markdown.go b/internal/exporters/markdown.go index fc40501..091637d 100644 --- a/internal/exporters/markdown.go +++ b/internal/exporters/markdown.go @@ -36,16 +36,7 @@ func NewMarkdownExporter(htmlCleaner *services.HTMLCleaner) interfaces.Exporter } } -// Export exports a course to Markdown format. -// It generates a structured Markdown document from the course data -// and writes it to the specified output path. -// -// Parameters: -// - course: The course data model to export -// - outputPath: The file path where the Markdown content will be written -// -// Returns: -// - An error if writing to the output file fails +// Export converts the course to Markdown format and writes it to the output path. func (e *MarkdownExporter) Export(course *models.Course, outputPath string) error { var buf bytes.Buffer @@ -92,23 +83,13 @@ func (e *MarkdownExporter) Export(course *models.Course, outputPath string) erro return os.WriteFile(outputPath, buf.Bytes(), 0644) } -// SupportedFormat returns the format name this exporter supports -// It indicates the file format that the MarkdownExporter can generate. -// -// Returns: -// - A string representing the supported format ("markdown") +// SupportedFormat returns "markdown". func (e *MarkdownExporter) SupportedFormat() string { return "markdown" } -// processItemToMarkdown converts a course item into Markdown format -// and appends it to the provided buffer. It handles different item types -// with appropriate Markdown formatting. -// -// Parameters: -// - buf: The buffer to write the Markdown content to -// - item: The course item to process -// - level: The heading level for the item (determines the number of # characters) +// processItemToMarkdown converts a course item into Markdown format. +// The level parameter determines the heading level (number of # characters). func (e *MarkdownExporter) processItemToMarkdown(buf *bytes.Buffer, item models.Item, level int) { headingPrefix := strings.Repeat("#", level) diff --git a/internal/exporters/output.docx b/internal/exporters/output.docx new file mode 100644 index 0000000..db1d20c Binary files /dev/null and b/internal/exporters/output.docx differ diff --git a/internal/exporters/output.md b/internal/exporters/output.md new file mode 100644 index 0000000..be78d86 --- /dev/null +++ b/internal/exporters/output.md @@ -0,0 +1,12 @@ +# Example Course + +Course description + +## Course Information + +- **Course ID**: +- **Share ID**: example-id +- **Navigation Mode**: + +--- + diff --git a/internal/interfaces/logger.go b/internal/interfaces/logger.go new file mode 100644 index 0000000..7ff33d5 --- /dev/null +++ b/internal/interfaces/logger.go @@ -0,0 +1,27 @@ +// Package interfaces provides the core contracts for the articulate-parser application. +// It defines interfaces for parsing and exporting Articulate Rise courses. +package interfaces + +import "context" + +// Logger defines the interface for structured logging. +// Implementations should provide leveled, structured logging capabilities. +type Logger interface { + // Debug logs a debug-level message with optional key-value pairs. + Debug(msg string, keysAndValues ...any) + + // Info logs an info-level message with optional key-value pairs. + Info(msg string, keysAndValues ...any) + + // Warn logs a warning-level message with optional key-value pairs. + Warn(msg string, keysAndValues ...any) + + // Error logs an error-level message with optional key-value pairs. + Error(msg string, keysAndValues ...any) + + // With returns a new logger with the given key-value pairs added as context. + With(keysAndValues ...any) Logger + + // WithContext returns a new logger with context information. + WithContext(ctx context.Context) Logger +} diff --git a/internal/services/app_test.go b/internal/services/app_test.go index 16ebe5b..4cac724 100644 --- a/internal/services/app_test.go +++ b/internal/services/app_test.go @@ -2,6 +2,7 @@ package services import ( + "context" "errors" "testing" @@ -11,13 +12,13 @@ import ( // MockCourseParser is a mock implementation of interfaces.CourseParser for testing. type MockCourseParser struct { - mockFetchCourse func(uri string) (*models.Course, error) + mockFetchCourse func(ctx context.Context, uri string) (*models.Course, error) mockLoadCourseFromFile func(filePath string) (*models.Course, error) } -func (m *MockCourseParser) FetchCourse(uri string) (*models.Course, error) { +func (m *MockCourseParser) FetchCourse(ctx context.Context, uri string) (*models.Course, error) { if m.mockFetchCourse != nil { - return m.mockFetchCourse(uri) + return m.mockFetchCourse(ctx, uri) } return nil, errors.New("not implemented") } @@ -243,7 +244,7 @@ func TestApp_ProcessCourseFromURI(t *testing.T) { format: "docx", outputPath: "output.docx", setupMocks: func(parser *MockCourseParser, factory *MockExporterFactory, exporter *MockExporter) { - parser.mockFetchCourse = func(uri string) (*models.Course, error) { + parser.mockFetchCourse = func(ctx context.Context, uri string) (*models.Course, error) { if uri != "https://rise.articulate.com/share/test123" { t.Errorf("Expected uri 'https://rise.articulate.com/share/test123', got '%s'", uri) } @@ -271,7 +272,7 @@ func TestApp_ProcessCourseFromURI(t *testing.T) { format: "docx", outputPath: "output.docx", setupMocks: func(parser *MockCourseParser, factory *MockExporterFactory, exporter *MockExporter) { - parser.mockFetchCourse = func(uri string) (*models.Course, error) { + parser.mockFetchCourse = func(ctx context.Context, uri string) (*models.Course, error) { return nil, errors.New("network error") } }, @@ -288,7 +289,7 @@ func TestApp_ProcessCourseFromURI(t *testing.T) { tt.setupMocks(parser, factory, exporter) app := NewApp(parser, factory) - err := app.ProcessCourseFromURI(tt.uri, tt.format, tt.outputPath) + err := app.ProcessCourseFromURI(context.Background(), tt.uri, tt.format, tt.outputPath) if tt.expectedError != "" { if err == nil { diff --git a/internal/services/example_test.go b/internal/services/example_test.go new file mode 100644 index 0000000..808a295 --- /dev/null +++ b/internal/services/example_test.go @@ -0,0 +1,96 @@ +// Package services_test provides examples for the services package. +package services_test + +import ( + "context" + "fmt" + "log" + + "github.com/kjanat/articulate-parser/internal/services" +) + +// ExampleNewArticulateParser demonstrates creating a new parser. +func ExampleNewArticulateParser() { + // Create a no-op logger for this example + logger := services.NewNoOpLogger() + + // Create parser with defaults + parser := services.NewArticulateParser(logger, "", 0) + + fmt.Printf("Parser created: %T\n", parser) + // Output: Parser created: *services.ArticulateParser +} + +// ExampleNewArticulateParser_custom demonstrates creating a parser with custom configuration. +func ExampleNewArticulateParser_custom() { + logger := services.NewNoOpLogger() + + // Create parser with custom base URL and timeout + parser := services.NewArticulateParser( + logger, + "https://custom.articulate.com", + 60_000_000_000, // 60 seconds in nanoseconds + ) + + fmt.Printf("Parser configured: %T\n", parser) + // Output: Parser configured: *services.ArticulateParser +} + +// ExampleArticulateParser_LoadCourseFromFile demonstrates loading a course from a file. +func ExampleArticulateParser_LoadCourseFromFile() { + logger := services.NewNoOpLogger() + parser := services.NewArticulateParser(logger, "", 0) + + // In a real scenario, you'd have an actual file + // This example shows the API usage + _, err := parser.LoadCourseFromFile("course.json") + if err != nil { + log.Printf("Failed to load course: %v", err) + } +} + +// ExampleArticulateParser_FetchCourse demonstrates fetching a course from a URI. +func ExampleArticulateParser_FetchCourse() { + logger := services.NewNoOpLogger() + parser := services.NewArticulateParser(logger, "", 0) + + // Create a context with timeout + ctx := context.Background() + + // In a real scenario, you'd use an actual share URL + _, err := parser.FetchCourse(ctx, "https://rise.articulate.com/share/YOUR_SHARE_ID") + if err != nil { + log.Printf("Failed to fetch course: %v", err) + } +} + +// ExampleHTMLCleaner demonstrates cleaning HTML content. +func ExampleHTMLCleaner() { + cleaner := services.NewHTMLCleaner() + + html := "

This is bold text with entities.

" + clean := cleaner.CleanHTML(html) + + fmt.Println(clean) + // Output: This is bold text with entities. +} + +// ExampleHTMLCleaner_CleanHTML demonstrates complex HTML cleaning. +func ExampleHTMLCleaner_CleanHTML() { + cleaner := services.NewHTMLCleaner() + + html := ` +
+

Title

+

Paragraph with link and & entity.

+ +
+ ` + clean := cleaner.CleanHTML(html) + + fmt.Println(clean) + // Output: Title Paragraph with link and & entity. Item 1 Item 2 +} diff --git a/internal/services/html_cleaner.go b/internal/services/html_cleaner.go index 7c44774..7facd42 100644 --- a/internal/services/html_cleaner.go +++ b/internal/services/html_cleaner.go @@ -24,15 +24,9 @@ func NewHTMLCleaner() *HTMLCleaner { } // CleanHTML removes HTML tags and converts entities, returning clean plain text. -// The function parses the HTML into a node tree and extracts only text content, -// which handles edge cases like script tags or attributes better than regex. -// It handles HTML entities automatically through the parser and normalizes whitespace. -// -// Parameters: -// - htmlStr: The HTML content to clean -// -// Returns: -// - A plain text string with all HTML elements and entities removed/converted +// It parses the HTML into a node tree and extracts only text content, +// skipping script and style tags. HTML entities are automatically handled +// by the parser, and whitespace is normalized. func (h *HTMLCleaner) CleanHTML(htmlStr string) string { // Parse the HTML into a node tree doc, err := html.Parse(strings.NewReader(htmlStr)) diff --git a/internal/services/logger.go b/internal/services/logger.go new file mode 100644 index 0000000..e352541 --- /dev/null +++ b/internal/services/logger.go @@ -0,0 +1,105 @@ +// Package services provides the core functionality for the articulate-parser application. +package services + +import ( + "context" + "log/slog" + "os" + + "github.com/kjanat/articulate-parser/internal/interfaces" +) + +// SlogLogger implements the Logger interface using the standard library's slog package. +type SlogLogger struct { + logger *slog.Logger +} + +// NewSlogLogger creates a new structured logger using slog. +// The level parameter controls the minimum log level (debug, info, warn, error). +func NewSlogLogger(level slog.Level) interfaces.Logger { + opts := &slog.HandlerOptions{ + Level: level, + } + handler := slog.NewJSONHandler(os.Stdout, opts) + return &SlogLogger{ + logger: slog.New(handler), + } +} + +// NewTextLogger creates a new structured logger with human-readable text output. +// Useful for development and debugging. +func NewTextLogger(level slog.Level) interfaces.Logger { + opts := &slog.HandlerOptions{ + Level: level, + } + handler := slog.NewTextHandler(os.Stdout, opts) + return &SlogLogger{ + logger: slog.New(handler), + } +} + +// Debug logs a debug-level message with optional key-value pairs. +func (l *SlogLogger) Debug(msg string, keysAndValues ...any) { + l.logger.Debug(msg, keysAndValues...) +} + +// Info logs an info-level message with optional key-value pairs. +func (l *SlogLogger) Info(msg string, keysAndValues ...any) { + l.logger.Info(msg, keysAndValues...) +} + +// Warn logs a warning-level message with optional key-value pairs. +func (l *SlogLogger) Warn(msg string, keysAndValues ...any) { + l.logger.Warn(msg, keysAndValues...) +} + +// Error logs an error-level message with optional key-value pairs. +func (l *SlogLogger) Error(msg string, keysAndValues ...any) { + l.logger.Error(msg, keysAndValues...) +} + +// With returns a new logger with the given key-value pairs added as context. +func (l *SlogLogger) With(keysAndValues ...any) interfaces.Logger { + return &SlogLogger{ + logger: l.logger.With(keysAndValues...), + } +} + +// WithContext returns a new logger with context information. +// Currently preserves the logger as-is, but can be extended to extract +// trace IDs or other context values in the future. +func (l *SlogLogger) WithContext(ctx context.Context) interfaces.Logger { + // Can be extended to extract trace IDs, request IDs, etc. from context + return l +} + +// NoOpLogger is a logger that discards all log messages. +// Useful for testing or when logging should be disabled. +type NoOpLogger struct{} + +// NewNoOpLogger creates a logger that discards all messages. +func NewNoOpLogger() interfaces.Logger { + return &NoOpLogger{} +} + +// Debug does nothing. +func (l *NoOpLogger) Debug(msg string, keysAndValues ...any) {} + +// Info does nothing. +func (l *NoOpLogger) Info(msg string, keysAndValues ...any) {} + +// Warn does nothing. +func (l *NoOpLogger) Warn(msg string, keysAndValues ...any) {} + +// Error does nothing. +func (l *NoOpLogger) Error(msg string, keysAndValues ...any) {} + +// With returns the same no-op logger. +func (l *NoOpLogger) With(keysAndValues ...any) interfaces.Logger { + return l +} + +// WithContext returns the same no-op logger. +func (l *NoOpLogger) WithContext(ctx context.Context) interfaces.Logger { + return l +} diff --git a/internal/services/logger_bench_test.go b/internal/services/logger_bench_test.go new file mode 100644 index 0000000..dd0803f --- /dev/null +++ b/internal/services/logger_bench_test.go @@ -0,0 +1,96 @@ +// Package services_test provides benchmarks for the logger implementations. +package services + +import ( + "context" + "io" + "log/slog" + "testing" +) + +// BenchmarkSlogLogger_Info benchmarks structured JSON logging. +func BenchmarkSlogLogger_Info(b *testing.B) { + // Create logger that writes to io.Discard to avoid benchmark noise + opts := &slog.HandlerOptions{Level: slog.LevelInfo} + handler := slog.NewJSONHandler(io.Discard, opts) + logger := &SlogLogger{logger: slog.New(handler)} + + b.ResetTimer() + for b.Loop() { + logger.Info("test message", "key1", "value1", "key2", 42, "key3", true) + } +} + +// BenchmarkSlogLogger_Debug benchmarks debug level logging. +func BenchmarkSlogLogger_Debug(b *testing.B) { + opts := &slog.HandlerOptions{Level: slog.LevelDebug} + handler := slog.NewJSONHandler(io.Discard, opts) + logger := &SlogLogger{logger: slog.New(handler)} + + b.ResetTimer() + for b.Loop() { + logger.Debug("debug message", "operation", "test", "duration", 123) + } +} + +// BenchmarkSlogLogger_Error benchmarks error logging. +func BenchmarkSlogLogger_Error(b *testing.B) { + opts := &slog.HandlerOptions{Level: slog.LevelError} + handler := slog.NewJSONHandler(io.Discard, opts) + logger := &SlogLogger{logger: slog.New(handler)} + + b.ResetTimer() + for b.Loop() { + logger.Error("error occurred", "error", "test error", "code", 500) + } +} + +// BenchmarkTextLogger_Info benchmarks text logging. +func BenchmarkTextLogger_Info(b *testing.B) { + opts := &slog.HandlerOptions{Level: slog.LevelInfo} + handler := slog.NewTextHandler(io.Discard, opts) + logger := &SlogLogger{logger: slog.New(handler)} + + b.ResetTimer() + for b.Loop() { + logger.Info("test message", "key1", "value1", "key2", 42) + } +} + +// BenchmarkNoOpLogger benchmarks the no-op logger. +func BenchmarkNoOpLogger(b *testing.B) { + logger := NewNoOpLogger() + + b.ResetTimer() + for b.Loop() { + logger.Info("test message", "key1", "value1", "key2", 42) + logger.Error("error message", "error", "test") + } +} + +// BenchmarkLogger_With benchmarks logger with context. +func BenchmarkLogger_With(b *testing.B) { + opts := &slog.HandlerOptions{Level: slog.LevelInfo} + handler := slog.NewJSONHandler(io.Discard, opts) + logger := &SlogLogger{logger: slog.New(handler)} + + b.ResetTimer() + for b.Loop() { + contextLogger := logger.With("request_id", "123", "user_id", "456") + contextLogger.Info("operation completed") + } +} + +// BenchmarkLogger_WithContext benchmarks logger with Go context. +func BenchmarkLogger_WithContext(b *testing.B) { + opts := &slog.HandlerOptions{Level: slog.LevelInfo} + handler := slog.NewJSONHandler(io.Discard, opts) + logger := &SlogLogger{logger: slog.New(handler)} + ctx := context.Background() + + b.ResetTimer() + for b.Loop() { + contextLogger := logger.WithContext(ctx) + contextLogger.Info("context operation") + } +} diff --git a/internal/services/parser.go b/internal/services/parser.go index b6bd8e8..31fbfba 100644 --- a/internal/services/parser.go +++ b/internal/services/parser.go @@ -24,32 +24,35 @@ type ArticulateParser struct { BaseURL string // Client is the HTTP client used to make requests to the API Client *http.Client + // Logger for structured logging + Logger interfaces.Logger } -// NewArticulateParser creates a new ArticulateParser instance with default settings. -// The default configuration uses the standard Articulate Rise API URL and a -// HTTP client with a 30-second timeout. -func NewArticulateParser() interfaces.CourseParser { +// NewArticulateParser creates a new ArticulateParser instance. +// If baseURL is empty, uses the default Articulate Rise API URL. +// If timeout is zero, uses a 30-second timeout. +func NewArticulateParser(logger interfaces.Logger, baseURL string, timeout time.Duration) interfaces.CourseParser { + if logger == nil { + logger = NewNoOpLogger() + } + if baseURL == "" { + baseURL = "https://rise.articulate.com" + } + if timeout == 0 { + timeout = 30 * time.Second + } return &ArticulateParser{ - BaseURL: "https://rise.articulate.com", + BaseURL: baseURL, Client: &http.Client{ - Timeout: 30 * time.Second, + Timeout: timeout, }, + Logger: logger, } } -// FetchCourse fetches a course from the given URI. -// It extracts the share ID from the URI, constructs an API URL, and fetches the course data. -// The course data is then unmarshalled into a Course model. -// -// Parameters: -// - ctx: Context for cancellation and timeout control -// - uri: The Articulate Rise share URL (e.g., https://rise.articulate.com/share/SHARE_ID) -// -// Returns: -// - A parsed Course model if successful -// - An error if the fetch fails, if the share ID can't be extracted, -// or if the response can't be parsed +// FetchCourse fetches a course from the given URI and returns the parsed course data. +// The URI should be an Articulate Rise share URL (e.g., https://rise.articulate.com/share/SHARE_ID). +// The context can be used for cancellation and timeout control. func (p *ArticulateParser) FetchCourse(ctx context.Context, uri string) (*models.Course, error) { shareID, err := p.extractShareID(uri) if err != nil { @@ -73,7 +76,7 @@ func (p *ArticulateParser) FetchCourse(ctx context.Context, uri string) (*models // connection, but a close error doesn't invalidate the data already consumed. defer func() { if err := resp.Body.Close(); err != nil { - fmt.Fprintf(os.Stderr, "warning: failed to close response body: %v\n", err) + p.Logger.Warn("failed to close response body", "error", err, "url", apiURL) } }() @@ -95,14 +98,6 @@ func (p *ArticulateParser) FetchCourse(ctx context.Context, uri string) (*models } // LoadCourseFromFile loads an Articulate Rise course from a local JSON file. -// The file should contain a valid JSON representation of an Articulate Rise course. -// -// Parameters: -// - filePath: The path to the JSON file containing the course data -// -// Returns: -// - A parsed Course model if successful -// - An error if the file can't be read or the JSON can't be parsed func (p *ArticulateParser) LoadCourseFromFile(filePath string) (*models.Course, error) { data, err := os.ReadFile(filePath) if err != nil { diff --git a/internal/services/parser_bench_test.go b/internal/services/parser_bench_test.go new file mode 100644 index 0000000..5834d5d --- /dev/null +++ b/internal/services/parser_bench_test.go @@ -0,0 +1,216 @@ +// Package services_test provides benchmarks for the parser service. +package services + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/kjanat/articulate-parser/internal/models" +) + +// BenchmarkArticulateParser_FetchCourse benchmarks the FetchCourse method. +func BenchmarkArticulateParser_FetchCourse(b *testing.B) { + testCourse := &models.Course{ + ShareID: "benchmark-id", + Author: "Benchmark Author", + Course: models.CourseInfo{ + ID: "bench-course", + Title: "Benchmark Course", + Description: "Testing performance", + Lessons: []models.Lesson{ + { + ID: "lesson1", + Title: "Lesson 1", + Type: "lesson", + }, + }, + }, + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(testCourse) + })) + defer server.Close() + + parser := &ArticulateParser{ + BaseURL: server.URL, + Client: &http.Client{}, + Logger: NewNoOpLogger(), + } + + b.ResetTimer() + for b.Loop() { + _, err := parser.FetchCourse(context.Background(), "https://rise.articulate.com/share/benchmark-id") + if err != nil { + b.Fatalf("FetchCourse failed: %v", err) + } + } +} + +// BenchmarkArticulateParser_FetchCourse_LargeCourse benchmarks with a large course. +func BenchmarkArticulateParser_FetchCourse_LargeCourse(b *testing.B) { + // Create a large course with many lessons + lessons := make([]models.Lesson, 100) + for i := 0; i < 100; i++ { + lessons[i] = models.Lesson{ + ID: string(rune(i)), + Title: "Lesson " + string(rune(i)), + Type: "lesson", + Description: "This is a test lesson with some description", + Items: []models.Item{ + { + Type: "text", + Items: []models.SubItem{ + { + Heading: "Test Heading", + Paragraph: "Test paragraph content with some text", + }, + }, + }, + }, + } + } + + testCourse := &models.Course{ + ShareID: "large-course-id", + Author: "Benchmark Author", + Course: models.CourseInfo{ + ID: "large-course", + Title: "Large Benchmark Course", + Description: "Testing performance with large course", + Lessons: lessons, + }, + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(testCourse) + })) + defer server.Close() + + parser := &ArticulateParser{ + BaseURL: server.URL, + Client: &http.Client{}, + Logger: NewNoOpLogger(), + } + + b.ResetTimer() + for b.Loop() { + _, err := parser.FetchCourse(context.Background(), "https://rise.articulate.com/share/large-course-id") + if err != nil { + b.Fatalf("FetchCourse failed: %v", err) + } + } +} + +// BenchmarkArticulateParser_LoadCourseFromFile benchmarks loading from file. +func BenchmarkArticulateParser_LoadCourseFromFile(b *testing.B) { + testCourse := &models.Course{ + ShareID: "file-test-id", + Course: models.CourseInfo{ + Title: "File Test Course", + }, + } + + tempDir := b.TempDir() + tempFile := filepath.Join(tempDir, "benchmark.json") + + data, err := json.Marshal(testCourse) + if err != nil { + b.Fatalf("Failed to marshal: %v", err) + } + + if err := os.WriteFile(tempFile, data, 0644); err != nil { + b.Fatalf("Failed to write file: %v", err) + } + + parser := NewArticulateParser(nil, "", 0) + + b.ResetTimer() + for b.Loop() { + _, err := parser.LoadCourseFromFile(tempFile) + if err != nil { + b.Fatalf("LoadCourseFromFile failed: %v", err) + } + } +} + +// BenchmarkArticulateParser_LoadCourseFromFile_Large benchmarks with large file. +func BenchmarkArticulateParser_LoadCourseFromFile_Large(b *testing.B) { + // Create a large course + lessons := make([]models.Lesson, 200) + for i := 0; i < 200; i++ { + lessons[i] = models.Lesson{ + ID: string(rune(i)), + Title: "Lesson " + string(rune(i)), + Type: "lesson", + Items: []models.Item{ + {Type: "text", Items: []models.SubItem{{Heading: "H", Paragraph: "P"}}}, + {Type: "list", Items: []models.SubItem{{Paragraph: "Item 1"}, {Paragraph: "Item 2"}}}, + }, + } + } + + testCourse := &models.Course{ + ShareID: "large-file-id", + Course: models.CourseInfo{ + Title: "Large File Course", + Lessons: lessons, + }, + } + + tempDir := b.TempDir() + tempFile := filepath.Join(tempDir, "large-benchmark.json") + + data, err := json.Marshal(testCourse) + if err != nil { + b.Fatalf("Failed to marshal: %v", err) + } + + if err := os.WriteFile(tempFile, data, 0644); err != nil { + b.Fatalf("Failed to write file: %v", err) + } + + parser := NewArticulateParser(nil, "", 0) + + b.ResetTimer() + for b.Loop() { + _, err := parser.LoadCourseFromFile(tempFile) + if err != nil { + b.Fatalf("LoadCourseFromFile failed: %v", err) + } + } +} + +// BenchmarkArticulateParser_ExtractShareID benchmarks share ID extraction. +func BenchmarkArticulateParser_ExtractShareID(b *testing.B) { + parser := &ArticulateParser{} + uri := "https://rise.articulate.com/share/N_APNg40Vr2CSH2xNz-ZLATM5kNviDIO#/" + + b.ResetTimer() + for b.Loop() { + _, err := parser.extractShareID(uri) + if err != nil { + b.Fatalf("extractShareID failed: %v", err) + } + } +} + +// BenchmarkArticulateParser_BuildAPIURL benchmarks API URL building. +func BenchmarkArticulateParser_BuildAPIURL(b *testing.B) { + parser := &ArticulateParser{ + BaseURL: "https://rise.articulate.com", + } + shareID := "test-share-id-12345" + + b.ResetTimer() + for b.Loop() { + _ = parser.buildAPIURL(shareID) + } +} diff --git a/internal/services/parser_context_test.go b/internal/services/parser_context_test.go new file mode 100644 index 0000000..4e0ee45 --- /dev/null +++ b/internal/services/parser_context_test.go @@ -0,0 +1,285 @@ +// Package services_test provides context-aware tests for the parser service. +package services + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/kjanat/articulate-parser/internal/models" +) + +// TestArticulateParser_FetchCourse_ContextCancellation tests that FetchCourse +// respects context cancellation. +func TestArticulateParser_FetchCourse_ContextCancellation(t *testing.T) { + // Create a server that delays response + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Sleep to give time for context cancellation + time.Sleep(100 * time.Millisecond) + + testCourse := &models.Course{ + ShareID: "test-id", + Course: models.CourseInfo{ + Title: "Test Course", + }, + } + json.NewEncoder(w).Encode(testCourse) + })) + defer server.Close() + + parser := &ArticulateParser{ + BaseURL: server.URL, + Client: &http.Client{ + Timeout: 5 * time.Second, + }, + Logger: NewNoOpLogger(), + } + + // Create a context that we'll cancel immediately + ctx, cancel := context.WithCancel(context.Background()) + cancel() // Cancel immediately + + _, err := parser.FetchCourse(ctx, "https://rise.articulate.com/share/test-id") + + // Should get a context cancellation error + if err == nil { + t.Fatal("Expected error due to context cancellation, got nil") + } + + if !strings.Contains(err.Error(), "context canceled") { + t.Errorf("Expected context cancellation error, got: %v", err) + } +} + +// TestArticulateParser_FetchCourse_ContextTimeout tests that FetchCourse +// respects context timeout. +func TestArticulateParser_FetchCourse_ContextTimeout(t *testing.T) { + // Create a server that delays response longer than timeout + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Sleep longer than the context timeout + time.Sleep(200 * time.Millisecond) + + testCourse := &models.Course{ + ShareID: "test-id", + Course: models.CourseInfo{ + Title: "Test Course", + }, + } + json.NewEncoder(w).Encode(testCourse) + })) + defer server.Close() + + parser := &ArticulateParser{ + BaseURL: server.URL, + Client: &http.Client{ + Timeout: 5 * time.Second, + }, + Logger: NewNoOpLogger(), + } + + // Create a context with a very short timeout + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + defer cancel() + + _, err := parser.FetchCourse(ctx, "https://rise.articulate.com/share/test-id") + + // Should get a context deadline exceeded error + if err == nil { + t.Fatal("Expected error due to context timeout, got nil") + } + + if !strings.Contains(err.Error(), "deadline exceeded") && + !strings.Contains(err.Error(), "context deadline exceeded") { + t.Errorf("Expected context timeout error, got: %v", err) + } +} + +// TestArticulateParser_FetchCourse_ContextDeadline tests that FetchCourse +// respects context deadline. +func TestArticulateParser_FetchCourse_ContextDeadline(t *testing.T) { + // Create a server that delays response + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + time.Sleep(150 * time.Millisecond) + + testCourse := &models.Course{ + ShareID: "test-id", + Course: models.CourseInfo{ + Title: "Test Course", + }, + } + json.NewEncoder(w).Encode(testCourse) + })) + defer server.Close() + + parser := &ArticulateParser{ + BaseURL: server.URL, + Client: &http.Client{ + Timeout: 5 * time.Second, + }, + Logger: NewNoOpLogger(), + } + + // Create a context with a deadline in the past + ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(10*time.Millisecond)) + defer cancel() + + _, err := parser.FetchCourse(ctx, "https://rise.articulate.com/share/test-id") + + // Should get a deadline exceeded error + if err == nil { + t.Fatal("Expected error due to context deadline, got nil") + } + + if !strings.Contains(err.Error(), "deadline exceeded") && + !strings.Contains(err.Error(), "context deadline exceeded") { + t.Errorf("Expected deadline exceeded error, got: %v", err) + } +} + +// TestArticulateParser_FetchCourse_ContextSuccess tests that FetchCourse +// succeeds when context is not cancelled. +func TestArticulateParser_FetchCourse_ContextSuccess(t *testing.T) { + testCourse := &models.Course{ + ShareID: "test-id", + Course: models.CourseInfo{ + Title: "Test Course", + }, + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Respond quickly + json.NewEncoder(w).Encode(testCourse) + })) + defer server.Close() + + parser := &ArticulateParser{ + BaseURL: server.URL, + Client: &http.Client{ + Timeout: 5 * time.Second, + }, + Logger: NewNoOpLogger(), + } + + // Create a context with generous timeout + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + course, err := parser.FetchCourse(ctx, "https://rise.articulate.com/share/test-id") + + if err != nil { + t.Fatalf("Expected no error, got: %v", err) + } + + if course == nil { + t.Fatal("Expected course, got nil") + } + + if course.Course.Title != testCourse.Course.Title { + t.Errorf("Expected title '%s', got '%s'", testCourse.Course.Title, course.Course.Title) + } +} + +// TestArticulateParser_FetchCourse_CancellationDuringRequest tests cancellation +// during an in-flight request. +func TestArticulateParser_FetchCourse_CancellationDuringRequest(t *testing.T) { + requestStarted := make(chan bool) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestStarted <- true + // Keep the handler running to simulate slow response + time.Sleep(300 * time.Millisecond) + + testCourse := &models.Course{ + ShareID: "test-id", + } + json.NewEncoder(w).Encode(testCourse) + })) + defer server.Close() + + parser := &ArticulateParser{ + BaseURL: server.URL, + Client: &http.Client{ + Timeout: 5 * time.Second, + }, + Logger: NewNoOpLogger(), + } + + ctx, cancel := context.WithCancel(context.Background()) + + // Start the request in a goroutine + errChan := make(chan error, 1) + go func() { + _, err := parser.FetchCourse(ctx, "https://rise.articulate.com/share/test-id") + errChan <- err + }() + + // Wait for request to start + <-requestStarted + + // Cancel after request has started + cancel() + + // Get the error + err := <-errChan + + if err == nil { + t.Fatal("Expected error due to context cancellation, got nil") + } + + // Should contain context canceled somewhere in the error chain + if !strings.Contains(err.Error(), "context canceled") { + t.Errorf("Expected context canceled error, got: %v", err) + } +} + +// TestArticulateParser_FetchCourse_MultipleTimeouts tests behavior with +// multiple concurrent requests and timeouts. +func TestArticulateParser_FetchCourse_MultipleTimeouts(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + time.Sleep(100 * time.Millisecond) + testCourse := &models.Course{ShareID: "test"} + json.NewEncoder(w).Encode(testCourse) + })) + defer server.Close() + + parser := &ArticulateParser{ + BaseURL: server.URL, + Client: &http.Client{ + Timeout: 5 * time.Second, + }, + Logger: NewNoOpLogger(), + } + + // Launch multiple requests with different timeouts + tests := []struct { + name string + timeout time.Duration + shouldSucceed bool + }{ + {"very short timeout", 10 * time.Millisecond, false}, + {"short timeout", 50 * time.Millisecond, false}, + {"adequate timeout", 500 * time.Millisecond, true}, + {"long timeout", 2 * time.Second, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), tt.timeout) + defer cancel() + + _, err := parser.FetchCourse(ctx, "https://rise.articulate.com/share/test-id") + + if tt.shouldSucceed && err != nil { + t.Errorf("Expected success with timeout %v, got error: %v", tt.timeout, err) + } + + if !tt.shouldSucceed && err == nil { + t.Errorf("Expected timeout error with timeout %v, got success", tt.timeout) + } + }) + } +} diff --git a/internal/services/parser_test.go b/internal/services/parser_test.go index 70b34ed..4a60ad3 100644 --- a/internal/services/parser_test.go +++ b/internal/services/parser_test.go @@ -2,6 +2,7 @@ package services import ( + "context" "encoding/json" "net/http" "net/http/httptest" @@ -16,7 +17,7 @@ import ( // TestNewArticulateParser tests the NewArticulateParser constructor. func TestNewArticulateParser(t *testing.T) { - parser := NewArticulateParser() + parser := NewArticulateParser(nil, "", 0) if parser == nil { t.Fatal("NewArticulateParser() returned nil") @@ -112,7 +113,7 @@ func TestArticulateParser_FetchCourse(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - course, err := parser.FetchCourse(tt.uri) + course, err := parser.FetchCourse(context.Background(), tt.uri) if tt.expectedError != "" { if err == nil { @@ -146,7 +147,7 @@ func TestArticulateParser_FetchCourse_NetworkError(t *testing.T) { }, } - _, err := parser.FetchCourse("https://rise.articulate.com/share/test-share-id") + _, err := parser.FetchCourse(context.Background(), "https://rise.articulate.com/share/test-share-id") if err == nil { t.Fatal("Expected network error, got nil") } @@ -175,7 +176,7 @@ func TestArticulateParser_FetchCourse_InvalidJSON(t *testing.T) { }, } - _, err := parser.FetchCourse("https://rise.articulate.com/share/test-share-id") + _, err := parser.FetchCourse(context.Background(), "https://rise.articulate.com/share/test-share-id") if err == nil { t.Fatal("Expected JSON parsing error, got nil") } @@ -212,7 +213,7 @@ func TestArticulateParser_LoadCourseFromFile(t *testing.T) { t.Fatalf("Failed to write test file: %v", err) } - parser := NewArticulateParser() + parser := NewArticulateParser(nil, "", 0) tests := []struct { name string @@ -271,7 +272,7 @@ func TestArticulateParser_LoadCourseFromFile_InvalidJSON(t *testing.T) { t.Fatalf("Failed to write test file: %v", err) } - parser := NewArticulateParser() + parser := NewArticulateParser(nil, "", 0) _, err := parser.LoadCourseFromFile(tempFile) if err == nil { diff --git a/main.go b/main.go index 9741c21..50ff209 100644 --- a/main.go +++ b/main.go @@ -4,12 +4,14 @@ package main import ( + "context" "fmt" - "log" "os" "strings" + "github.com/kjanat/articulate-parser/internal/config" "github.com/kjanat/articulate-parser/internal/exporters" + "github.com/kjanat/articulate-parser/internal/interfaces" "github.com/kjanat/articulate-parser/internal/services" "github.com/kjanat/articulate-parser/internal/version" ) @@ -24,9 +26,19 @@ func main() { // run contains the main application logic and returns an exit code. // This function is testable as it doesn't call os.Exit directly. func run(args []string) int { - // Dependency injection setup + // Load configuration + cfg := config.Load() + + // Dependency injection setup with configuration + var logger interfaces.Logger + if cfg.LogFormat == "json" { + logger = services.NewSlogLogger(cfg.LogLevel) + } else { + logger = services.NewTextLogger(cfg.LogLevel) + } + htmlCleaner := services.NewHTMLCleaner() - parser := services.NewArticulateParser() + parser := services.NewArticulateParser(logger, cfg.BaseURL, cfg.RequestTimeout) exporterFactory := exporters.NewFactory(htmlCleaner) app := services.NewApp(parser, exporterFactory) @@ -58,17 +70,17 @@ func run(args []string) int { // Determine if source is a URI or file path if isURI(source) { - err = app.ProcessCourseFromURI(source, format, output) + err = app.ProcessCourseFromURI(context.Background(), source, format, output) } else { err = app.ProcessCourseFromFile(source, format, output) } if err != nil { - log.Printf("Error processing course: %v", err) + logger.Error("failed to process course", "error", err, "source", source) return 1 } - fmt.Printf("Successfully exported course to %s\n", output) + logger.Info("successfully exported course", "output", output, "format", format) return 0 } diff --git a/main_test.go b/main_test.go index 5c124da..b417ee4 100644 --- a/main_test.go +++ b/main_test.go @@ -3,7 +3,6 @@ package main import ( "bytes" - "fmt" "io" "log" "os" @@ -297,10 +296,10 @@ func TestRunWithInvalidFile(t *testing.T) { t.Errorf("Expected exit code 1 for non-existent file, got %d", exitCode) } - // Should have error output - errorOutput := stderrBuf.String() - if !strings.Contains(errorOutput, "Error processing course") { - t.Errorf("Expected error message about processing course, got: %s", errorOutput) + // Should have error output in structured log format + output := stdoutBuf.String() + if !strings.Contains(output, "level=ERROR") && !strings.Contains(output, "failed to process course") { + t.Errorf("Expected error message about processing course, got: %s", output) } } @@ -350,10 +349,10 @@ func TestRunWithInvalidURI(t *testing.T) { t.Errorf("Expected failure (exit code 1) for invalid URI, got %d", exitCode) } - // Should have error output - errorOutput := stderrBuf.String() - if !strings.Contains(errorOutput, "Error processing course") { - t.Errorf("Expected error message about processing course, got: %s", errorOutput) + // Should have error output in structured log format + output := stdoutBuf.String() + if !strings.Contains(output, "level=ERROR") && !strings.Contains(output, "failed to process course") { + t.Errorf("Expected error message about processing course, got: %s", output) } } @@ -436,10 +435,9 @@ func TestRunWithValidJSONFile(t *testing.T) { t.Errorf("Expected successful execution (exit code 0), got %d", exitCode) } - // Verify success message - expectedMsg := fmt.Sprintf("Successfully exported course to %s", outputFile) - if !strings.Contains(output, expectedMsg) { - t.Errorf("Expected success message '%s' in output, got: %s", expectedMsg, output) + // Verify success message in structured log format + if !strings.Contains(output, "level=INFO") || !strings.Contains(output, "successfully exported course") { + t.Errorf("Expected success message in output, got: %s", output) } // Verify output file was created