All checks were successful
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-school (push) Successful in 28s
CI / test-go-edu-search (push) Successful in 27s
CI / test-python-klausur (push) Successful in 1m45s
CI / test-python-agent-core (push) Successful in 16s
CI / test-nodejs-website (push) Successful in 21s
- edu-search-service von breakpilot-pwa nach breakpilot-lehrer kopiert (ohne vendor) - opensearch + edu-search-service in docker-compose.yml hinzugefuegt - voice-service aus docker-compose.yml entfernt (jetzt in breakpilot-core) - geo-service aus docker-compose.yml entfernt (nicht mehr benoetigt) - CI/CD: edu-search-service zu Gitea Actions und Woodpecker hinzugefuegt (Go lint, test mit go mod download, build, SBOM) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
646 lines
15 KiB
Go
646 lines
15 KiB
Go
package handlers
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
)
|
|
|
|
func init() {
|
|
gin.SetMode(gin.TestMode)
|
|
}
|
|
|
|
// setupTestRouter creates a test router with the handler
|
|
func setupTestRouter(h *Handler, apiKey string) *gin.Engine {
|
|
router := gin.New()
|
|
SetupRoutes(router, h, apiKey)
|
|
return router
|
|
}
|
|
|
|
// setupTestSeedStore creates a test seed store
|
|
func setupTestSeedStore(t *testing.T) string {
|
|
t.Helper()
|
|
dir := t.TempDir()
|
|
|
|
// Initialize global seed store
|
|
err := InitSeedStore(dir)
|
|
if err != nil {
|
|
t.Fatalf("Failed to initialize seed store: %v", err)
|
|
}
|
|
|
|
return dir
|
|
}
|
|
|
|
func TestHealthEndpoint(t *testing.T) {
|
|
// Health endpoint requires indexClient for health check
|
|
// This test verifies the route is set up correctly
|
|
// A full integration test would need a mock OpenSearch client
|
|
t.Skip("Skipping: requires mock indexer client for full test")
|
|
}
|
|
|
|
func TestAuthMiddleware_NoAuth(t *testing.T) {
|
|
h := &Handler{}
|
|
router := setupTestRouter(h, "test-api-key")
|
|
|
|
// Request without auth header
|
|
req, _ := http.NewRequest("POST", "/v1/search", bytes.NewBufferString(`{"q":"test"}`))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
w := httptest.NewRecorder()
|
|
router.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusUnauthorized {
|
|
t.Errorf("Expected status 401, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestAuthMiddleware_InvalidFormat(t *testing.T) {
|
|
h := &Handler{}
|
|
router := setupTestRouter(h, "test-api-key")
|
|
|
|
// Request with wrong auth format
|
|
req, _ := http.NewRequest("POST", "/v1/search", bytes.NewBufferString(`{"q":"test"}`))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("Authorization", "Basic dGVzdDp0ZXN0") // Basic auth instead of Bearer
|
|
w := httptest.NewRecorder()
|
|
router.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusUnauthorized {
|
|
t.Errorf("Expected status 401, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestAuthMiddleware_InvalidKey(t *testing.T) {
|
|
h := &Handler{}
|
|
router := setupTestRouter(h, "test-api-key")
|
|
|
|
// Request with wrong API key
|
|
req, _ := http.NewRequest("POST", "/v1/search", bytes.NewBufferString(`{"q":"test"}`))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("Authorization", "Bearer wrong-key")
|
|
w := httptest.NewRecorder()
|
|
router.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusUnauthorized {
|
|
t.Errorf("Expected status 401, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestAuthMiddleware_ValidKey(t *testing.T) {
|
|
h := &Handler{}
|
|
router := setupTestRouter(h, "test-api-key")
|
|
|
|
// Request with correct API key (search will fail due to no search service, but auth should pass)
|
|
req, _ := http.NewRequest("GET", "/v1/document?doc_id=test", nil)
|
|
req.Header.Set("Authorization", "Bearer test-api-key")
|
|
w := httptest.NewRecorder()
|
|
router.ServeHTTP(w, req)
|
|
|
|
// Auth should pass, endpoint returns 501 (not implemented)
|
|
if w.Code == http.StatusUnauthorized {
|
|
t.Error("Expected auth to pass, got 401")
|
|
}
|
|
}
|
|
|
|
func TestAuthMiddleware_HealthNoAuth(t *testing.T) {
|
|
// Health endpoint requires indexClient for health check
|
|
// Skipping because route calls h.indexClient.Health() which panics with nil
|
|
t.Skip("Skipping: requires mock indexer client for full test")
|
|
}
|
|
|
|
func TestGetDocument_MissingDocID(t *testing.T) {
|
|
h := &Handler{}
|
|
router := setupTestRouter(h, "test-key")
|
|
|
|
req, _ := http.NewRequest("GET", "/v1/document", nil)
|
|
req.Header.Set("Authorization", "Bearer test-key")
|
|
w := httptest.NewRecorder()
|
|
router.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("Expected status 400, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
// Admin Handler Tests
|
|
|
|
func TestSeedStore_InitAndLoad(t *testing.T) {
|
|
dir := t.TempDir()
|
|
|
|
// First initialization should create default seeds
|
|
err := InitSeedStore(dir)
|
|
if err != nil {
|
|
t.Fatalf("InitSeedStore failed: %v", err)
|
|
}
|
|
|
|
// Check that seeds file was created
|
|
seedsFile := filepath.Join(dir, "seeds.json")
|
|
if _, err := os.Stat(seedsFile); os.IsNotExist(err) {
|
|
t.Error("seeds.json was not created")
|
|
}
|
|
|
|
// Check that default seeds were loaded
|
|
seeds := seedStore.GetAllSeeds()
|
|
if len(seeds) == 0 {
|
|
t.Error("Expected default seeds to be loaded")
|
|
}
|
|
}
|
|
|
|
func TestSeedStore_CreateSeed(t *testing.T) {
|
|
setupTestSeedStore(t)
|
|
|
|
newSeed := SeedURL{
|
|
URL: "https://test.example.com",
|
|
Name: "Test Seed",
|
|
Category: "test",
|
|
Description: "A test seed",
|
|
TrustBoost: 0.5,
|
|
Enabled: true,
|
|
}
|
|
|
|
created, err := seedStore.CreateSeed(newSeed)
|
|
if err != nil {
|
|
t.Fatalf("CreateSeed failed: %v", err)
|
|
}
|
|
|
|
if created.ID == "" {
|
|
t.Error("Expected generated ID")
|
|
}
|
|
if created.URL != newSeed.URL {
|
|
t.Errorf("Expected URL %q, got %q", newSeed.URL, created.URL)
|
|
}
|
|
if created.CreatedAt.IsZero() {
|
|
t.Error("Expected CreatedAt to be set")
|
|
}
|
|
}
|
|
|
|
func TestSeedStore_GetSeed(t *testing.T) {
|
|
setupTestSeedStore(t)
|
|
|
|
// Create a seed first
|
|
newSeed := SeedURL{
|
|
URL: "https://get-test.example.com",
|
|
Name: "Get Test",
|
|
Category: "test",
|
|
}
|
|
created, _ := seedStore.CreateSeed(newSeed)
|
|
|
|
// Get the seed
|
|
retrieved, found := seedStore.GetSeed(created.ID)
|
|
if !found {
|
|
t.Fatal("Seed not found")
|
|
}
|
|
|
|
if retrieved.URL != newSeed.URL {
|
|
t.Errorf("Expected URL %q, got %q", newSeed.URL, retrieved.URL)
|
|
}
|
|
}
|
|
|
|
func TestSeedStore_GetSeed_NotFound(t *testing.T) {
|
|
setupTestSeedStore(t)
|
|
|
|
_, found := seedStore.GetSeed("nonexistent-id")
|
|
if found {
|
|
t.Error("Expected seed not to be found")
|
|
}
|
|
}
|
|
|
|
func TestSeedStore_UpdateSeed(t *testing.T) {
|
|
setupTestSeedStore(t)
|
|
|
|
// Create a seed first
|
|
original := SeedURL{
|
|
URL: "https://update-test.example.com",
|
|
Name: "Original Name",
|
|
Category: "test",
|
|
Enabled: true,
|
|
}
|
|
created, _ := seedStore.CreateSeed(original)
|
|
|
|
// Update the seed
|
|
updates := SeedURL{
|
|
Name: "Updated Name",
|
|
TrustBoost: 0.75,
|
|
Enabled: false,
|
|
}
|
|
|
|
updated, found, err := seedStore.UpdateSeed(created.ID, updates)
|
|
if err != nil {
|
|
t.Fatalf("UpdateSeed failed: %v", err)
|
|
}
|
|
if !found {
|
|
t.Fatal("Seed not found for update")
|
|
}
|
|
|
|
if updated.Name != "Updated Name" {
|
|
t.Errorf("Expected name 'Updated Name', got %q", updated.Name)
|
|
}
|
|
if updated.TrustBoost != 0.75 {
|
|
t.Errorf("Expected TrustBoost 0.75, got %f", updated.TrustBoost)
|
|
}
|
|
if updated.Enabled != false {
|
|
t.Error("Expected Enabled to be false")
|
|
}
|
|
// URL should remain unchanged since we didn't provide it
|
|
if updated.URL != original.URL {
|
|
t.Errorf("URL should remain unchanged, expected %q, got %q", original.URL, updated.URL)
|
|
}
|
|
}
|
|
|
|
func TestSeedStore_UpdateSeed_NotFound(t *testing.T) {
|
|
setupTestSeedStore(t)
|
|
|
|
updates := SeedURL{Name: "New Name"}
|
|
_, found, err := seedStore.UpdateSeed("nonexistent-id", updates)
|
|
if err != nil {
|
|
t.Fatalf("Unexpected error: %v", err)
|
|
}
|
|
if found {
|
|
t.Error("Expected seed not to be found")
|
|
}
|
|
}
|
|
|
|
func TestSeedStore_DeleteSeed(t *testing.T) {
|
|
setupTestSeedStore(t)
|
|
|
|
// Create a seed first
|
|
newSeed := SeedURL{
|
|
URL: "https://delete-test.example.com",
|
|
Name: "Delete Test",
|
|
Category: "test",
|
|
}
|
|
created, _ := seedStore.CreateSeed(newSeed)
|
|
|
|
// Delete the seed
|
|
deleted := seedStore.DeleteSeed(created.ID)
|
|
if !deleted {
|
|
t.Error("Expected delete to succeed")
|
|
}
|
|
|
|
// Verify it's gone
|
|
_, found := seedStore.GetSeed(created.ID)
|
|
if found {
|
|
t.Error("Seed should have been deleted")
|
|
}
|
|
}
|
|
|
|
func TestSeedStore_DeleteSeed_NotFound(t *testing.T) {
|
|
setupTestSeedStore(t)
|
|
|
|
deleted := seedStore.DeleteSeed("nonexistent-id")
|
|
if deleted {
|
|
t.Error("Expected delete to return false for nonexistent seed")
|
|
}
|
|
}
|
|
|
|
func TestSeedStore_Persistence(t *testing.T) {
|
|
dir := t.TempDir()
|
|
|
|
// Create and populate seed store
|
|
err := InitSeedStore(dir)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
newSeed := SeedURL{
|
|
URL: "https://persist-test.example.com",
|
|
Name: "Persistence Test",
|
|
Category: "test",
|
|
}
|
|
created, err := seedStore.CreateSeed(newSeed)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Re-initialize from the same directory
|
|
seedStore = nil
|
|
err = InitSeedStore(dir)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Check if the seed persisted
|
|
retrieved, found := seedStore.GetSeed(created.ID)
|
|
if !found {
|
|
t.Error("Seed should have persisted")
|
|
}
|
|
if retrieved.URL != newSeed.URL {
|
|
t.Errorf("Persisted seed URL mismatch: expected %q, got %q", newSeed.URL, retrieved.URL)
|
|
}
|
|
}
|
|
|
|
func TestAdminGetSeeds(t *testing.T) {
|
|
dir := setupTestSeedStore(t)
|
|
|
|
h := &Handler{}
|
|
router := gin.New()
|
|
SetupRoutes(router, h, "test-key")
|
|
|
|
// Initialize seed store for the test
|
|
InitSeedStore(dir)
|
|
|
|
req, _ := http.NewRequest("GET", "/v1/admin/seeds", nil)
|
|
req.Header.Set("Authorization", "Bearer test-key")
|
|
w := httptest.NewRecorder()
|
|
router.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("Expected status 200, got %d", w.Code)
|
|
}
|
|
|
|
var seeds []SeedURL
|
|
if err := json.Unmarshal(w.Body.Bytes(), &seeds); err != nil {
|
|
t.Fatalf("Failed to parse response: %v", err)
|
|
}
|
|
|
|
// Should have default seeds
|
|
if len(seeds) == 0 {
|
|
t.Error("Expected seeds to be returned")
|
|
}
|
|
}
|
|
|
|
func TestAdminCreateSeed(t *testing.T) {
|
|
dir := setupTestSeedStore(t)
|
|
|
|
h := &Handler{}
|
|
router := gin.New()
|
|
SetupRoutes(router, h, "test-key")
|
|
InitSeedStore(dir)
|
|
|
|
newSeed := map[string]interface{}{
|
|
"url": "https://new-seed.example.com",
|
|
"name": "New Seed",
|
|
"category": "test",
|
|
"description": "Test description",
|
|
"trustBoost": 0.5,
|
|
"enabled": true,
|
|
}
|
|
|
|
body, _ := json.Marshal(newSeed)
|
|
req, _ := http.NewRequest("POST", "/v1/admin/seeds", bytes.NewBuffer(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("Authorization", "Bearer test-key")
|
|
w := httptest.NewRecorder()
|
|
router.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusCreated {
|
|
t.Errorf("Expected status 201, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var created SeedURL
|
|
if err := json.Unmarshal(w.Body.Bytes(), &created); err != nil {
|
|
t.Fatalf("Failed to parse response: %v", err)
|
|
}
|
|
|
|
if created.ID == "" {
|
|
t.Error("Expected ID to be generated")
|
|
}
|
|
if created.URL != "https://new-seed.example.com" {
|
|
t.Errorf("Expected URL to match, got %q", created.URL)
|
|
}
|
|
}
|
|
|
|
func TestAdminCreateSeed_MissingURL(t *testing.T) {
|
|
dir := setupTestSeedStore(t)
|
|
|
|
h := &Handler{}
|
|
router := gin.New()
|
|
SetupRoutes(router, h, "test-key")
|
|
InitSeedStore(dir)
|
|
|
|
newSeed := map[string]interface{}{
|
|
"name": "No URL Seed",
|
|
"category": "test",
|
|
}
|
|
|
|
body, _ := json.Marshal(newSeed)
|
|
req, _ := http.NewRequest("POST", "/v1/admin/seeds", bytes.NewBuffer(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("Authorization", "Bearer test-key")
|
|
w := httptest.NewRecorder()
|
|
router.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("Expected status 400 for missing URL, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestAdminUpdateSeed(t *testing.T) {
|
|
dir := setupTestSeedStore(t)
|
|
|
|
h := &Handler{}
|
|
router := gin.New()
|
|
SetupRoutes(router, h, "test-key")
|
|
InitSeedStore(dir)
|
|
|
|
// Create a seed first
|
|
newSeed := SeedURL{
|
|
URL: "https://update-api-test.example.com",
|
|
Name: "API Update Test",
|
|
Category: "test",
|
|
}
|
|
created, _ := seedStore.CreateSeed(newSeed)
|
|
|
|
// Update via API
|
|
updates := map[string]interface{}{
|
|
"name": "Updated via API",
|
|
"trustBoost": 0.8,
|
|
}
|
|
|
|
body, _ := json.Marshal(updates)
|
|
req, _ := http.NewRequest("PUT", "/v1/admin/seeds/"+created.ID, bytes.NewBuffer(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("Authorization", "Bearer test-key")
|
|
w := httptest.NewRecorder()
|
|
router.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("Expected status 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var updated SeedURL
|
|
if err := json.Unmarshal(w.Body.Bytes(), &updated); err != nil {
|
|
t.Fatalf("Failed to parse response: %v", err)
|
|
}
|
|
|
|
if updated.Name != "Updated via API" {
|
|
t.Errorf("Expected name 'Updated via API', got %q", updated.Name)
|
|
}
|
|
}
|
|
|
|
func TestAdminDeleteSeed(t *testing.T) {
|
|
dir := setupTestSeedStore(t)
|
|
|
|
h := &Handler{}
|
|
router := gin.New()
|
|
SetupRoutes(router, h, "test-key")
|
|
InitSeedStore(dir)
|
|
|
|
// Create a seed first
|
|
newSeed := SeedURL{
|
|
URL: "https://delete-api-test.example.com",
|
|
Name: "API Delete Test",
|
|
Category: "test",
|
|
}
|
|
created, _ := seedStore.CreateSeed(newSeed)
|
|
|
|
// Delete via API
|
|
req, _ := http.NewRequest("DELETE", "/v1/admin/seeds/"+created.ID, nil)
|
|
req.Header.Set("Authorization", "Bearer test-key")
|
|
w := httptest.NewRecorder()
|
|
router.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("Expected status 200, got %d", w.Code)
|
|
}
|
|
|
|
// Verify it's deleted
|
|
_, found := seedStore.GetSeed(created.ID)
|
|
if found {
|
|
t.Error("Seed should have been deleted")
|
|
}
|
|
}
|
|
|
|
func TestAdminDeleteSeed_NotFound(t *testing.T) {
|
|
dir := setupTestSeedStore(t)
|
|
|
|
h := &Handler{}
|
|
router := gin.New()
|
|
SetupRoutes(router, h, "test-key")
|
|
InitSeedStore(dir)
|
|
|
|
req, _ := http.NewRequest("DELETE", "/v1/admin/seeds/nonexistent-id", nil)
|
|
req.Header.Set("Authorization", "Bearer test-key")
|
|
w := httptest.NewRecorder()
|
|
router.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusNotFound {
|
|
t.Errorf("Expected status 404, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestAdminGetStats(t *testing.T) {
|
|
dir := setupTestSeedStore(t)
|
|
|
|
h := &Handler{}
|
|
router := gin.New()
|
|
SetupRoutes(router, h, "test-key")
|
|
InitSeedStore(dir)
|
|
|
|
req, _ := http.NewRequest("GET", "/v1/admin/stats", nil)
|
|
req.Header.Set("Authorization", "Bearer test-key")
|
|
w := httptest.NewRecorder()
|
|
router.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("Expected status 200, got %d", w.Code)
|
|
}
|
|
|
|
var stats CrawlStats
|
|
if err := json.Unmarshal(w.Body.Bytes(), &stats); err != nil {
|
|
t.Fatalf("Failed to parse response: %v", err)
|
|
}
|
|
|
|
// Check that stats structure is populated
|
|
if stats.CrawlStatus == "" {
|
|
t.Error("Expected CrawlStatus to be set")
|
|
}
|
|
if stats.DocumentsPerCategory == nil {
|
|
t.Error("Expected DocumentsPerCategory to be set")
|
|
}
|
|
}
|
|
|
|
func TestAdminStartCrawl(t *testing.T) {
|
|
dir := setupTestSeedStore(t)
|
|
|
|
h := &Handler{}
|
|
router := gin.New()
|
|
SetupRoutes(router, h, "test-key")
|
|
InitSeedStore(dir)
|
|
|
|
// Reset crawl status
|
|
crawlStatus = "idle"
|
|
|
|
req, _ := http.NewRequest("POST", "/v1/admin/crawl/start", nil)
|
|
req.Header.Set("Authorization", "Bearer test-key")
|
|
w := httptest.NewRecorder()
|
|
router.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusAccepted {
|
|
t.Errorf("Expected status 202, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var response map[string]interface{}
|
|
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
|
|
t.Fatalf("Failed to parse response: %v", err)
|
|
}
|
|
|
|
if response["status"] != "started" {
|
|
t.Errorf("Expected status 'started', got %v", response["status"])
|
|
}
|
|
}
|
|
|
|
func TestAdminStartCrawl_AlreadyRunning(t *testing.T) {
|
|
dir := setupTestSeedStore(t)
|
|
|
|
h := &Handler{}
|
|
router := gin.New()
|
|
SetupRoutes(router, h, "test-key")
|
|
InitSeedStore(dir)
|
|
|
|
// Set crawl status to running
|
|
crawlStatus = "running"
|
|
|
|
req, _ := http.NewRequest("POST", "/v1/admin/crawl/start", nil)
|
|
req.Header.Set("Authorization", "Bearer test-key")
|
|
w := httptest.NewRecorder()
|
|
router.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusConflict {
|
|
t.Errorf("Expected status 409, got %d", w.Code)
|
|
}
|
|
|
|
// Reset for other tests
|
|
crawlStatus = "idle"
|
|
}
|
|
|
|
func TestConcurrentSeedAccess(t *testing.T) {
|
|
setupTestSeedStore(t)
|
|
|
|
// Test concurrent reads and writes
|
|
done := make(chan bool, 10)
|
|
|
|
// Concurrent readers
|
|
for i := 0; i < 5; i++ {
|
|
go func() {
|
|
seedStore.GetAllSeeds()
|
|
done <- true
|
|
}()
|
|
}
|
|
|
|
// Concurrent writers
|
|
for i := 0; i < 5; i++ {
|
|
go func(n int) {
|
|
seed := SeedURL{
|
|
URL: "https://concurrent-" + string(rune('A'+n)) + ".example.com",
|
|
Name: "Concurrent Test",
|
|
Category: "test",
|
|
}
|
|
seedStore.CreateSeed(seed)
|
|
done <- true
|
|
}(i)
|
|
}
|
|
|
|
// Wait for all goroutines
|
|
for i := 0; i < 10; i++ {
|
|
<-done
|
|
}
|
|
|
|
// If we get here without deadlock or race, test passes
|
|
}
|