mirror of
https://github.com/kjanat/articulate-parser.git
synced 2026-01-16 09:02: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:
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user