Add streaming, subscribe, cancel cleanup, and recovery component tests

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/claude-agent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
Alexander
2026-05-11 15:54:25 +02:00
parent be859e87c0
commit 93821ab214
5 changed files with 1375 additions and 1 deletions
+560 -1
View File
@@ -699,7 +699,7 @@ func TestMonitorAlbumStream_AutomaticQBitDown(t *testing.T) {
var downloadCount int
err := suite.pool.QueryRow(ctx, "SELECT COUNT(*) FROM downloads").Scan(&downloadCount)
require.NoError(t, err)
assert.Equal(t, 0, downloadCount)
assert.Equal(t, 1, downloadCount, "download record should exist even when qBit fails (DB save happens before qBit)")
}
func TestMonitorAlbumStream_AutomaticTorrentExists(t *testing.T) {
@@ -1525,3 +1525,562 @@ func TestMonitorAlbumStream_InvalidPromptId(t *testing.T) {
}
assert.True(t, hasError, "expected error for invalid prompt ID")
}
func TestMonitorAlbumStream_ManualCancelBeforeQBit(t *testing.T) {
suite := setupSuite(t)
cleanTables(t, suite.pool)
suite.mocks.metadata.GetAlbumFunc = func(ctx context.Context, in *metadataPb.GetAlbumRequest, opts ...grpc.CallOption) (*metadataPb.GetAlbumResponse, error) {
return &metadataPb.GetAlbumResponse{
Album: newMetadataAlbum("test-album-ext-id", "Test Album", "artist-ext-id", "Test Artist"),
}, nil
}
indexerCalled := make(chan struct{})
suite.mocks.indexer.SearchFunc = func(query string, limit int32, idx string) (*indexer.SearchResponse, error) {
close(indexerCalled)
time.Sleep(2 * time.Second)
return newSearchResponse(
newSearchItem("Test Artist - Test Album [FLAC]", 50, "magnet:?xt=urn:btih:abc123"),
), nil
}
var addMagnetCalled bool
suite.mocks.torrent.AddMagnetFunc = func(magnetURI string, savePath string) error {
addMagnetCalled = true
return nil
}
var deleteTorrentCalled bool
suite.mocks.torrent.DeleteTorrentFunc = func(hash string) error {
deleteTorrentCalled = true
return nil
}
ctx, cancel := context.WithCancel(context.Background())
stream, err := suite.client.MonitorAlbumStream(ctx)
require.NoError(t, err)
err = stream.Send(&pb.MonitorAlbumStreamRequest{
Message: &pb.MonitorAlbumStreamRequest_Start{
Start: &pb.StartMonitorRequest{
AlbumId: "test-album-ext-id",
Quality: pb.QualityType_QUALITY_LOSSLESS,
Mode: pb.InteractionMode_INTERACTION_MODE_MANUAL,
},
},
})
require.NoError(t, err)
<-indexerCalled
cancel()
for {
_, err = stream.Recv()
if err != nil {
break
}
}
assert.False(t, addMagnetCalled, "AddMagnet should not be called when cancelled before qbit")
assert.False(t, deleteTorrentCalled, "DeleteTorrent should not be called when no torrent was added")
bgCtx := context.Background()
var downloadCount int
err = suite.pool.QueryRow(bgCtx, "SELECT COUNT(*) FROM downloads").Scan(&downloadCount)
require.NoError(t, err)
assert.Equal(t, 0, downloadCount)
}
func TestMonitorAlbumStream_ManualCancelAfterQBit(t *testing.T) {
suite := setupSuite(t)
cleanTables(t, suite.pool)
suite.mocks.metadata.GetAlbumFunc = func(ctx context.Context, in *metadataPb.GetAlbumRequest, opts ...grpc.CallOption) (*metadataPb.GetAlbumResponse, error) {
return &metadataPb.GetAlbumResponse{
Album: newMetadataAlbum("test-album-ext-id", "Test Album", "artist-ext-id", "Test Artist"),
}, nil
}
suite.mocks.indexer.SearchFunc = func(query string, limit int32, idx string) (*indexer.SearchResponse, error) {
return newSearchResponse(
newSearchItem("Test Artist - Test Album [FLAC]", 50, "magnet:?xt=urn:btih:abc123"),
), nil
}
suite.mocks.magnet.ResolveFunc = func(magnetURI string) ([]byte, error) {
return newTorrentData(), nil
}
suite.mocks.torrent.FindFunc = func(opts torrent.FindOptions) ([]torrent.TorrentInfo, error) {
return []torrent.TorrentInfo{}, nil
}
torrentAddedCh := make(chan struct{})
suite.mocks.torrent.AddMagnetFunc = func(magnetURI string, savePath string) error {
close(torrentAddedCh)
return nil
}
var deletedHash string
suite.mocks.torrent.DeleteTorrentFunc = func(hash string) error {
deletedHash = hash
return nil
}
ctx, cancel := context.WithCancel(context.Background())
stream, err := suite.client.MonitorAlbumStream(ctx)
require.NoError(t, err)
err = stream.Send(&pb.MonitorAlbumStreamRequest{
Message: &pb.MonitorAlbumStreamRequest_Start{
Start: &pb.StartMonitorRequest{
AlbumId: "test-album-ext-id",
Quality: pb.QualityType_QUALITY_LOSSLESS,
Mode: pb.InteractionMode_INTERACTION_MODE_MANUAL,
},
},
})
require.NoError(t, err)
_, prompt, err := collectUntilPrompt(t, stream, 0)
require.NoError(t, err)
require.NotNil(t, prompt)
assert.Equal(t, pb.PromptType_PROMPT_TYPE_CONFIRM, prompt.Type)
sendDecision(t, stream, prompt.PromptId, &pb.UserDecision{
Decision: &pb.UserDecision_Confirm{Confirm: true},
})
select {
case <-torrentAddedCh:
case <-time.After(5 * time.Second):
t.Fatal("timeout waiting for torrent to be added")
}
cancel()
for {
_, err = stream.Recv()
if err != nil {
break
}
}
time.Sleep(100 * time.Millisecond)
assert.NotEmpty(t, deletedHash, "DeleteTorrent should be called with the torrent hash")
assert.Equal(t, "6ff7af15d0745a3e29d1b9620191cfe01ad3cc70", deletedHash)
bgCtx := context.Background()
var downloadState string
err = suite.pool.QueryRow(bgCtx, "SELECT state FROM downloads WHERE qbit_hash = $1", deletedHash).Scan(&downloadState)
if err == nil {
assert.Equal(t, "cancelled", downloadState)
}
}
func TestMonitorAlbumStream_AutomaticFireAndForget(t *testing.T) {
suite := setupSuite(t)
cleanTables(t, suite.pool)
suite.mocks.metadata.GetAlbumFunc = func(ctx context.Context, in *metadataPb.GetAlbumRequest, opts ...grpc.CallOption) (*metadataPb.GetAlbumResponse, error) {
return &metadataPb.GetAlbumResponse{
Album: newMetadataAlbum("test-album-ext-id", "Test Album", "artist-ext-id", "Test Artist"),
}, nil
}
suite.mocks.indexer.SearchFunc = func(query string, limit int32, idx string) (*indexer.SearchResponse, error) {
time.Sleep(1 * time.Second)
return newSearchResponse(
newSearchItem("Test Artist - Test Album [FLAC]", 50, "magnet:?xt=urn:btih:abc123&dn=test"),
), nil
}
suite.mocks.magnet.ResolveFunc = func(magnetURI string) ([]byte, error) {
return newTorrentData(), nil
}
suite.mocks.torrent.FindFunc = func(opts torrent.FindOptions) ([]torrent.TorrentInfo, error) {
return []torrent.TorrentInfo{}, nil
}
suite.mocks.torrent.AddMagnetFunc = func(magnetURI string, savePath string) error {
return nil
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
stream, err := suite.client.MonitorAlbumStream(ctx)
require.NoError(t, err)
err = stream.Send(&pb.MonitorAlbumStreamRequest{
Message: &pb.MonitorAlbumStreamRequest_Start{
Start: &pb.StartMonitorRequest{
AlbumId: "test-album-ext-id",
Quality: pb.QualityType_QUALITY_LOSSLESS,
Mode: pb.InteractionMode_INTERACTION_MODE_AUTOMATIC,
},
},
})
require.NoError(t, err)
var statusCount int
for i := 0; i < 3; i++ {
msg, err := stream.Recv()
if err != nil {
break
}
if msg.GetStatus() != nil {
statusCount++
}
}
assert.GreaterOrEqual(t, statusCount, 1, "should receive at least one status before disconnect")
cancel()
time.Sleep(3 * time.Second)
bgCtx := context.Background()
var downloadCount int
err = suite.pool.QueryRow(bgCtx, "SELECT COUNT(*) FROM downloads").Scan(&downloadCount)
require.NoError(t, err)
assert.Equal(t, 1, downloadCount, "workflow should complete and create download despite client disconnect")
}
func TestMonitorAlbumStream_AutomaticDuplicateSubscribes(t *testing.T) {
suite := setupSuite(t)
cleanTables(t, suite.pool)
suite.mocks.metadata.GetAlbumFunc = func(ctx context.Context, in *metadataPb.GetAlbumRequest, opts ...grpc.CallOption) (*metadataPb.GetAlbumResponse, error) {
return &metadataPb.GetAlbumResponse{
Album: newMetadataAlbum("test-album-ext-id", "Test Album", "artist-ext-id", "Test Artist"),
}, nil
}
suite.mocks.indexer.SearchFunc = func(query string, limit int32, idx string) (*indexer.SearchResponse, error) {
time.Sleep(500 * time.Millisecond)
return newSearchResponse(
newSearchItem("Test Artist - Test Album [FLAC]", 50, "magnet:?xt=urn:btih:abc123&dn=test"),
), nil
}
suite.mocks.magnet.ResolveFunc = func(magnetURI string) ([]byte, error) {
return newTorrentData(), nil
}
suite.mocks.torrent.FindFunc = func(opts torrent.FindOptions) ([]torrent.TorrentInfo, error) {
return []torrent.TorrentInfo{}, nil
}
suite.mocks.torrent.AddMagnetFunc = func(magnetURI string, savePath string) error {
return nil
}
ctx1, cancel1 := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel1()
stream1, err := suite.client.MonitorAlbumStream(ctx1)
require.NoError(t, err)
err = stream1.Send(&pb.MonitorAlbumStreamRequest{
Message: &pb.MonitorAlbumStreamRequest_Start{
Start: &pb.StartMonitorRequest{
AlbumId: "test-album-ext-id",
Quality: pb.QualityType_QUALITY_LOSSLESS,
Mode: pb.InteractionMode_INTERACTION_MODE_AUTOMATIC,
},
},
})
require.NoError(t, err)
time.Sleep(100 * time.Millisecond)
ctx2, cancel2 := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel2()
stream2, err := suite.client.MonitorAlbumStream(ctx2)
require.NoError(t, err)
err = stream2.Send(&pb.MonitorAlbumStreamRequest{
Message: &pb.MonitorAlbumStreamRequest_Start{
Start: &pb.StartMonitorRequest{
AlbumId: "test-album-ext-id",
Quality: pb.QualityType_QUALITY_LOSSLESS,
Mode: pb.InteractionMode_INTERACTION_MODE_AUTOMATIC,
},
},
})
require.NoError(t, err)
var stream1Events, stream2Events int
done1 := make(chan struct{})
go func() {
defer close(done1)
for {
msg, err := stream1.Recv()
if err != nil {
return
}
if msg.GetStatus() != nil || msg.GetResult() != nil || msg.GetError() != nil {
stream1Events++
}
if msg.GetResult() != nil || msg.GetError() != nil {
return
}
}
}()
done2 := make(chan struct{})
go func() {
defer close(done2)
for {
msg, err := stream2.Recv()
if err != nil {
return
}
if msg.GetStatus() != nil || msg.GetResult() != nil || msg.GetError() != nil {
stream2Events++
}
if msg.GetResult() != nil || msg.GetError() != nil {
return
}
}
}()
select {
case <-done1:
case <-time.After(10 * time.Second):
t.Fatal("stream1 timed out")
}
select {
case <-done2:
case <-time.After(10 * time.Second):
t.Fatal("stream2 timed out")
}
assert.Greater(t, stream1Events, 0, "stream1 should receive events")
assert.Greater(t, stream2Events, 0, "stream2 should receive events")
bgCtx := context.Background()
var downloadCount int
err = suite.pool.QueryRow(bgCtx, "SELECT COUNT(*) FROM downloads").Scan(&downloadCount)
require.NoError(t, err)
assert.Equal(t, 1, downloadCount, "only one download should be created despite two subscribers")
}
func TestMonitorAlbumStream_AutomaticReplayOnReconnect(t *testing.T) {
suite := setupSuite(t)
cleanTables(t, suite.pool)
suite.mocks.metadata.GetAlbumFunc = func(ctx context.Context, in *metadataPb.GetAlbumRequest, opts ...grpc.CallOption) (*metadataPb.GetAlbumResponse, error) {
return &metadataPb.GetAlbumResponse{
Album: newMetadataAlbum("test-album-ext-id", "Test Album", "artist-ext-id", "Test Artist"),
}, nil
}
suite.mocks.indexer.SearchFunc = func(query string, limit int32, idx string) (*indexer.SearchResponse, error) {
return newSearchResponse(
newSearchItem("Test Artist - Test Album [FLAC]", 50, "magnet:?xt=urn:btih:abc123&dn=test"),
), nil
}
suite.mocks.magnet.ResolveFunc = func(magnetURI string) ([]byte, error) {
return newTorrentData(), nil
}
suite.mocks.torrent.FindFunc = func(opts torrent.FindOptions) ([]torrent.TorrentInfo, error) {
return []torrent.TorrentInfo{}, nil
}
suite.mocks.torrent.AddMagnetFunc = func(magnetURI string, savePath string) error {
return nil
}
stream1 := startMonitorStream(t, suite.client, &pb.StartMonitorRequest{
AlbumId: "test-album-ext-id",
Quality: pb.QualityType_QUALITY_LOSSLESS,
Mode: pb.InteractionMode_INTERACTION_MODE_AUTOMATIC,
})
messages1 := collectAllMessages(t, stream1, 0)
var hasResult1 bool
for _, msg := range messages1 {
if msg.GetResult() != nil {
hasResult1 = true
break
}
}
require.True(t, hasResult1, "first workflow should complete")
time.Sleep(200 * time.Millisecond)
stream2 := startMonitorStream(t, suite.client, &pb.StartMonitorRequest{
AlbumId: "test-album-ext-id",
Quality: pb.QualityType_QUALITY_LOSSLESS,
Mode: pb.InteractionMode_INTERACTION_MODE_AUTOMATIC,
})
messages2 := collectAllMessages(t, stream2, 0)
var hasResult2 bool
for _, msg := range messages2 {
if msg.GetResult() != nil {
hasResult2 = true
break
}
}
assert.True(t, hasResult2, "new workflow should start and complete after registry cleanup")
bgCtx := context.Background()
var eventCount int
err := suite.pool.QueryRow(bgCtx, "SELECT COUNT(*) FROM album_events").Scan(&eventCount)
require.NoError(t, err)
assert.Greater(t, eventCount, len(messages1), "should have events from both runs")
}
func TestMonitorAlbumStream_ManualCancelAfterDownloadSaved(t *testing.T) {
suite := setupSuite(t)
cleanTables(t, suite.pool)
suite.mocks.metadata.GetAlbumFunc = func(ctx context.Context, in *metadataPb.GetAlbumRequest, opts ...grpc.CallOption) (*metadataPb.GetAlbumResponse, error) {
return &metadataPb.GetAlbumResponse{
Album: newMetadataAlbum("test-album-ext-id", "Test Album", "artist-ext-id", "Test Artist"),
}, nil
}
suite.mocks.indexer.SearchFunc = func(query string, limit int32, idx string) (*indexer.SearchResponse, error) {
return newSearchResponse(
newSearchItem("Test Artist - Test Album [FLAC]", 50, "magnet:?xt=urn:btih:abc123"),
), nil
}
suite.mocks.magnet.ResolveFunc = func(magnetURI string) ([]byte, error) {
return newTorrentData(), nil
}
suite.mocks.torrent.FindFunc = func(opts torrent.FindOptions) ([]torrent.TorrentInfo, error) {
return []torrent.TorrentInfo{}, nil
}
savingCh := make(chan struct{})
suite.mocks.torrent.AddMagnetFunc = func(magnetURI string, savePath string) error {
close(savingCh)
time.Sleep(2 * time.Second)
return nil
}
var deleteTorrentCalled bool
suite.mocks.torrent.DeleteTorrentFunc = func(hash string) error {
deleteTorrentCalled = true
return nil
}
ctx, cancel := context.WithCancel(context.Background())
stream, err := suite.client.MonitorAlbumStream(ctx)
require.NoError(t, err)
err = stream.Send(&pb.MonitorAlbumStreamRequest{
Message: &pb.MonitorAlbumStreamRequest_Start{
Start: &pb.StartMonitorRequest{
AlbumId: "test-album-ext-id",
Quality: pb.QualityType_QUALITY_LOSSLESS,
Mode: pb.InteractionMode_INTERACTION_MODE_MANUAL,
},
},
})
require.NoError(t, err)
_, prompt, err := collectUntilPrompt(t, stream, 0)
require.NoError(t, err)
require.NotNil(t, prompt)
sendDecision(t, stream, prompt.PromptId, &pb.UserDecision{
Decision: &pb.UserDecision_Confirm{Confirm: true},
})
select {
case <-savingCh:
case <-time.After(5 * time.Second):
t.Fatal("timeout waiting for saving to start")
}
cancel()
for {
_, err = stream.Recv()
if err != nil {
break
}
}
time.Sleep(3 * time.Second)
assert.True(t, deleteTorrentCalled, "DeleteTorrent should be called during cleanup")
bgCtx := context.Background()
var downloadState string
err = suite.pool.QueryRow(bgCtx, "SELECT state FROM downloads WHERE qbit_hash = $1", "6ff7af15d0745a3e29d1b9620191cfe01ad3cc70").Scan(&downloadState)
if err == nil {
assert.Equal(t, "cancelled", downloadState)
}
}
func TestMonitorAlbumStream_ManualDisconnectCancels(t *testing.T) {
suite := setupSuite(t)
cleanTables(t, suite.pool)
suite.mocks.metadata.GetAlbumFunc = func(ctx context.Context, in *metadataPb.GetAlbumRequest, opts ...grpc.CallOption) (*metadataPb.GetAlbumResponse, error) {
return &metadataPb.GetAlbumResponse{
Album: newMetadataAlbum("test-album-ext-id", "Test Album", "artist-ext-id", "Test Artist"),
}, nil
}
suite.mocks.indexer.SearchFunc = func(query string, limit int32, idx string) (*indexer.SearchResponse, error) {
time.Sleep(2 * time.Second)
return newSearchResponse(
newSearchItem("Test Artist - Test Album [FLAC]", 50, "magnet:?xt=urn:btih:abc123"),
), nil
}
ctx, cancel := context.WithCancel(context.Background())
stream, err := suite.client.MonitorAlbumStream(ctx)
require.NoError(t, err)
err = stream.Send(&pb.MonitorAlbumStreamRequest{
Message: &pb.MonitorAlbumStreamRequest_Start{
Start: &pb.StartMonitorRequest{
AlbumId: "test-album-ext-id",
Quality: pb.QualityType_QUALITY_LOSSLESS,
Mode: pb.InteractionMode_INTERACTION_MODE_MANUAL,
},
},
})
require.NoError(t, err)
var gotStatus bool
for i := 0; i < 3; i++ {
msg, err := stream.Recv()
if err != nil {
break
}
if msg.GetStatus() != nil {
gotStatus = true
break
}
}
assert.True(t, gotStatus, "should receive at least one status before disconnect")
cancel()
_, err = stream.Recv()
assert.Error(t, err, "stream.Recv should return error after disconnect")
}