diff --git a/pkg/database/interface_test.go b/pkg/database/interface_test.go index 272fa43..dff17f1 100644 --- a/pkg/database/interface_test.go +++ b/pkg/database/interface_test.go @@ -133,3 +133,72 @@ func TestInterfaceCompatibility(t *testing.T) { var _ HashrateStore = &defaultStore{} var _ HashrateStore = &nopStore{} } + +func TestDefaultStore_ContextCancellation(t *testing.T) { + cleanup := setupTestDB(t) + defer cleanup() + + store := DefaultStore() + + // Create a cancelled context + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + point := HashratePoint{ + Timestamp: time.Now(), + Hashrate: 1000, + } + + // Insert with cancelled context should fail + err := store.InsertHashratePoint(ctx, "cancel-test", "xmrig", point, ResolutionHigh) + if err == nil { + t.Log("InsertHashratePoint with cancelled context succeeded (SQLite may not check context)") + } else { + t.Logf("InsertHashratePoint with cancelled context: %v (expected)", err) + } +} + +func TestDefaultStore_ContextTimeout(t *testing.T) { + cleanup := setupTestDB(t) + defer cleanup() + + store := DefaultStore() + + // Create a context that expires very quickly + ctx, cancel := context.WithTimeout(context.Background(), 1*time.Nanosecond) + defer cancel() + + // Wait for timeout to expire + time.Sleep(1 * time.Millisecond) + + point := HashratePoint{ + Timestamp: time.Now(), + Hashrate: 1000, + } + + // Insert with expired context + err := store.InsertHashratePoint(ctx, "timeout-test", "xmrig", point, ResolutionHigh) + if err == nil { + t.Log("InsertHashratePoint with expired context succeeded (SQLite may not check context)") + } else { + t.Logf("InsertHashratePoint with expired context: %v (expected)", err) + } +} + +func TestNopStore_WithContext(t *testing.T) { + store := NopStore() + + // NopStore should work with any context, including cancelled ones + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + point := HashratePoint{ + Timestamp: time.Now(), + Hashrate: 1000, + } + + // Should still succeed (nop store ignores context) + if err := store.InsertHashratePoint(ctx, "nop-cancel-test", "xmrig", point, ResolutionHigh); err != nil { + t.Errorf("NopStore should succeed even with cancelled context: %v", err) + } +} diff --git a/pkg/mining/container_test.go b/pkg/mining/container_test.go index 0815b64..8fe9fec 100644 --- a/pkg/mining/container_test.go +++ b/pkg/mining/container_test.go @@ -236,3 +236,81 @@ func TestContainer_ShutdownChannel(t *testing.T) { t.Error("ShutdownCh should be closed after Shutdown()") } } + +func TestContainer_InitializeWithCancelledContext(t *testing.T) { + cleanup := setupContainerTestEnv(t) + defer cleanup() + + config := DefaultContainerConfig() + config.Database.Enabled = false + config.SimulationMode = true + + container := NewContainer(config) + + // Use a pre-cancelled context + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + // Initialize should still succeed (context is checked at operation start) + // But operations that check context should respect cancellation + if err := container.Initialize(ctx); err != nil { + // This is acceptable - initialization may fail with cancelled context + t.Logf("Initialize with cancelled context: %v (acceptable)", err) + } + + // Cleanup if initialized + if container.IsInitialized() { + container.Shutdown(context.Background()) + } +} + +func TestContainer_ShutdownWithTimeout(t *testing.T) { + cleanup := setupContainerTestEnv(t) + defer cleanup() + + config := DefaultContainerConfig() + config.Database.Enabled = false + config.SimulationMode = true + + container := NewContainer(config) + ctx := context.Background() + + if err := container.Initialize(ctx); err != nil { + t.Fatalf("Initialize failed: %v", err) + } + + // Use a context with very short timeout + timeoutCtx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond) + defer cancel() + + // Shutdown should still complete (cleanup is fast without real miners) + if err := container.Shutdown(timeoutCtx); err != nil { + t.Logf("Shutdown with timeout: %v (may be acceptable)", err) + } +} + +func TestContainer_DoubleShutdown(t *testing.T) { + cleanup := setupContainerTestEnv(t) + defer cleanup() + + config := DefaultContainerConfig() + config.Database.Enabled = false + config.SimulationMode = true + + container := NewContainer(config) + ctx := context.Background() + + if err := container.Initialize(ctx); err != nil { + t.Fatalf("Initialize failed: %v", err) + } + + // First shutdown + if err := container.Shutdown(ctx); err != nil { + t.Errorf("First shutdown failed: %v", err) + } + + // Second shutdown should not panic or error + if err := container.Shutdown(ctx); err != nil { + t.Logf("Second shutdown returned: %v (expected no-op)", err) + } +} diff --git a/pkg/mining/repository_test.go b/pkg/mining/repository_test.go index a15a475..f285166 100644 --- a/pkg/mining/repository_test.go +++ b/pkg/mining/repository_test.go @@ -253,6 +253,107 @@ func TestFileRepository_Path(t *testing.T) { } } +func TestFileRepository_UpdateWithLoadError(t *testing.T) { + tmpDir := t.TempDir() + path := filepath.Join(tmpDir, "corrupt.json") + repo := NewFileRepository[testData](path) + + // Write invalid JSON + if err := os.WriteFile(path, []byte(`{invalid}`), 0600); err != nil { + t.Fatalf("Failed to write corrupt file: %v", err) + } + + // Update should fail to load the corrupt file + err := repo.Update(func(data *testData) error { + data.Value = 999 + return nil + }) + if err == nil { + t.Error("Expected error for corrupt file during Update") + } +} + +func TestFileRepository_SaveToReadOnlyDirectory(t *testing.T) { + if os.Getuid() == 0 { + t.Skip("Test skipped when running as root") + } + + tmpDir := t.TempDir() + readOnlyDir := filepath.Join(tmpDir, "readonly") + if err := os.Mkdir(readOnlyDir, 0555); err != nil { + t.Fatalf("Failed to create readonly dir: %v", err) + } + defer os.Chmod(readOnlyDir, 0755) // Restore permissions for cleanup + + path := filepath.Join(readOnlyDir, "test.json") + repo := NewFileRepository[testData](path) + + // Save should fail due to permission denied + err := repo.Save(testData{Name: "test", Value: 1}) + if err == nil { + t.Error("Expected error when saving to read-only directory") + } +} + +func TestFileRepository_DeleteNonExistent(t *testing.T) { + tmpDir := t.TempDir() + path := filepath.Join(tmpDir, "nonexistent.json") + repo := NewFileRepository[testData](path) + + // Delete on non-existent file should not error + if err := repo.Delete(); err != nil { + t.Errorf("Delete on non-existent file should not error: %v", err) + } +} + +func TestFileRepository_ExistsOnInvalidPath(t *testing.T) { + // Use a path that definitely doesn't exist + repo := NewFileRepository[testData]("/nonexistent/path/to/file.json") + + if repo.Exists() { + t.Error("Exists should return false for invalid path") + } +} + +func TestFileRepository_ConcurrentUpdates(t *testing.T) { + tmpDir := t.TempDir() + path := filepath.Join(tmpDir, "concurrent.json") + repo := NewFileRepository[testData](path, WithDefaults(func() testData { + return testData{Name: "initial", Value: 0} + })) + + // Run multiple concurrent updates + const numUpdates = 10 + done := make(chan bool) + + for i := 0; i < numUpdates; i++ { + go func() { + err := repo.Update(func(data *testData) error { + data.Value++ + return nil + }) + if err != nil { + t.Logf("Concurrent update error: %v", err) + } + done <- true + }() + } + + // Wait for all updates + for i := 0; i < numUpdates; i++ { + <-done + } + + // Verify final value equals number of updates + data, err := repo.Load() + if err != nil { + t.Fatalf("Load failed: %v", err) + } + if data.Value != numUpdates { + t.Errorf("Expected value %d after concurrent updates, got %d", numUpdates, data.Value) + } +} + // Test with slice data func TestFileRepository_SliceData(t *testing.T) { type item struct {