Files
music-agregator/test/component/monitor_album_stream_test.go
T

1528 lines
47 KiB
Go

package component
import (
"context"
"io"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
metadataPb "homelab.lan/music-agregator/gen/metadata/v1"
pb "homelab.lan/music-agregator/gen/music_agregator/v1"
"homelab.lan/music-agregator/internal"
"homelab.lan/music-agregator/internal/indexer"
"homelab.lan/music-agregator/internal/torrent"
)
const defaultStreamTimeout = 5 * time.Second
func startMonitorStream(
t *testing.T,
client pb.MusicAgregatorServiceClient,
req *pb.StartMonitorRequest,
) grpc.BidiStreamingClient[pb.MonitorAlbumStreamRequest, pb.MonitorAlbumStreamResponse] {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), defaultStreamTimeout)
t.Cleanup(cancel)
stream, err := client.MonitorAlbumStream(ctx)
require.NoError(t, err)
err = stream.Send(&pb.MonitorAlbumStreamRequest{
Message: &pb.MonitorAlbumStreamRequest_Start{Start: req},
})
require.NoError(t, err)
return stream
}
func collectAllMessages(
t *testing.T,
stream grpc.BidiStreamingClient[pb.MonitorAlbumStreamRequest, pb.MonitorAlbumStreamResponse],
timeout time.Duration,
) []*pb.MonitorAlbumStreamResponse {
t.Helper()
if timeout == 0 {
timeout = defaultStreamTimeout
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
var messages []*pb.MonitorAlbumStreamResponse
done := make(chan struct{})
go func() {
defer close(done)
for {
msg, err := stream.Recv()
if err == io.EOF {
return
}
if err != nil {
return
}
messages = append(messages, msg)
}
}()
select {
case <-done:
case <-ctx.Done():
t.Fatalf("collectAllMessages timed out after %v", timeout)
}
return messages
}
func collectUntilPrompt(
t *testing.T,
stream grpc.BidiStreamingClient[pb.MonitorAlbumStreamRequest, pb.MonitorAlbumStreamResponse],
timeout time.Duration,
) ([]*pb.MonitorAlbumStreamResponse, *pb.PromptForDecision, error) {
t.Helper()
if timeout == 0 {
timeout = defaultStreamTimeout
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
var messages []*pb.MonitorAlbumStreamResponse
resultChan := make(chan struct {
prompt *pb.PromptForDecision
err error
}, 1)
go func() {
for {
msg, err := stream.Recv()
if err == io.EOF {
resultChan <- struct {
prompt *pb.PromptForDecision
err error
}{nil, io.EOF}
return
}
if err != nil {
resultChan <- struct {
prompt *pb.PromptForDecision
err error
}{nil, err}
return
}
messages = append(messages, msg)
if prompt := msg.GetPrompt(); prompt != nil {
resultChan <- struct {
prompt *pb.PromptForDecision
err error
}{prompt, nil}
return
}
if errUpdate := msg.GetError(); errUpdate != nil {
resultChan <- struct {
prompt *pb.PromptForDecision
err error
}{nil, status.Error(codes.Internal, errUpdate.Message)}
return
}
}
}()
select {
case result := <-resultChan:
return messages, result.prompt, result.err
case <-ctx.Done():
t.Fatalf("collectUntilPrompt timed out after %v", timeout)
return nil, nil, ctx.Err()
}
}
func sendDecision(
t *testing.T,
stream grpc.BidiStreamingClient[pb.MonitorAlbumStreamRequest, pb.MonitorAlbumStreamResponse],
promptID string,
decision *pb.UserDecision,
) {
t.Helper()
decision.PromptId = promptID
err := stream.Send(&pb.MonitorAlbumStreamRequest{
Message: &pb.MonitorAlbumStreamRequest_Decision{Decision: decision},
})
require.NoError(t, err)
}
func assertStatusSequence(t *testing.T, messages []*pb.MonitorAlbumStreamResponse, expectedSteps []pb.MonitorStep) {
t.Helper()
var actualSteps []pb.MonitorStep
for _, msg := range messages {
if status := msg.GetStatus(); status != nil {
actualSteps = append(actualSteps, status.Step)
}
}
assert.Equal(t, expectedSteps, actualSteps, "status step sequence mismatch")
}
func assertContainsStep(t *testing.T, messages []*pb.MonitorAlbumStreamResponse, step pb.MonitorStep) {
t.Helper()
for _, msg := range messages {
if status := msg.GetStatus(); status != nil && status.Step == step {
return
}
}
t.Errorf("expected messages to contain step %v", step)
}
func TestMonitorAlbumStream_AutomaticHappyPath(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"),
newSearchItem("Test Artist - Test Album [MP3 320]", 10, "magnet:?xt=urn:btih:def456&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
}
stream := startMonitorStream(t, suite.client, &pb.StartMonitorRequest{
AlbumId: "test-album-ext-id",
Quality: pb.QualityType_QUALITY_LOSSLESS,
Mode: pb.InteractionMode_INTERACTION_MODE_AUTOMATIC,
})
messages := collectAllMessages(t, stream, 0)
assertStatusSequence(t, messages, []pb.MonitorStep{
pb.MonitorStep_MONITOR_STEP_FETCHING_METADATA,
pb.MonitorStep_MONITOR_STEP_FETCHING_METADATA,
pb.MonitorStep_MONITOR_STEP_CHECKING_OWNED,
pb.MonitorStep_MONITOR_STEP_SEARCHING_INDEXER,
pb.MonitorStep_MONITOR_STEP_PARSING_RESULTS,
pb.MonitorStep_MONITOR_STEP_FILTERING_QUALITY,
pb.MonitorStep_MONITOR_STEP_SELECTING_RELEASE,
pb.MonitorStep_MONITOR_STEP_ADDING_TORRENT,
pb.MonitorStep_MONITOR_STEP_SAVING,
pb.MonitorStep_MONITOR_STEP_COMPLETE,
})
var result *pb.MonitorAlbumResponse
for _, msg := range messages {
if r := msg.GetResult(); r != nil {
result = r
break
}
}
require.NotNil(t, result)
require.NotNil(t, result.Album)
assert.Equal(t, "Test Album", result.Album.Title)
require.NotNil(t, result.Release)
ctx := context.Background()
var torrentCount int
err := suite.pool.QueryRow(ctx, "SELECT COUNT(*) FROM torrents").Scan(&torrentCount)
require.NoError(t, err)
assert.Equal(t, 1, torrentCount)
}
func TestMonitorAlbumStream_AutomaticMetadataUnavailable(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 nil, status.Error(codes.Unavailable, "connection refused")
}
stream := startMonitorStream(t, suite.client, &pb.StartMonitorRequest{
AlbumId: "test-album-ext-id",
Quality: pb.QualityType_QUALITY_LOSSLESS,
Mode: pb.InteractionMode_INTERACTION_MODE_AUTOMATIC,
})
messages := collectAllMessages(t, stream, 0)
var hasError bool
for _, msg := range messages {
if errUpdate := msg.GetError(); errUpdate != nil {
hasError = true
assert.Equal(t, pb.MonitorStep_MONITOR_STEP_FETCHING_METADATA, errUpdate.FailedStep)
break
}
}
assert.True(t, hasError, "expected error message for metadata unavailable")
ctx := context.Background()
var count int
err := suite.pool.QueryRow(ctx, "SELECT COUNT(*) FROM artists").Scan(&count)
require.NoError(t, err)
assert.Equal(t, 0, count)
}
func TestMonitorAlbumStream_AutomaticMetadataNotFound(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 nil, status.Error(codes.NotFound, "album not found")
}
stream := startMonitorStream(t, suite.client, &pb.StartMonitorRequest{
AlbumId: "nonexistent-album",
Quality: pb.QualityType_QUALITY_LOSSLESS,
Mode: pb.InteractionMode_INTERACTION_MODE_AUTOMATIC,
})
messages := collectAllMessages(t, stream, 0)
var hasError bool
for _, msg := range messages {
if errUpdate := msg.GetError(); errUpdate != nil {
hasError = true
assert.Equal(t, pb.MonitorStep_MONITOR_STEP_FETCHING_METADATA, errUpdate.FailedStep)
break
}
}
assert.True(t, hasError, "expected error message for metadata not found")
ctx := context.Background()
var count int
err := suite.pool.QueryRow(ctx, "SELECT COUNT(*) FROM albums").Scan(&count)
require.NoError(t, err)
assert.Equal(t, 0, count)
}
func TestMonitorAlbumStream_AutomaticArtistPersistFails(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: &metadataPb.Album{
Id: "orphan-album-ext-id",
Title: "Orphan Album",
AlbumType: "album",
ReleaseDate: "2024-01-15",
TotalTracks: 10,
Artists: []*metadataPb.ArtistCredit{},
},
}, nil
}
suite.mocks.indexer.SearchFunc = func(query string, limit int32, idx string) (*indexer.SearchResponse, error) {
return newSearchResponse(
newSearchItem("Orphan 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
}
stream := startMonitorStream(t, suite.client, &pb.StartMonitorRequest{
AlbumId: "orphan-album-ext-id",
Quality: pb.QualityType_QUALITY_LOSSLESS,
Mode: pb.InteractionMode_INTERACTION_MODE_AUTOMATIC,
})
messages := collectAllMessages(t, stream, 0)
var result *pb.MonitorAlbumResponse
for _, msg := range messages {
if r := msg.GetResult(); r != nil {
result = r
break
}
}
require.NotNil(t, result)
require.NotNil(t, result.Release)
assert.Nil(t, result.Album)
ctx := context.Background()
var count int
err := suite.pool.QueryRow(ctx, "SELECT COUNT(*) FROM downloads").Scan(&count)
require.NoError(t, err)
assert.Equal(t, 0, count)
}
func TestMonitorAlbumStream_AutomaticAlreadyOwned(t *testing.T) {
suite := setupSuite(t)
cleanTables(t, suite.pool)
ctx := context.Background()
var artistID string
err := suite.pool.QueryRow(ctx, `
INSERT INTO artists (external_id, name, artist_type, country, genres, image_url, monitor_state)
VALUES ('artist-ext-id', 'Test Artist', 'person', 'US', ARRAY['Rock'], 'http://img.com', 'monitored')
RETURNING id
`).Scan(&artistID)
require.NoError(t, err)
var albumID string
err = suite.pool.QueryRow(ctx, `
INSERT INTO albums (external_id, artist_id, title, album_type, total_tracks, total_discs, label, genres, cover_url, monitor_state)
VALUES ('owned-album-ext-id', $1, 'Owned Album', 'album', 10, 1, 'Test Label', ARRAY['Rock'], 'http://cover.com', 'monitored')
RETURNING id
`, artistID).Scan(&albumID)
require.NoError(t, err)
var torrentID string
err = suite.pool.QueryRow(ctx, `
INSERT INTO torrents (album_id, info_hash, tracker, title, format, quality, source, seeders, peers, size)
VALUES ($1, 'existing-hash', 'test-tracker', 'Owned Album FLAC', 'FLAC', '16-44', 'CD', 100, 50, 500000000)
RETURNING id
`, albumID).Scan(&torrentID)
require.NoError(t, err)
_, err = suite.pool.Exec(ctx, `
INSERT INTO downloads (torrent_id, album_id, format, quality, state, qbit_hash, save_path)
VALUES ($1, $2, 'QUALITY_LOSSLESS', '16-44', 'completed', 'existing-hash', '/music/owned')
`, torrentID, albumID)
require.NoError(t, err)
suite.mocks.metadata.GetAlbumFunc = func(ctx context.Context, in *metadataPb.GetAlbumRequest, opts ...grpc.CallOption) (*metadataPb.GetAlbumResponse, error) {
return &metadataPb.GetAlbumResponse{
Album: newMetadataAlbum("owned-album-ext-id", "Owned Album", "artist-ext-id", "Test Artist"),
}, nil
}
var indexerCalled bool
suite.mocks.indexer.SearchFunc = func(query string, limit int32, idx string) (*indexer.SearchResponse, error) {
indexerCalled = true
return newSearchResponse(), nil
}
stream := startMonitorStream(t, suite.client, &pb.StartMonitorRequest{
AlbumId: "owned-album-ext-id",
Quality: pb.QualityType_QUALITY_LOSSLESS,
Mode: pb.InteractionMode_INTERACTION_MODE_AUTOMATIC,
})
messages := collectAllMessages(t, stream, 0)
assertContainsStep(t, messages, pb.MonitorStep_MONITOR_STEP_CHECKING_OWNED)
var result *pb.MonitorAlbumResponse
for _, msg := range messages {
if r := msg.GetResult(); r != nil {
result = r
break
}
}
require.NotNil(t, result)
require.NotNil(t, result.Album)
assert.Equal(t, pb.MonitorState_MONITOR_STATE_MONITORED, result.Album.MonitorState)
assert.Nil(t, result.Release)
assert.False(t, indexerCalled)
}
func TestMonitorAlbumStream_AutomaticIndexerDown(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 nil, assert.AnError
}
stream := startMonitorStream(t, suite.client, &pb.StartMonitorRequest{
AlbumId: "test-album-ext-id",
Quality: pb.QualityType_QUALITY_LOSSLESS,
Mode: pb.InteractionMode_INTERACTION_MODE_AUTOMATIC,
})
messages := collectAllMessages(t, stream, 0)
var hasError bool
for _, msg := range messages {
if errUpdate := msg.GetError(); errUpdate != nil {
hasError = true
assert.Equal(t, pb.MonitorStep_MONITOR_STEP_SEARCHING_INDEXER, errUpdate.FailedStep)
break
}
}
assert.True(t, hasError, "expected error message for indexer down")
}
func TestMonitorAlbumStream_AutomaticNoResults(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(), nil
}
stream := startMonitorStream(t, suite.client, &pb.StartMonitorRequest{
AlbumId: "test-album-ext-id",
Quality: pb.QualityType_QUALITY_LOSSLESS,
Mode: pb.InteractionMode_INTERACTION_MODE_AUTOMATIC,
})
messages := collectAllMessages(t, stream, 0)
assertContainsStep(t, messages, pb.MonitorStep_MONITOR_STEP_COMPLETE)
var result *pb.MonitorAlbumResponse
for _, msg := range messages {
if r := msg.GetResult(); r != nil {
result = r
break
}
}
require.NotNil(t, result)
require.NotNil(t, result.Album)
assert.Nil(t, result.Release)
ctx := context.Background()
var count int
err := suite.pool.QueryRow(ctx, "SELECT COUNT(*) FROM torrents").Scan(&count)
require.NoError(t, err)
assert.Equal(t, 0, count)
}
func TestMonitorAlbumStream_AutomaticAllSeedersZero(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 Album FLAC 1", 0, "magnet:?xt=urn:btih:abc1"),
newSearchItem("Test Album FLAC 2", 0, "magnet:?xt=urn:btih:abc2"),
newSearchItem("Test Album FLAC 3", 0, "magnet:?xt=urn:btih:abc3"),
), nil
}
stream := startMonitorStream(t, suite.client, &pb.StartMonitorRequest{
AlbumId: "test-album-ext-id",
Quality: pb.QualityType_QUALITY_LOSSLESS,
Mode: pb.InteractionMode_INTERACTION_MODE_AUTOMATIC,
})
messages := collectAllMessages(t, stream, 0)
var result *pb.MonitorAlbumResponse
for _, msg := range messages {
if r := msg.GetResult(); r != nil {
result = r
break
}
}
require.NotNil(t, result)
require.NotNil(t, result.Album)
assert.Nil(t, result.Release)
}
func TestMonitorAlbumStream_AutomaticAllMagnetsFail(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 Album 1", 50, "magnet:?xt=urn:btih:abc1"),
newSearchItem("Test Album 2", 30, "magnet:?xt=urn:btih:abc2"),
), nil
}
suite.mocks.magnet.ResolveFunc = func(magnetURI string) ([]byte, error) {
return nil, assert.AnError
}
stream := startMonitorStream(t, suite.client, &pb.StartMonitorRequest{
AlbumId: "test-album-ext-id",
Quality: pb.QualityType_QUALITY_LOSSLESS,
Mode: pb.InteractionMode_INTERACTION_MODE_AUTOMATIC,
})
messages := collectAllMessages(t, stream, 0)
var result *pb.MonitorAlbumResponse
for _, msg := range messages {
if r := msg.GetResult(); r != nil {
result = r
break
}
}
require.NotNil(t, result)
require.NotNil(t, result.Album)
assert.Nil(t, result.Release)
}
func TestMonitorAlbumStream_AutomaticNoQualityMatch(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 Album MP3 320", 50, "magnet:?xt=urn:btih:abc1"),
newSearchItem("Test Album MP3 V0", 30, "magnet:?xt=urn:btih:abc2"),
), nil
}
suite.mocks.magnet.ResolveFunc = func(magnetURI string) ([]byte, error) {
return newTorrentDataMP3(), nil
}
stream := startMonitorStream(t, suite.client, &pb.StartMonitorRequest{
AlbumId: "test-album-ext-id",
Quality: pb.QualityType_QUALITY_LOSSLESS,
Mode: pb.InteractionMode_INTERACTION_MODE_AUTOMATIC,
})
messages := collectAllMessages(t, stream, 0)
assertContainsStep(t, messages, pb.MonitorStep_MONITOR_STEP_FILTERING_QUALITY)
var result *pb.MonitorAlbumResponse
for _, msg := range messages {
if r := msg.GetResult(); r != nil {
result = r
break
}
}
require.NotNil(t, result)
require.NotNil(t, result.Album)
assert.Nil(t, result.Release)
}
func TestMonitorAlbumStream_AutomaticQBitDown(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 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 nil, assert.AnError
}
suite.mocks.torrent.AddMagnetFunc = func(magnetURI string, savePath string) error {
return assert.AnError
}
stream := startMonitorStream(t, suite.client, &pb.StartMonitorRequest{
AlbumId: "test-album-ext-id",
Quality: pb.QualityType_QUALITY_LOSSLESS,
Mode: pb.InteractionMode_INTERACTION_MODE_AUTOMATIC,
})
messages := collectAllMessages(t, stream, 0)
var hasError bool
for _, msg := range messages {
if errUpdate := msg.GetError(); errUpdate != nil {
hasError = true
assert.Equal(t, pb.MonitorStep_MONITOR_STEP_ADDING_TORRENT, errUpdate.FailedStep)
break
}
}
assert.True(t, hasError, "expected error message for qbit down")
ctx := context.Background()
var downloadCount int
err := suite.pool.QueryRow(ctx, "SELECT COUNT(*) FROM downloads").Scan(&downloadCount)
require.NoError(t, err)
assert.Equal(t, 0, downloadCount)
}
func TestMonitorAlbumStream_AutomaticTorrentExists(t *testing.T) {
suite := setupSuite(t)
cleanTables(t, suite.pool)
ctx := context.Background()
var artistID string
err := suite.pool.QueryRow(ctx, `
INSERT INTO artists (external_id, name, artist_type, country, genres, image_url, monitor_state)
VALUES ('artist-ext-id', 'Test Artist', 'person', 'US', ARRAY['Rock'], 'http://img.com', 'monitored')
RETURNING id
`).Scan(&artistID)
require.NoError(t, err)
var albumID string
err = suite.pool.QueryRow(ctx, `
INSERT INTO albums (external_id, artist_id, title, album_type, total_tracks, total_discs, label, genres, cover_url, monitor_state)
VALUES ('test-album-ext-id', $1, 'Test Album', 'album', 10, 1, 'Test Label', ARRAY['Rock'], 'http://cover.com', 'unmonitored')
RETURNING id
`, artistID).Scan(&albumID)
require.NoError(t, err)
torrentHash := "6ff7af15d0745a3e29d1b9620191cfe01ad3cc70"
var torrentID string
err = suite.pool.QueryRow(ctx, `
INSERT INTO torrents (album_id, info_hash, tracker, title, format, quality, source, seeders, peers, size)
VALUES ($1, $2, 'test-tracker', 'Test Album FLAC', 'FLAC', '16-44', 'CD', 100, 50, 500000000)
RETURNING id
`, albumID, torrentHash).Scan(&torrentID)
require.NoError(t, err)
_, err = suite.pool.Exec(ctx, `
INSERT INTO downloads (torrent_id, album_id, format, quality, state, qbit_hash)
VALUES ($1, $2, 'FLAC', '16-44', 'completed', $3)
`, torrentID, albumID, torrentHash)
require.NoError(t, err)
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 Album FLAC", 50, "magnet:?xt=urn:btih:"+torrentHash),
), 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{{State: "stalledUP"}}, nil
}
stream := startMonitorStream(t, suite.client, &pb.StartMonitorRequest{
AlbumId: "test-album-ext-id",
Quality: pb.QualityType_QUALITY_LOSSLESS,
Mode: pb.InteractionMode_INTERACTION_MODE_AUTOMATIC,
})
messages := collectAllMessages(t, stream, 0)
var result *pb.MonitorAlbumResponse
for _, msg := range messages {
if r := msg.GetResult(); r != nil {
result = r
break
}
}
require.NotNil(t, result)
require.NotNil(t, result.Release)
var downloadCount int
err = suite.pool.QueryRow(ctx, "SELECT COUNT(*) FROM downloads").Scan(&downloadCount)
require.NoError(t, err)
assert.Equal(t, 1, downloadCount)
}
func TestMonitorAlbumStream_AutomaticDuplicateSkipped(t *testing.T) {
suite := setupSuite(t)
cleanTables(t, suite.pool)
ctx := context.Background()
var artistID string
err := suite.pool.QueryRow(ctx, `
INSERT INTO artists (external_id, name, artist_type, country, genres, image_url, monitor_state)
VALUES ('artist-ext-id', 'Test Artist', 'person', 'US', ARRAY['Rock'], 'http://img.com', 'monitored')
RETURNING id
`).Scan(&artistID)
require.NoError(t, err)
var albumID string
err = suite.pool.QueryRow(ctx, `
INSERT INTO albums (external_id, artist_id, title, album_type, total_tracks, total_discs, label, genres, cover_url, monitor_state)
VALUES ('test-album-ext-id', $1, 'Test Album', 'album', 10, 1, 'Test Label', ARRAY['Rock'], 'http://cover.com', 'unmonitored')
RETURNING id
`, artistID).Scan(&albumID)
require.NoError(t, err)
torrentHash := "6ff7af15d0745a3e29d1b9620191cfe01ad3cc70"
var torrentID string
err = suite.pool.QueryRow(ctx, `
INSERT INTO torrents (album_id, info_hash, tracker, title, format, quality, source, seeders, peers, size)
VALUES ($1, $2, 'test-tracker', 'Test Album FLAC', 'FLAC', '16-44', 'CD', 100, 50, 500000000)
RETURNING id
`, albumID, torrentHash).Scan(&torrentID)
require.NoError(t, err)
_, err = suite.pool.Exec(ctx, `
INSERT INTO downloads (torrent_id, album_id, format, quality, state, qbit_hash)
VALUES ($1, $2, 'FLAC', '16-44', 'downloading', $3)
`, torrentID, albumID, torrentHash)
require.NoError(t, err)
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 Album FLAC", 50, "magnet:?xt=urn:btih:"+torrentHash),
), 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{{State: "downloading"}}, nil
}
stream := startMonitorStream(t, suite.client, &pb.StartMonitorRequest{
AlbumId: "test-album-ext-id",
Quality: pb.QualityType_QUALITY_LOSSLESS,
Mode: pb.InteractionMode_INTERACTION_MODE_AUTOMATIC,
})
messages := collectAllMessages(t, stream, 0)
var result *pb.MonitorAlbumResponse
for _, msg := range messages {
if r := msg.GetResult(); r != nil {
result = r
break
}
}
require.NotNil(t, result)
require.NotNil(t, result.Release)
var downloadCount int
err = suite.pool.QueryRow(ctx, "SELECT COUNT(*) FROM downloads").Scan(&downloadCount)
require.NoError(t, err)
assert.Equal(t, 1, downloadCount)
}
func TestMonitorAlbumStream_ManualSelectTorrents(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 24/96", 50, "magnet:?xt=urn:btih:hash1"),
newSearchItem("Test Artist - Test Album FLAC 16/44", 100, "magnet:?xt=urn:btih:hash2"),
newSearchItem("Test Artist - Test Album MP3", 200, "magnet:?xt=urn:btih:hash3"),
), 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(), defaultStreamTimeout)
defer cancel()
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)
messages, prompt, err := collectUntilPrompt(t, stream, 0)
require.NoError(t, err)
require.NotNil(t, prompt)
assert.Equal(t, pb.PromptType_PROMPT_TYPE_SELECT_MANY, prompt.Type)
require.NotNil(t, prompt.GetSelectMany())
assert.GreaterOrEqual(t, len(prompt.GetSelectMany().Options), 2)
assertContainsStep(t, messages, pb.MonitorStep_MONITOR_STEP_PARSING_RESULTS)
sendDecision(t, stream, prompt.PromptId, &pb.UserDecision{
Decision: &pb.UserDecision_SelectedIds{
SelectedIds: &pb.SelectedIds{
Ids: []string{prompt.GetSelectMany().Options[0].Id, prompt.GetSelectMany().Options[1].Id},
},
},
})
_, selectOnePrompt, err := collectUntilPrompt(t, stream, 0)
require.NoError(t, err)
require.NotNil(t, selectOnePrompt)
assert.Equal(t, pb.PromptType_PROMPT_TYPE_SELECT_ONE, selectOnePrompt.Type)
sendDecision(t, stream, selectOnePrompt.PromptId, &pb.UserDecision{
Decision: &pb.UserDecision_SelectedId{SelectedId: selectOnePrompt.GetSelectOne().DefaultId},
})
_, confirmPrompt, err := collectUntilPrompt(t, stream, 0)
require.NoError(t, err)
require.NotNil(t, confirmPrompt)
assert.Equal(t, pb.PromptType_PROMPT_TYPE_CONFIRM, confirmPrompt.Type)
sendDecision(t, stream, confirmPrompt.PromptId, &pb.UserDecision{
Decision: &pb.UserDecision_Confirm{Confirm: true},
})
remaining := collectAllMessages(t, stream, 0)
var result *pb.MonitorAlbumResponse
for _, msg := range remaining {
if r := msg.GetResult(); r != nil {
result = r
break
}
}
require.NotNil(t, result)
}
func TestMonitorAlbumStream_ManualSelectRelease(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 24/96", 50, "magnet:?xt=urn:btih:hash1"),
newSearchItem("Test Artist - Test Album FLAC 16/44", 100, "magnet:?xt=urn:btih:hash2"),
), 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(), defaultStreamTimeout)
defer cancel()
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)
messages, prompt, err := collectUntilPrompt(t, stream, 0)
require.NoError(t, err)
require.NotNil(t, prompt)
assert.Equal(t, pb.PromptType_PROMPT_TYPE_SELECT_MANY, prompt.Type)
require.NotNil(t, prompt.GetSelectMany())
assert.Len(t, prompt.GetSelectMany().Options, 2)
assertContainsStep(t, messages, pb.MonitorStep_MONITOR_STEP_PARSING_RESULTS)
sendDecision(t, stream, prompt.PromptId, &pb.UserDecision{
Decision: &pb.UserDecision_SelectedIds{SelectedIds: &pb.SelectedIds{Ids: prompt.GetSelectMany().DefaultIds}},
})
_, selectOnePrompt, err := collectUntilPrompt(t, stream, 0)
require.NoError(t, err)
require.NotNil(t, selectOnePrompt)
assert.Equal(t, pb.PromptType_PROMPT_TYPE_SELECT_ONE, selectOnePrompt.Type)
require.NotNil(t, selectOnePrompt.GetSelectOne())
assert.Len(t, selectOnePrompt.GetSelectOne().Options, 2)
selectedOption := selectOnePrompt.GetSelectOne().Options[1]
sendDecision(t, stream, selectOnePrompt.PromptId, &pb.UserDecision{
Decision: &pb.UserDecision_SelectedId{SelectedId: selectedOption.Id},
})
_, confirmPrompt, err := collectUntilPrompt(t, stream, 0)
require.NoError(t, err)
require.NotNil(t, confirmPrompt)
assert.Equal(t, pb.PromptType_PROMPT_TYPE_CONFIRM, confirmPrompt.Type)
sendDecision(t, stream, confirmPrompt.PromptId, &pb.UserDecision{
Decision: &pb.UserDecision_Confirm{Confirm: true},
})
remaining := collectAllMessages(t, stream, 0)
var result *pb.MonitorAlbumResponse
for _, msg := range remaining {
if r := msg.GetResult(); r != nil {
result = r
break
}
}
require.NotNil(t, result)
require.NotNil(t, result.Release)
assert.Equal(t, int32(100), result.Release.Seeders)
}
func TestMonitorAlbumStream_ManualConfirmAdd(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
}
var addMagnetCalled bool
suite.mocks.torrent.AddMagnetFunc = func(magnetURI string, savePath string) error {
addMagnetCalled = true
return nil
}
ctx, cancel := context.WithTimeout(context.Background(), defaultStreamTimeout)
defer cancel()
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},
})
remaining := collectAllMessages(t, stream, 0)
var result *pb.MonitorAlbumResponse
for _, msg := range remaining {
if r := msg.GetResult(); r != nil {
result = r
break
}
}
require.NotNil(t, result)
require.NotNil(t, result.Release)
assert.True(t, addMagnetCalled)
}
func TestMonitorAlbumStream_ManualRejectAdd(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
}
var addMagnetCalled bool
suite.mocks.torrent.AddMagnetFunc = func(magnetURI string, savePath string) error {
addMagnetCalled = true
return nil
}
ctx, cancel := context.WithTimeout(context.Background(), defaultStreamTimeout)
defer cancel()
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: false},
})
remaining := collectAllMessages(t, stream, 0)
var result *pb.MonitorAlbumResponse
for _, msg := range remaining {
if r := msg.GetResult(); r != nil {
result = r
break
}
}
require.NotNil(t, result)
assert.Nil(t, result.Release)
assert.False(t, addMagnetCalled)
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_ManualAlreadyOwnedContinue(t *testing.T) {
suite := setupSuite(t)
cleanTables(t, suite.pool)
ctx := context.Background()
var artistID string
err := suite.pool.QueryRow(ctx, `
INSERT INTO artists (external_id, name, artist_type, country, genres, image_url, monitor_state)
VALUES ('artist-ext-id', 'Test Artist', 'person', 'US', ARRAY['Rock'], 'http://img.com', 'monitored')
RETURNING id
`).Scan(&artistID)
require.NoError(t, err)
var albumID string
err = suite.pool.QueryRow(ctx, `
INSERT INTO albums (external_id, artist_id, title, album_type, total_tracks, total_discs, label, genres, cover_url, monitor_state)
VALUES ('owned-album-ext-id', $1, 'Owned Album', 'album', 10, 1, 'Test Label', ARRAY['Rock'], 'http://cover.com', 'monitored')
RETURNING id
`, artistID).Scan(&albumID)
require.NoError(t, err)
var torrentID string
err = suite.pool.QueryRow(ctx, `
INSERT INTO torrents (album_id, info_hash, tracker, title, format, quality, source, seeders, peers, size)
VALUES ($1, 'existing-hash', 'test-tracker', 'Owned Album FLAC', 'FLAC', '16-44', 'CD', 100, 50, 500000000)
RETURNING id
`, albumID).Scan(&torrentID)
require.NoError(t, err)
_, err = suite.pool.Exec(ctx, `
INSERT INTO downloads (torrent_id, album_id, format, quality, state, qbit_hash, save_path)
VALUES ($1, $2, 'QUALITY_LOSSLESS', '16-44', 'completed', 'existing-hash', '/music/owned')
`, torrentID, albumID)
require.NoError(t, err)
suite.mocks.metadata.GetAlbumFunc = func(ctx context.Context, in *metadataPb.GetAlbumRequest, opts ...grpc.CallOption) (*metadataPb.GetAlbumResponse, error) {
return &metadataPb.GetAlbumResponse{
Album: newMetadataAlbum("owned-album-ext-id", "Owned Album", "artist-ext-id", "Test Artist"),
}, nil
}
suite.mocks.indexer.SearchFunc = func(query string, limit int32, idx string) (*indexer.SearchResponse, error) {
return newSearchResponse(
newSearchItem("Owned Album [FLAC 24/96]", 50, "magnet:?xt=urn:btih:newhash"),
), 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
}
streamCtx, cancel := context.WithTimeout(context.Background(), defaultStreamTimeout)
defer cancel()
stream, err := suite.client.MonitorAlbumStream(streamCtx)
require.NoError(t, err)
err = stream.Send(&pb.MonitorAlbumStreamRequest{
Message: &pb.MonitorAlbumStreamRequest_Start{
Start: &pb.StartMonitorRequest{
AlbumId: "owned-album-ext-id",
Quality: pb.QualityType_QUALITY_LOSSLESS,
Mode: pb.InteractionMode_INTERACTION_MODE_MANUAL,
},
},
})
require.NoError(t, err)
messages, prompt, err := collectUntilPrompt(t, stream, 0)
require.NoError(t, err)
require.NotNil(t, prompt)
assert.Equal(t, pb.PromptType_PROMPT_TYPE_CONFIRM, prompt.Type)
assertContainsStep(t, messages, pb.MonitorStep_MONITOR_STEP_CHECKING_OWNED)
sendDecision(t, stream, prompt.PromptId, &pb.UserDecision{
Decision: &pb.UserDecision_Confirm{Confirm: true},
})
addMessages, addPrompt, err := collectUntilPrompt(t, stream, 0)
require.NoError(t, err)
require.NotNil(t, addPrompt)
assert.Equal(t, pb.PromptType_PROMPT_TYPE_CONFIRM, addPrompt.Type)
assertContainsStep(t, addMessages, pb.MonitorStep_MONITOR_STEP_SEARCHING_INDEXER)
assertContainsStep(t, addMessages, pb.MonitorStep_MONITOR_STEP_ADDING_TORRENT)
sendDecision(t, stream, addPrompt.PromptId, &pb.UserDecision{
Decision: &pb.UserDecision_Confirm{Confirm: true},
})
remaining := collectAllMessages(t, stream, 0)
var result *pb.MonitorAlbumResponse
for _, msg := range remaining {
if r := msg.GetResult(); r != nil {
result = r
break
}
}
require.NotNil(t, result)
}
func TestMonitorAlbumStream_FirstMessageMustBeStart(t *testing.T) {
suite := setupSuite(t)
cleanTables(t, suite.pool)
ctx, cancel := context.WithTimeout(context.Background(), defaultStreamTimeout)
defer cancel()
stream, err := suite.client.MonitorAlbumStream(ctx)
require.NoError(t, err)
err = stream.Send(&pb.MonitorAlbumStreamRequest{
Message: &pb.MonitorAlbumStreamRequest_Decision{
Decision: &pb.UserDecision{
PromptId: "some-prompt-id",
Decision: &pb.UserDecision_Confirm{Confirm: true},
},
},
})
require.NoError(t, err)
_, err = stream.Recv()
require.Error(t, err)
st, ok := status.FromError(err)
require.True(t, ok)
assert.Equal(t, codes.InvalidArgument, st.Code())
}
func TestMonitorAlbumStream_ContextCancellation(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) {
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(2 * time.Second):
return &metadataPb.GetAlbumResponse{
Album: newMetadataAlbum("test-album-ext-id", "Test Album", "artist-ext-id", "Test Artist"),
}, nil
}
}
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
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 lastErr error
for {
_, err = stream.Recv()
if err != nil {
lastErr = err
break
}
}
require.Error(t, lastErr)
st, ok := status.FromError(lastErr)
if ok {
assert.Contains(t, []codes.Code{codes.Canceled, codes.DeadlineExceeded}, st.Code())
}
}
func TestMonitorAlbumStream_PromptTimeout(t *testing.T) {
suite := setupSuite(t)
cleanTables(t, suite.pool)
origTimeout := internal.MaxPromptTimeout
internal.MaxPromptTimeout = 3 * time.Second
t.Cleanup(func() { internal.MaxPromptTimeout = origTimeout })
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
}
suite.mocks.torrent.AddMagnetFunc = func(magnetURI string, savePath string) error {
return nil
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
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)
remaining := collectAllMessages(t, stream, 15*time.Second)
var hasResult bool
for _, msg := range remaining {
if r := msg.GetResult(); r != nil {
hasResult = true
break
}
}
assert.True(t, hasResult, "expected workflow to complete with default decision after timeout")
}
func TestMonitorAlbumStream_InvalidPromptId(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
}
suite.mocks.torrent.AddMagnetFunc = func(magnetURI string, savePath string) error {
return nil
}
ctx, cancel := context.WithTimeout(context.Background(), defaultStreamTimeout)
defer cancel()
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)
err = stream.Send(&pb.MonitorAlbumStreamRequest{
Message: &pb.MonitorAlbumStreamRequest_Decision{
Decision: &pb.UserDecision{
PromptId: "wrong-prompt-id",
Decision: &pb.UserDecision_Confirm{Confirm: true},
},
},
})
require.NoError(t, err)
remaining := collectAllMessages(t, stream, 0)
var hasError bool
for _, msg := range remaining {
if errUpdate := msg.GetError(); errUpdate != nil {
hasError = true
assert.Contains(t, errUpdate.Message, "prompt")
break
}
}
assert.True(t, hasError, "expected error for invalid prompt ID")
}