logos-storage-go/communities/codex_archive_downloader_test.go

473 lines
19 KiB
Go

//go:build !disable_torrent
// +build !disable_torrent
package communities_test
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/stretchr/testify/suite"
"go-codex-client/communities"
mock_communities "go-codex-client/communities/mock"
"go-codex-client/protobuf"
"go.uber.org/mock/gomock"
"go.uber.org/zap"
)
// CodexArchiveDownloaderTestifySuite demonstrates testify's suite functionality
type CodexArchiveDownloaderTestifySuite struct {
suite.Suite
ctrl *gomock.Controller
mockClient *mock_communities.MockCodexClientInterface
index *protobuf.CodexWakuMessageArchiveIndex
}
// SetupTest runs before each test method
func (suite *CodexArchiveDownloaderTestifySuite) SetupTest() {
suite.ctrl = gomock.NewController(suite.T())
suite.mockClient = mock_communities.NewMockCodexClientInterface(suite.ctrl)
suite.index = &protobuf.CodexWakuMessageArchiveIndex{
Archives: map[string]*protobuf.CodexWakuMessageArchiveIndexMetadata{
"test-archive-hash-1": {
Cid: "test-cid-1",
Metadata: &protobuf.WakuMessageArchiveMetadata{
From: 1000,
To: 2000,
},
},
},
}
}
// TearDownTest runs after each test method
func (suite *CodexArchiveDownloaderTestifySuite) TearDownTest() {
suite.ctrl.Finish()
}
func (suite *CodexArchiveDownloaderTestifySuite) TestBasicSingleArchive() {
// Test data
communityID := "test-community"
existingArchiveIDs := []string{} // No existing archives
cancelChan := make(chan struct{})
// Set up mock expectations - same as before
suite.mockClient.EXPECT().
TriggerDownloadWithContext(gomock.Any(), "test-cid-1").
Return(&communities.CodexManifest{CID: "test-cid-1"}, nil).
Times(1)
// First HasCid call returns false, second returns true (simulating polling)
gomock.InOrder(
suite.mockClient.EXPECT().HasCid("test-cid-1").Return(false, nil),
suite.mockClient.EXPECT().HasCid("test-cid-1").Return(true, nil),
)
// Create downloader with mock client
logger := zap.NewNop() // No-op logger for tests
downloader := communities.NewCodexArchiveDownloader(suite.mockClient, suite.index, communityID, existingArchiveIDs, cancelChan, logger)
// Set fast polling interval for tests (10ms instead of default 1s)
downloader.SetPollingInterval(10 * time.Millisecond)
// Set up callback to track completion
var callbackInvoked bool
var callbackHash string
var callbackFrom, callbackTo uint64
downloader.SetOnArchiveDownloaded(func(hash string, from, to uint64) {
callbackInvoked = true
callbackHash = hash
callbackFrom = from
callbackTo = to
})
// Verify initial state - compare testify vs standard assertions
// Testify version is more readable and provides better error messages
assert.Equal(suite.T(), 1, downloader.GetTotalArchivesCount(), "Total archives count should be 1")
assert.Equal(suite.T(), 0, downloader.GetTotalDownloadedArchivesCount(), "Downloaded archives should be 0 initially")
assert.False(suite.T(), downloader.IsDownloadComplete(), "Download should not be complete initially")
// Start the download
downloader.StartDownload()
// Wait for download to complete (with timeout)
require.Eventually(suite.T(), func() bool {
return downloader.IsDownloadComplete()
}, 5*time.Second, 100*time.Millisecond, "Download should complete within 5 seconds")
// Verify final state - testify makes these assertions more expressive
assert.True(suite.T(), downloader.IsDownloadComplete(), "Download should be complete")
assert.Equal(suite.T(), 1, downloader.GetTotalDownloadedArchivesCount(), "Should have 1 downloaded archive")
// Verify callback was invoked - multiple related assertions grouped logically
assert.True(suite.T(), callbackInvoked, "Callback should be invoked")
assert.Equal(suite.T(), "test-archive-hash-1", callbackHash, "Callback hash should match expected")
assert.Equal(suite.T(), uint64(1000), callbackFrom, "Callback from should be 1000")
assert.Equal(suite.T(), uint64(2000), callbackTo, "Callback to should be 2000")
suite.T().Log("✅ Basic single archive download test passed")
suite.T().Log(" - All mock expectations satisfied")
suite.T().Logf(" - Callback invoked: %v", callbackInvoked)
}
func (suite *CodexArchiveDownloaderTestifySuite) TestMultipleArchives() {
// Create test data with multiple archives
index := &protobuf.CodexWakuMessageArchiveIndex{
Archives: map[string]*protobuf.CodexWakuMessageArchiveIndexMetadata{
"archive-1": {
Cid: "cid-1",
Metadata: &protobuf.WakuMessageArchiveMetadata{From: 1000, To: 2000},
},
"archive-2": {
Cid: "cid-2",
Metadata: &protobuf.WakuMessageArchiveMetadata{From: 2000, To: 3000},
},
"archive-3": {
Cid: "cid-3",
Metadata: &protobuf.WakuMessageArchiveMetadata{From: 3000, To: 4000},
},
},
}
communityID := "test-community"
existingArchiveIDs := []string{} // No existing archives
cancelChan := make(chan struct{})
// Set up expectations for all 3 archives - testify makes verification cleaner
expectedCids := []string{"cid-1", "cid-2", "cid-3"}
for _, cid := range expectedCids {
suite.mockClient.EXPECT().
TriggerDownloadWithContext(gomock.Any(), cid).
Return(&communities.CodexManifest{CID: cid}, nil).
Times(1)
// Each archive becomes available after one poll
gomock.InOrder(
suite.mockClient.EXPECT().HasCid(cid).Return(false, nil),
suite.mockClient.EXPECT().HasCid(cid).Return(true, nil),
)
}
// Create downloader
logger := zap.NewNop() // No-op logger for tests
downloader := communities.NewCodexArchiveDownloader(suite.mockClient, index, communityID, existingArchiveIDs, cancelChan, logger)
downloader.SetPollingInterval(10 * time.Millisecond)
// Track the order in which archives are started (deterministic)
var startOrder []string
downloader.SetOnStartingArchiveDownload(func(hash string, from, to uint64) {
startOrder = append(startOrder, hash)
})
// Track completed archives (non-deterministic due to concurrency)
completedArchives := make(map[string]bool)
downloader.SetOnArchiveDownloaded(func(hash string, from, to uint64) {
completedArchives[hash] = true
})
// Initial state verification
assert.Equal(suite.T(), 3, downloader.GetTotalArchivesCount(), "Should have 3 total archives")
assert.Equal(suite.T(), 0, downloader.GetTotalDownloadedArchivesCount(), "Should start with 0 downloaded")
assert.False(suite.T(), downloader.IsDownloadComplete(), "Should not be complete initially")
// Start download
downloader.StartDownload()
// Wait for all downloads to complete
require.Eventually(suite.T(), func() bool {
return downloader.IsDownloadComplete()
}, 10*time.Second, 100*time.Millisecond, "All downloads should complete within 10 seconds")
// Final state verification - testify makes these checks very readable
assert.True(suite.T(), downloader.IsDownloadComplete(), "Download should be complete")
assert.Equal(suite.T(), 3, downloader.GetTotalDownloadedArchivesCount(), "Should have downloaded all 3 archives")
// Verify all archives were processed
assert.Len(suite.T(), completedArchives, 3, "Should have completed exactly 3 archives")
assert.Contains(suite.T(), completedArchives, "archive-1", "Should have completed archive-1")
assert.Contains(suite.T(), completedArchives, "archive-2", "Should have completed archive-2")
assert.Contains(suite.T(), completedArchives, "archive-3", "Should have completed archive-3")
// Verify sorting: archives should be started in most-recent-first order (deterministic)
// This tests the internal sorting logic before concurrency begins
expectedStartOrder := []string{"archive-3", "archive-2", "archive-1"}
assert.Equal(suite.T(), expectedStartOrder, startOrder, "Archives should be started in most-recent-first order")
suite.T().Log("✅ Multiple archives test passed")
suite.T().Logf(" - Completed %d out of %d archives", len(completedArchives), 3)
suite.T().Logf(" - Start order (sorted): %v", startOrder)
}
func (suite *CodexArchiveDownloaderTestifySuite) TestCancellationDuringTriggerDownload() {
// Test that cancellation during TriggerDownloadWithContext is handled properly
communityID := "test-community"
existingArchiveIDs := []string{} // No existing archives
cancelChan := make(chan struct{})
// Mock TriggerDownloadWithContext to simulate a cancellation error
suite.mockClient.EXPECT().
TriggerDownloadWithContext(gomock.Any(), "test-cid-1").
Return(nil, assert.AnError). // Return a generic error to simulate failure
Times(1)
// No HasCid calls should be made since TriggerDownload fails
// (this is the key test - we shouldn't proceed to polling)
// Create downloader with mock client
logger := zap.NewNop() // No-op logger for tests
downloader := communities.NewCodexArchiveDownloader(suite.mockClient, suite.index, communityID, existingArchiveIDs, cancelChan, logger)
downloader.SetPollingInterval(10 * time.Millisecond)
// Track callbacks - onArchiveDownloaded should NOT be called on failure
var callbackInvoked bool
var startCallbackInvoked bool
downloader.SetOnArchiveDownloaded(func(hash string, from, to uint64) {
callbackInvoked = true
})
downloader.SetOnStartingArchiveDownload(func(hash string, from, to uint64) {
startCallbackInvoked = true
})
// Start the download
downloader.StartDownload()
// Wait a bit to ensure the goroutine has time to complete
time.Sleep(200 * time.Millisecond)
// Verify the state - download should be marked complete (no pending downloads)
// but no archives should be successfully downloaded
assert.True(suite.T(), startCallbackInvoked, "Start callback should be invoked")
assert.False(suite.T(), callbackInvoked, "Success callback should NOT be invoked on failure")
assert.Equal(suite.T(), 0, downloader.GetTotalDownloadedArchivesCount(), "No archives should be downloaded on failure")
assert.True(suite.T(), downloader.IsDownloadComplete(), "Download should be complete (no pending downloads)")
assert.Equal(suite.T(), 0, downloader.GetPendingArchivesCount(), "No archives should be pending")
suite.T().Log("✅ Cancellation during trigger download test passed")
suite.T().Log(" - TriggerDownload failed as expected")
suite.T().Log(" - No polling occurred (as intended)")
suite.T().Log(" - Success callback was NOT invoked")
}
func (suite *CodexArchiveDownloaderTestifySuite) TestCancellationDuringPolling() {
// Test that cancellation during the polling phase is handled properly
communityID := "test-community"
existingArchiveIDs := []string{} // No existing archives
cancelChan := make(chan struct{})
// Mock successful TriggerDownload
suite.mockClient.EXPECT().
TriggerDownloadWithContext(gomock.Any(), "test-cid-1").
Return(&communities.CodexManifest{CID: "test-cid-1"}, nil).
Times(1)
// Mock polling - allow multiple calls, but we'll cancel before completion
suite.mockClient.EXPECT().
HasCid("test-cid-1").
Return(false, nil).
AnyTimes() // Allow multiple calls since timing is unpredictable
// Create downloader with mock client
logger := zap.NewNop() // No-op logger for tests
downloader := communities.NewCodexArchiveDownloader(suite.mockClient, suite.index, communityID, existingArchiveIDs, cancelChan, logger)
downloader.SetPollingInterval(50 * time.Millisecond) // Longer interval to allow cancellation
downloader.SetPollingTimeout(1 * time.Second) // Short timeout for test (instead of 30s)
// Track callbacks
var successCallbackInvoked bool
var startCallbackInvoked bool
downloader.SetOnArchiveDownloaded(func(hash string, from, to uint64) {
successCallbackInvoked = true
})
downloader.SetOnStartingArchiveDownload(func(hash string, from, to uint64) {
startCallbackInvoked = true
})
// Start the download
downloader.StartDownload()
// Wait for the download to start and first poll to occur
time.Sleep(100 * time.Millisecond)
// Verify initial state
assert.True(suite.T(), startCallbackInvoked, "Start callback should be invoked")
assert.Equal(suite.T(), 1, downloader.GetPendingArchivesCount(), "Should have 1 pending download")
assert.False(suite.T(), downloader.IsDownloadComplete(), "Download should not be complete yet")
// Cancel the entire operation
close(cancelChan)
// Wait for cancellation to propagate
require.Eventually(suite.T(), func() bool {
return downloader.IsDownloadComplete()
}, 2*time.Second, 50*time.Millisecond, "Download should complete after cancellation")
// Verify final state
assert.False(suite.T(), successCallbackInvoked, "Success callback should NOT be invoked on cancellation")
assert.Equal(suite.T(), 0, downloader.GetPendingArchivesCount(), "No archives should be pending after cancellation")
assert.True(suite.T(), downloader.IsCancelled(), "Downloader should be marked as cancelled")
suite.T().Log("✅ Cancellation during polling test passed")
suite.T().Log(" - TriggerDownload succeeded")
suite.T().Log(" - Polling started but was cancelled")
suite.T().Log(" - Success callback was NOT invoked")
suite.T().Log(" - Download marked as cancelled")
}
func (suite *CodexArchiveDownloaderTestifySuite) TestPollingTimeout() {
// Test that polling timeout is handled properly (no success callback)
communityID := "test-community"
existingArchiveIDs := []string{} // No existing archives
cancelChan := make(chan struct{})
// Mock successful TriggerDownload
suite.mockClient.EXPECT().
TriggerDownloadWithContext(gomock.Any(), "test-cid-1").
Return(&communities.CodexManifest{CID: "test-cid-1"}, nil).
Times(1)
// Mock polling to always return false (simulating timeout)
suite.mockClient.EXPECT().
HasCid("test-cid-1").
Return(false, nil).
AnyTimes() // Will be called multiple times until timeout
// Create downloader with mock client
logger := zap.NewNop() // No-op logger for tests
downloader := communities.NewCodexArchiveDownloader(suite.mockClient, suite.index, communityID, existingArchiveIDs, cancelChan, logger)
downloader.SetPollingInterval(10 * time.Millisecond) // Fast polling for test
downloader.SetPollingTimeout(100 * time.Millisecond) // Short timeout for test (instead of 30s)
// Track callbacks
var successCallbackInvoked bool
var startCallbackInvoked bool
downloader.SetOnArchiveDownloaded(func(hash string, from, to uint64) {
successCallbackInvoked = true
})
downloader.SetOnStartingArchiveDownload(func(hash string, from, to uint64) {
startCallbackInvoked = true
})
// Start the download
downloader.StartDownload()
// Wait for timeout (100ms configured timeout)
// We'll wait a bit longer to ensure timeout occurs
require.Eventually(suite.T(), func() bool {
return downloader.IsDownloadComplete()
}, 500*time.Millisecond, 50*time.Millisecond, "Download should complete after timeout")
// Verify state after timeout
assert.True(suite.T(), startCallbackInvoked, "Start callback should be invoked")
assert.False(suite.T(), successCallbackInvoked, "Success callback should NOT be invoked on timeout")
assert.Equal(suite.T(), 0, downloader.GetTotalDownloadedArchivesCount(), "No archives should be downloaded on timeout")
assert.Equal(suite.T(), 0, downloader.GetPendingArchivesCount(), "No archives should be pending after timeout")
assert.True(suite.T(), downloader.IsDownloadComplete(), "Download should be complete")
suite.T().Log("✅ Polling timeout test passed")
suite.T().Log(" - TriggerDownload succeeded")
suite.T().Log(" - Polling timed out after 100ms (fast test)")
suite.T().Log(" - Success callback was NOT invoked")
}
func (suite *CodexArchiveDownloaderTestifySuite) TestWithExistingArchives() {
// Test with some archives already downloaded (existing archive IDs)
index := &protobuf.CodexWakuMessageArchiveIndex{
Archives: map[string]*protobuf.CodexWakuMessageArchiveIndexMetadata{
"archive-1": {
Cid: "cid-1",
Metadata: &protobuf.WakuMessageArchiveMetadata{From: 1000, To: 2000},
},
"archive-2": {
Cid: "cid-2",
Metadata: &protobuf.WakuMessageArchiveMetadata{From: 2000, To: 3000},
},
"archive-3": {
Cid: "cid-3",
Metadata: &protobuf.WakuMessageArchiveMetadata{From: 3000, To: 4000},
},
},
}
communityID := "test-community"
// Simulate that we already have archive-1 and archive-3
existingArchiveIDs := []string{"archive-1", "archive-3"}
cancelChan := make(chan struct{})
// Only archive-2 should be downloaded (not in existingArchiveIDs)
suite.mockClient.EXPECT().
TriggerDownloadWithContext(gomock.Any(), "cid-2").
Return(&communities.CodexManifest{CID: "cid-2"}, nil).
Times(1) // Only one call expected
// Only archive-2 should be polled
gomock.InOrder(
suite.mockClient.EXPECT().HasCid("cid-2").Return(false, nil),
suite.mockClient.EXPECT().HasCid("cid-2").Return(true, nil),
)
// Create downloader with existing archives
logger := zap.NewNop() // No-op logger for tests
downloader := communities.NewCodexArchiveDownloader(suite.mockClient, index, communityID, existingArchiveIDs, cancelChan, logger)
downloader.SetPollingInterval(10 * time.Millisecond)
// Track which archives are started and completed
var startedArchives []string
var completedArchives []string
downloader.SetOnStartingArchiveDownload(func(hash string, from, to uint64) {
startedArchives = append(startedArchives, hash)
})
downloader.SetOnArchiveDownloaded(func(hash string, from, to uint64) {
completedArchives = append(completedArchives, hash)
})
// Verify initial state - should start with 2 existing archives counted
assert.Equal(suite.T(), 3, downloader.GetTotalArchivesCount(), "Should have 3 total archives")
assert.Equal(suite.T(), 2, downloader.GetTotalDownloadedArchivesCount(), "Should start with 2 existing archives")
assert.False(suite.T(), downloader.IsDownloadComplete(), "Should not be complete initially")
// Start download
downloader.StartDownload()
// Wait for download to complete
require.Eventually(suite.T(), func() bool {
return downloader.IsDownloadComplete()
}, 5*time.Second, 100*time.Millisecond, "Download should complete within 5 seconds")
// Verify final state
assert.True(suite.T(), downloader.IsDownloadComplete(), "Download should be complete")
assert.Equal(suite.T(), 3, downloader.GetTotalDownloadedArchivesCount(), "Should have 3 total downloaded (2 existing + 1 new)")
// Verify only missing archive was processed
assert.Len(suite.T(), startedArchives, 1, "Should have started exactly 1 archive download")
assert.Contains(suite.T(), startedArchives, "archive-2", "Should have started archive-2")
assert.NotContains(suite.T(), startedArchives, "archive-1", "Should NOT have started archive-1 (existing)")
assert.NotContains(suite.T(), startedArchives, "archive-3", "Should NOT have started archive-3 (existing)")
assert.Len(suite.T(), completedArchives, 1, "Should have completed exactly 1 archive download")
assert.Contains(suite.T(), completedArchives, "archive-2", "Should have completed archive-2")
suite.T().Log("✅ Existing archives test passed")
suite.T().Logf(" - Started with %d existing archives", len(existingArchiveIDs))
suite.T().Logf(" - Downloaded %d missing archives", len(completedArchives))
suite.T().Logf(" - Final count: %d total", downloader.GetTotalDownloadedArchivesCount())
}
// Run the test suite
func TestCodexArchiveDownloaderSuite(t *testing.T) {
suite.Run(t, new(CodexArchiveDownloaderTestifySuite))
}