Files
articulate-parser/internal/services/parser_test.go
Kaj Kowalski 59f2de9d22 chore(build): introduce go-task for project automation
Adds a comprehensive Taskfile.yml to centralize all project scripts for building, testing, linting, and Docker image management.

The GitHub Actions CI workflow is refactored to utilize these `task` commands, resulting in a cleaner, more readable, and maintainable configuration. This approach ensures consistency between local development and CI environments.
2025-11-06 03:52:21 +01:00

439 lines
12 KiB
Go

// Package services_test provides tests for the parser service.
package services
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/kjanat/articulate-parser/internal/models"
)
// TestNewArticulateParser tests the NewArticulateParser constructor.
func TestNewArticulateParser(t *testing.T) {
parser := NewArticulateParser()
if parser == nil {
t.Fatal("NewArticulateParser() returned nil")
}
// Type assertion to check internal structure
articulateParser, ok := parser.(*ArticulateParser)
if !ok {
t.Fatal("NewArticulateParser() returned wrong type")
}
expectedBaseURL := "https://rise.articulate.com"
if articulateParser.BaseURL != expectedBaseURL {
t.Errorf("Expected BaseURL '%s', got '%s'", expectedBaseURL, articulateParser.BaseURL)
}
if articulateParser.Client == nil {
t.Error("Client should not be nil")
}
expectedTimeout := 30 * time.Second
if articulateParser.Client.Timeout != expectedTimeout {
t.Errorf("Expected timeout %v, got %v", expectedTimeout, articulateParser.Client.Timeout)
}
}
// TestArticulateParser_FetchCourse tests the FetchCourse method.
func TestArticulateParser_FetchCourse(t *testing.T) {
// Create a test course object
testCourse := &models.Course{
ShareID: "test-share-id",
Author: "Test Author",
Course: models.CourseInfo{
ID: "test-course-id",
Title: "Test Course",
Description: "Test Description",
},
}
// Create test server
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Check request path
expectedPath := "/api/rise-runtime/boot/share/test-share-id"
if r.URL.Path != expectedPath {
t.Errorf("Expected path '%s', got '%s'", expectedPath, r.URL.Path)
}
// Check request method
if r.Method != http.MethodGet {
t.Errorf("Expected method GET, got %s", r.Method)
}
// Return mock response
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(testCourse); err != nil {
t.Fatalf("Failed to encode test course: %v", err)
}
}))
defer server.Close()
// Create parser with test server URL
parser := &ArticulateParser{
BaseURL: server.URL,
Client: &http.Client{
Timeout: 5 * time.Second,
},
}
tests := []struct {
name string
uri string
expectedError string
}{
{
name: "valid articulate rise URI",
uri: "https://rise.articulate.com/share/test-share-id#/",
},
{
name: "valid articulate rise URI without fragment",
uri: "https://rise.articulate.com/share/test-share-id",
},
{
name: "invalid URI format",
uri: "invalid-uri",
expectedError: "invalid domain for Articulate Rise URI:",
},
{
name: "empty URI",
uri: "",
expectedError: "invalid domain for Articulate Rise URI:",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
course, err := parser.FetchCourse(tt.uri)
if tt.expectedError != "" {
if err == nil {
t.Fatalf("Expected error containing '%s', got nil", tt.expectedError)
}
if !strings.Contains(err.Error(), tt.expectedError) {
t.Errorf("Expected error containing '%s', got '%s'", tt.expectedError, err.Error())
}
} else {
if err != nil {
t.Fatalf("Expected no error, got: %v", err)
}
if course == nil {
t.Fatal("Expected course, got nil")
}
if course.ShareID != testCourse.ShareID {
t.Errorf("Expected ShareID '%s', got '%s'", testCourse.ShareID, course.ShareID)
}
}
})
}
}
// TestArticulateParser_FetchCourse_NetworkError tests network error handling.
func TestArticulateParser_FetchCourse_NetworkError(t *testing.T) {
// Create parser with invalid URL to simulate network error
parser := &ArticulateParser{
BaseURL: "http://localhost:99999", // Invalid port
Client: &http.Client{
Timeout: 1 * time.Millisecond, // Very short timeout
},
}
_, err := parser.FetchCourse("https://rise.articulate.com/share/test-share-id")
if err == nil {
t.Fatal("Expected network error, got nil")
}
if !strings.Contains(err.Error(), "failed to fetch course data") {
t.Errorf("Expected error to contain 'failed to fetch course data', got '%s'", err.Error())
}
}
// TestArticulateParser_FetchCourse_InvalidJSON tests invalid JSON response handling.
func TestArticulateParser_FetchCourse_InvalidJSON(t *testing.T) {
// Create test server that returns invalid JSON
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte("invalid json"))
}))
defer server.Close()
parser := &ArticulateParser{
BaseURL: server.URL,
Client: &http.Client{
Timeout: 5 * time.Second,
},
}
_, err := parser.FetchCourse("https://rise.articulate.com/share/test-share-id")
if err == nil {
t.Fatal("Expected JSON parsing error, got nil")
}
if !strings.Contains(err.Error(), "failed to unmarshal JSON") {
t.Errorf("Expected error to contain 'failed to unmarshal JSON', got '%s'", err.Error())
}
}
// TestArticulateParser_LoadCourseFromFile tests the LoadCourseFromFile method.
func TestArticulateParser_LoadCourseFromFile(t *testing.T) {
// Create a temporary test file
testCourse := &models.Course{
ShareID: "file-test-share-id",
Author: "File Test Author",
Course: models.CourseInfo{
ID: "file-test-course-id",
Title: "File Test Course",
Description: "File Test Description",
},
}
// Create temporary directory and file
tempDir := t.TempDir()
tempFile := filepath.Join(tempDir, "test-course.json")
// Write test data to file
data, err := json.Marshal(testCourse)
if err != nil {
t.Fatalf("Failed to marshal test course: %v", err)
}
if err := os.WriteFile(tempFile, data, 0644); err != nil {
t.Fatalf("Failed to write test file: %v", err)
}
parser := NewArticulateParser()
tests := []struct {
name string
filePath string
expectedError string
}{
{
name: "valid file",
filePath: tempFile,
},
{
name: "nonexistent file",
filePath: filepath.Join(tempDir, "nonexistent.json"),
expectedError: "failed to read file",
},
{
name: "empty path",
filePath: "",
expectedError: "failed to read file",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
course, err := parser.LoadCourseFromFile(tt.filePath)
if tt.expectedError != "" {
if err == nil {
t.Fatalf("Expected error containing '%s', got nil", tt.expectedError)
}
if !strings.Contains(err.Error(), tt.expectedError) {
t.Errorf("Expected error containing '%s', got '%s'", tt.expectedError, err.Error())
}
} else {
if err != nil {
t.Fatalf("Expected no error, got: %v", err)
}
if course == nil {
t.Fatal("Expected course, got nil")
}
if course.ShareID != testCourse.ShareID {
t.Errorf("Expected ShareID '%s', got '%s'", testCourse.ShareID, course.ShareID)
}
}
})
}
}
// TestArticulateParser_LoadCourseFromFile_InvalidJSON tests invalid JSON file handling.
func TestArticulateParser_LoadCourseFromFile_InvalidJSON(t *testing.T) {
// Create temporary file with invalid JSON
tempDir := t.TempDir()
tempFile := filepath.Join(tempDir, "invalid.json")
if err := os.WriteFile(tempFile, []byte("invalid json content"), 0644); err != nil {
t.Fatalf("Failed to write test file: %v", err)
}
parser := NewArticulateParser()
_, err := parser.LoadCourseFromFile(tempFile)
if err == nil {
t.Fatal("Expected JSON parsing error, got nil")
}
if !strings.Contains(err.Error(), "failed to unmarshal JSON") {
t.Errorf("Expected error to contain 'failed to unmarshal JSON', got '%s'", err.Error())
}
}
// TestExtractShareID tests the extractShareID method.
func TestExtractShareID(t *testing.T) {
parser := &ArticulateParser{}
tests := []struct {
name string
uri string
expected string
hasError bool
}{
{
name: "standard articulate rise URI with fragment",
uri: "https://rise.articulate.com/share/N_APNg40Vr2CSH2xNz-ZLATM5kNviDIO#/",
expected: "N_APNg40Vr2CSH2xNz-ZLATM5kNviDIO",
},
{
name: "standard articulate rise URI without fragment",
uri: "https://rise.articulate.com/share/N_APNg40Vr2CSH2xNz-ZLATM5kNviDIO",
expected: "N_APNg40Vr2CSH2xNz-ZLATM5kNviDIO",
},
{
name: "URI with trailing slash",
uri: "https://rise.articulate.com/share/N_APNg40Vr2CSH2xNz-ZLATM5kNviDIO/",
expected: "N_APNg40Vr2CSH2xNz-ZLATM5kNviDIO",
},
{
name: "short share ID",
uri: "https://rise.articulate.com/share/abc123",
expected: "abc123",
},
{
name: "share ID with hyphens and underscores",
uri: "https://rise.articulate.com/share/test_ID-123_abc",
expected: "test_ID-123_abc",
},
{
name: "invalid URI - no share path",
uri: "https://rise.articulate.com/",
hasError: true,
},
{
name: "invalid URI - wrong domain",
uri: "https://example.com/share/test123",
hasError: true,
},
{
name: "invalid URI - no share ID",
uri: "https://rise.articulate.com/share/",
hasError: true,
},
{
name: "empty URI",
uri: "",
hasError: true,
},
{
name: "malformed URI",
uri: "not-a-uri",
hasError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := parser.extractShareID(tt.uri)
if tt.hasError {
if err == nil {
t.Fatalf("Expected error for URI '%s', got nil", tt.uri)
}
} else {
if err != nil {
t.Fatalf("Expected no error for URI '%s', got: %v", tt.uri, err)
}
if result != tt.expected {
t.Errorf("Expected share ID '%s', got '%s'", tt.expected, result)
}
}
})
}
}
// TestBuildAPIURL tests the buildAPIURL method.
func TestBuildAPIURL(t *testing.T) {
parser := &ArticulateParser{
BaseURL: "https://rise.articulate.com",
}
tests := []struct {
name string
shareID string
expected string
}{
{
name: "standard share ID",
shareID: "N_APNg40Vr2CSH2xNz-ZLATM5kNviDIO",
expected: "https://rise.articulate.com/api/rise-runtime/boot/share/N_APNg40Vr2CSH2xNz-ZLATM5kNviDIO",
},
{
name: "short share ID",
shareID: "abc123",
expected: "https://rise.articulate.com/api/rise-runtime/boot/share/abc123",
},
{
name: "share ID with special characters",
shareID: "test_ID-123_abc",
expected: "https://rise.articulate.com/api/rise-runtime/boot/share/test_ID-123_abc",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := parser.buildAPIURL(tt.shareID)
if result != tt.expected {
t.Errorf("Expected URL '%s', got '%s'", tt.expected, result)
}
})
}
}
// TestBuildAPIURL_DifferentBaseURL tests buildAPIURL with different base URLs.
func TestBuildAPIURL_DifferentBaseURL(t *testing.T) {
parser := &ArticulateParser{
BaseURL: "https://custom.domain.com",
}
shareID := "test123"
expected := "https://custom.domain.com/api/rise-runtime/boot/share/test123"
result := parser.buildAPIURL(shareID)
if result != expected {
t.Errorf("Expected URL '%s', got '%s'", expected, result)
}
}
// BenchmarkExtractShareID benchmarks the extractShareID method.
func BenchmarkExtractShareID(b *testing.B) {
parser := &ArticulateParser{}
uri := "https://rise.articulate.com/share/N_APNg40Vr2CSH2xNz-ZLATM5kNviDIO#/"
for b.Loop() {
_, _ = parser.extractShareID(uri)
}
}
// BenchmarkBuildAPIURL benchmarks the buildAPIURL method.
func BenchmarkBuildAPIURL(b *testing.B) {
parser := &ArticulateParser{
BaseURL: "https://rise.articulate.com",
}
shareID := "N_APNg40Vr2CSH2xNz-ZLATM5kNviDIO"
for b.Loop() {
_ = parser.buildAPIURL(shareID)
}
}