mirror of
https://github.com/kjanat/articulate-parser.git
synced 2026-01-16 13:42:10 +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:
@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user