mirror of
https://github.com/kjanat/articulate-parser.git
synced 2026-01-16 21:42:11 +01:00
refactor(core)!: Add context, config, and structured logging
Introduces `context.Context` to the `FetchCourse` method and its call chain, allowing for cancellable network requests and timeouts. This improves application robustness when fetching remote course data. A new configuration package centralizes application settings, loading them from environment variables with sensible defaults for base URL, request timeout, and logging. Standard `log` and `fmt` calls are replaced with a structured logging system built on `slog`, supporting both JSON and human-readable text formats. This change also includes: - Extensive benchmarks and example tests. - Simplified Go doc comments across several packages. BREAKING CHANGE: The `NewArticulateParser` constructor signature has been updated to accept a logger, base URL, and timeout, which are now supplied via the new configuration system.
This commit is contained in:
77
internal/config/config.go
Normal file
77
internal/config/config.go
Normal file
@ -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
|
||||
}
|
||||
}
|
||||
118
internal/config/config_test.go
Normal file
118
internal/config/config_test.go
Normal file
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
201
internal/exporters/bench_test.go
Normal file
201
internal/exporters/bench_test.go
Normal file
@ -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: "<p>This is a test paragraph with <strong>HTML</strong> content.</p>",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
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: "<p>This is lesson description with <em>formatting</em>.</p>",
|
||||
Items: []models.Item{
|
||||
{
|
||||
Type: "text",
|
||||
Items: []models.SubItem{
|
||||
{
|
||||
Heading: "Section Heading",
|
||||
Paragraph: "<p>Content with <strong>bold</strong> and <em>italic</em> text.</p>",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
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,
|
||||
},
|
||||
}
|
||||
}
|
||||
101
internal/exporters/example_test.go
Normal file
101
internal/exporters/example_test.go
Normal file
@ -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: "<p>Course description</p>",
|
||||
},
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
@ -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"}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
BIN
internal/exporters/output.docx
Normal file
BIN
internal/exporters/output.docx
Normal file
Binary file not shown.
12
internal/exporters/output.md
Normal file
12
internal/exporters/output.md
Normal file
@ -0,0 +1,12 @@
|
||||
# Example Course
|
||||
|
||||
Course description
|
||||
|
||||
## Course Information
|
||||
|
||||
- **Course ID**:
|
||||
- **Share ID**: example-id
|
||||
- **Navigation Mode**:
|
||||
|
||||
---
|
||||
|
||||
27
internal/interfaces/logger.go
Normal file
27
internal/interfaces/logger.go
Normal file
@ -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
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
96
internal/services/example_test.go
Normal file
96
internal/services/example_test.go
Normal file
@ -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 := "<p>This is <strong>bold</strong> text with entities.</p>"
|
||||
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 := `
|
||||
<div>
|
||||
<h1>Title</h1>
|
||||
<p>Paragraph with <a href="#">link</a> and & entity.</p>
|
||||
<ul>
|
||||
<li>Item 1</li>
|
||||
<li>Item 2</li>
|
||||
</ul>
|
||||
</div>
|
||||
`
|
||||
clean := cleaner.CleanHTML(html)
|
||||
|
||||
fmt.Println(clean)
|
||||
// Output: Title Paragraph with link and & entity. Item 1 Item 2
|
||||
}
|
||||
@ -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))
|
||||
|
||||
105
internal/services/logger.go
Normal file
105
internal/services/logger.go
Normal file
@ -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
|
||||
}
|
||||
96
internal/services/logger_bench_test.go
Normal file
96
internal/services/logger_bench_test.go
Normal file
@ -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")
|
||||
}
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
216
internal/services/parser_bench_test.go
Normal file
216
internal/services/parser_bench_test.go
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
285
internal/services/parser_context_test.go
Normal file
285
internal/services/parser_context_test.go
Normal file
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
24
main.go
24
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
|
||||
}
|
||||
|
||||
|
||||
24
main_test.go
24
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
|
||||
|
||||
Reference in New Issue
Block a user