mirror of
https://github.com/kjanat/articulate-parser.git
synced 2026-01-16 13:02:08 +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.
|
// CreateExporter creates an exporter for the specified format.
|
||||||
// It returns an appropriate exporter implementation based on the format string.
|
// Format strings are case-insensitive (e.g., "markdown", "DOCX").
|
||||||
// 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
|
|
||||||
func (f *Factory) CreateExporter(format string) (interfaces.Exporter, error) {
|
func (f *Factory) CreateExporter(format string) (interfaces.Exporter, error) {
|
||||||
switch strings.ToLower(format) {
|
switch strings.ToLower(format) {
|
||||||
case "markdown", "md":
|
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.
|
// SupportedFormats returns a list of all supported export formats,
|
||||||
// This includes both primary format names and their aliases.
|
// including both primary format names and their aliases.
|
||||||
//
|
|
||||||
// Returns:
|
|
||||||
// - A string slice containing all supported format names
|
|
||||||
func (f *Factory) SupportedFormats() []string {
|
func (f *Factory) SupportedFormats() []string {
|
||||||
return []string{"markdown", "md", "docx", "word", "html", "htm"}
|
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.
|
// Export converts the course to Markdown format and writes it to the output path.
|
||||||
// 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
|
|
||||||
func (e *MarkdownExporter) Export(course *models.Course, outputPath string) error {
|
func (e *MarkdownExporter) Export(course *models.Course, outputPath string) error {
|
||||||
var buf bytes.Buffer
|
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)
|
return os.WriteFile(outputPath, buf.Bytes(), 0644)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SupportedFormat returns the format name this exporter supports
|
// SupportedFormat returns "markdown".
|
||||||
// It indicates the file format that the MarkdownExporter can generate.
|
|
||||||
//
|
|
||||||
// Returns:
|
|
||||||
// - A string representing the supported format ("markdown")
|
|
||||||
func (e *MarkdownExporter) SupportedFormat() string {
|
func (e *MarkdownExporter) SupportedFormat() string {
|
||||||
return "markdown"
|
return "markdown"
|
||||||
}
|
}
|
||||||
|
|
||||||
// processItemToMarkdown converts a course item into Markdown format
|
// processItemToMarkdown converts a course item into Markdown format.
|
||||||
// and appends it to the provided buffer. It handles different item types
|
// The level parameter determines the heading level (number of # characters).
|
||||||
// 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)
|
|
||||||
func (e *MarkdownExporter) processItemToMarkdown(buf *bytes.Buffer, item models.Item, level int) {
|
func (e *MarkdownExporter) processItemToMarkdown(buf *bytes.Buffer, item models.Item, level int) {
|
||||||
headingPrefix := strings.Repeat("#", level)
|
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
|
package services
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@ -11,13 +12,13 @@ import (
|
|||||||
|
|
||||||
// MockCourseParser is a mock implementation of interfaces.CourseParser for testing.
|
// MockCourseParser is a mock implementation of interfaces.CourseParser for testing.
|
||||||
type MockCourseParser struct {
|
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)
|
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 {
|
if m.mockFetchCourse != nil {
|
||||||
return m.mockFetchCourse(uri)
|
return m.mockFetchCourse(ctx, uri)
|
||||||
}
|
}
|
||||||
return nil, errors.New("not implemented")
|
return nil, errors.New("not implemented")
|
||||||
}
|
}
|
||||||
@ -243,7 +244,7 @@ func TestApp_ProcessCourseFromURI(t *testing.T) {
|
|||||||
format: "docx",
|
format: "docx",
|
||||||
outputPath: "output.docx",
|
outputPath: "output.docx",
|
||||||
setupMocks: func(parser *MockCourseParser, factory *MockExporterFactory, exporter *MockExporter) {
|
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" {
|
if uri != "https://rise.articulate.com/share/test123" {
|
||||||
t.Errorf("Expected uri 'https://rise.articulate.com/share/test123', got '%s'", uri)
|
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",
|
format: "docx",
|
||||||
outputPath: "output.docx",
|
outputPath: "output.docx",
|
||||||
setupMocks: func(parser *MockCourseParser, factory *MockExporterFactory, exporter *MockExporter) {
|
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")
|
return nil, errors.New("network error")
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -288,7 +289,7 @@ func TestApp_ProcessCourseFromURI(t *testing.T) {
|
|||||||
tt.setupMocks(parser, factory, exporter)
|
tt.setupMocks(parser, factory, exporter)
|
||||||
|
|
||||||
app := NewApp(parser, factory)
|
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 tt.expectedError != "" {
|
||||||
if err == nil {
|
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.
|
// 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,
|
// It parses the HTML into a node tree and extracts only text content,
|
||||||
// which handles edge cases like script tags or attributes better than regex.
|
// skipping script and style tags. HTML entities are automatically handled
|
||||||
// It handles HTML entities automatically through the parser and normalizes whitespace.
|
// by the parser, and whitespace is normalized.
|
||||||
//
|
|
||||||
// Parameters:
|
|
||||||
// - htmlStr: The HTML content to clean
|
|
||||||
//
|
|
||||||
// Returns:
|
|
||||||
// - A plain text string with all HTML elements and entities removed/converted
|
|
||||||
func (h *HTMLCleaner) CleanHTML(htmlStr string) string {
|
func (h *HTMLCleaner) CleanHTML(htmlStr string) string {
|
||||||
// Parse the HTML into a node tree
|
// Parse the HTML into a node tree
|
||||||
doc, err := html.Parse(strings.NewReader(htmlStr))
|
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
|
BaseURL string
|
||||||
// Client is the HTTP client used to make requests to the API
|
// Client is the HTTP client used to make requests to the API
|
||||||
Client *http.Client
|
Client *http.Client
|
||||||
|
// Logger for structured logging
|
||||||
|
Logger interfaces.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewArticulateParser creates a new ArticulateParser instance with default settings.
|
// NewArticulateParser creates a new ArticulateParser instance.
|
||||||
// The default configuration uses the standard Articulate Rise API URL and a
|
// If baseURL is empty, uses the default Articulate Rise API URL.
|
||||||
// HTTP client with a 30-second timeout.
|
// If timeout is zero, uses a 30-second timeout.
|
||||||
func NewArticulateParser() interfaces.CourseParser {
|
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{
|
return &ArticulateParser{
|
||||||
BaseURL: "https://rise.articulate.com",
|
BaseURL: baseURL,
|
||||||
Client: &http.Client{
|
Client: &http.Client{
|
||||||
Timeout: 30 * time.Second,
|
Timeout: timeout,
|
||||||
},
|
},
|
||||||
|
Logger: logger,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// FetchCourse fetches a course from the given URI.
|
// FetchCourse fetches a course from the given URI and returns the parsed course data.
|
||||||
// It extracts the share ID from the URI, constructs an API URL, and fetches the course data.
|
// The URI should be an Articulate Rise share URL (e.g., https://rise.articulate.com/share/SHARE_ID).
|
||||||
// The course data is then unmarshalled into a Course model.
|
// The context can be used for cancellation and timeout control.
|
||||||
//
|
|
||||||
// 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
|
|
||||||
func (p *ArticulateParser) FetchCourse(ctx context.Context, uri string) (*models.Course, error) {
|
func (p *ArticulateParser) FetchCourse(ctx context.Context, uri string) (*models.Course, error) {
|
||||||
shareID, err := p.extractShareID(uri)
|
shareID, err := p.extractShareID(uri)
|
||||||
if err != nil {
|
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.
|
// connection, but a close error doesn't invalidate the data already consumed.
|
||||||
defer func() {
|
defer func() {
|
||||||
if err := resp.Body.Close(); err != nil {
|
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.
|
// 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) {
|
func (p *ArticulateParser) LoadCourseFromFile(filePath string) (*models.Course, error) {
|
||||||
data, err := os.ReadFile(filePath)
|
data, err := os.ReadFile(filePath)
|
||||||
if err != nil {
|
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
|
package services
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
@ -16,7 +17,7 @@ import (
|
|||||||
|
|
||||||
// TestNewArticulateParser tests the NewArticulateParser constructor.
|
// TestNewArticulateParser tests the NewArticulateParser constructor.
|
||||||
func TestNewArticulateParser(t *testing.T) {
|
func TestNewArticulateParser(t *testing.T) {
|
||||||
parser := NewArticulateParser()
|
parser := NewArticulateParser(nil, "", 0)
|
||||||
|
|
||||||
if parser == nil {
|
if parser == nil {
|
||||||
t.Fatal("NewArticulateParser() returned nil")
|
t.Fatal("NewArticulateParser() returned nil")
|
||||||
@ -112,7 +113,7 @@ func TestArticulateParser_FetchCourse(t *testing.T) {
|
|||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
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 tt.expectedError != "" {
|
||||||
if err == nil {
|
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 {
|
if err == nil {
|
||||||
t.Fatal("Expected network error, got 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 {
|
if err == nil {
|
||||||
t.Fatal("Expected JSON parsing error, got 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)
|
t.Fatalf("Failed to write test file: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
parser := NewArticulateParser()
|
parser := NewArticulateParser(nil, "", 0)
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
@ -271,7 +272,7 @@ func TestArticulateParser_LoadCourseFromFile_InvalidJSON(t *testing.T) {
|
|||||||
t.Fatalf("Failed to write test file: %v", err)
|
t.Fatalf("Failed to write test file: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
parser := NewArticulateParser()
|
parser := NewArticulateParser(nil, "", 0)
|
||||||
_, err := parser.LoadCourseFromFile(tempFile)
|
_, err := parser.LoadCourseFromFile(tempFile)
|
||||||
|
|
||||||
if err == nil {
|
if err == nil {
|
||||||
|
|||||||
24
main.go
24
main.go
@ -4,12 +4,14 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/kjanat/articulate-parser/internal/config"
|
||||||
"github.com/kjanat/articulate-parser/internal/exporters"
|
"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/services"
|
||||||
"github.com/kjanat/articulate-parser/internal/version"
|
"github.com/kjanat/articulate-parser/internal/version"
|
||||||
)
|
)
|
||||||
@ -24,9 +26,19 @@ func main() {
|
|||||||
// run contains the main application logic and returns an exit code.
|
// run contains the main application logic and returns an exit code.
|
||||||
// This function is testable as it doesn't call os.Exit directly.
|
// This function is testable as it doesn't call os.Exit directly.
|
||||||
func run(args []string) int {
|
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()
|
htmlCleaner := services.NewHTMLCleaner()
|
||||||
parser := services.NewArticulateParser()
|
parser := services.NewArticulateParser(logger, cfg.BaseURL, cfg.RequestTimeout)
|
||||||
exporterFactory := exporters.NewFactory(htmlCleaner)
|
exporterFactory := exporters.NewFactory(htmlCleaner)
|
||||||
app := services.NewApp(parser, exporterFactory)
|
app := services.NewApp(parser, exporterFactory)
|
||||||
|
|
||||||
@ -58,17 +70,17 @@ func run(args []string) int {
|
|||||||
|
|
||||||
// Determine if source is a URI or file path
|
// Determine if source is a URI or file path
|
||||||
if isURI(source) {
|
if isURI(source) {
|
||||||
err = app.ProcessCourseFromURI(source, format, output)
|
err = app.ProcessCourseFromURI(context.Background(), source, format, output)
|
||||||
} else {
|
} else {
|
||||||
err = app.ProcessCourseFromFile(source, format, output)
|
err = app.ProcessCourseFromFile(source, format, output)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Error processing course: %v", err)
|
logger.Error("failed to process course", "error", err, "source", source)
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("Successfully exported course to %s\n", output)
|
logger.Info("successfully exported course", "output", output, "format", format)
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
24
main_test.go
24
main_test.go
@ -3,7 +3,6 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"fmt"
|
|
||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
@ -297,10 +296,10 @@ func TestRunWithInvalidFile(t *testing.T) {
|
|||||||
t.Errorf("Expected exit code 1 for non-existent file, got %d", exitCode)
|
t.Errorf("Expected exit code 1 for non-existent file, got %d", exitCode)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Should have error output
|
// Should have error output in structured log format
|
||||||
errorOutput := stderrBuf.String()
|
output := stdoutBuf.String()
|
||||||
if !strings.Contains(errorOutput, "Error processing course") {
|
if !strings.Contains(output, "level=ERROR") && !strings.Contains(output, "failed to process course") {
|
||||||
t.Errorf("Expected error message about processing course, got: %s", errorOutput)
|
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)
|
t.Errorf("Expected failure (exit code 1) for invalid URI, got %d", exitCode)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Should have error output
|
// Should have error output in structured log format
|
||||||
errorOutput := stderrBuf.String()
|
output := stdoutBuf.String()
|
||||||
if !strings.Contains(errorOutput, "Error processing course") {
|
if !strings.Contains(output, "level=ERROR") && !strings.Contains(output, "failed to process course") {
|
||||||
t.Errorf("Expected error message about processing course, got: %s", errorOutput)
|
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)
|
t.Errorf("Expected successful execution (exit code 0), got %d", exitCode)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify success message
|
// Verify success message in structured log format
|
||||||
expectedMsg := fmt.Sprintf("Successfully exported course to %s", outputFile)
|
if !strings.Contains(output, "level=INFO") || !strings.Contains(output, "successfully exported course") {
|
||||||
if !strings.Contains(output, expectedMsg) {
|
t.Errorf("Expected success message in output, got: %s", output)
|
||||||
t.Errorf("Expected success message '%s' in output, got: %s", expectedMsg, output)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify output file was created
|
// Verify output file was created
|
||||||
|
|||||||
Reference in New Issue
Block a user