Add MonitorAlbum component tests: 21 cases covering all flow diagrams (bufconn + testcontainers + hand-rolled mocks)

This commit is contained in:
Alexander
2026-05-09 21:31:09 +02:00
parent 6f31698006
commit 31ec3f9826
23 changed files with 2166 additions and 4 deletions
+83
View File
@@ -0,0 +1,83 @@
package component
import (
metadataPb "homelab.lan/music-agregator/gen/metadata/v1"
"homelab.lan/music-agregator/internal/indexer"
)
func newMetadataAlbum(id, title, artistID, artistName string) *metadataPb.Album {
return &metadataPb.Album{
Id: id,
Title: title,
AlbumType: "album",
ReleaseDate: "2024-01-15",
TotalTracks: 10,
TotalDiscs: 1,
CoverUrl: "https://example.com/cover.jpg",
Artists: []*metadataPb.ArtistCredit{
{
Artist: &metadataPb.Artist{
Id: artistID,
Name: artistName,
},
Role: "primary",
Position: 1,
},
},
Label: &metadataPb.Label{
Id: "label-1",
Name: "Test Label",
},
Genres: []*metadataPb.Genre{
{Id: "genre-1", Name: "Rock"},
{Id: "genre-2", Name: "Alternative"},
},
}
}
func newMetadataTrack(id, title string, trackNum int32) *metadataPb.Track {
return &metadataPb.Track{
Id: id,
Title: title,
DurationMs: 240000,
Isrc: "US-XYZ-24-00001",
Explicit: false,
DiscNumber: 1,
TrackNumber: trackNum,
Artists: []*metadataPb.ArtistCredit{
{
Artist: &metadataPb.Artist{
Id: "artist-1",
Name: "Test Artist",
},
Role: "primary",
Position: 1,
},
},
}
}
func newSearchResponse(items ...*indexer.SearchItemResult) *indexer.SearchResponse {
return &indexer.SearchResponse{
Items: items,
}
}
func newSearchItem(title string, seeders int, downloadLink string) *indexer.SearchItemResult {
return &indexer.SearchItemResult{
Title: title,
DownloadLink: downloadLink,
Size: 500 * 1024 * 1024,
Tracker: "test-tracker",
Seeders: seeders,
Peers: seeders / 2,
}
}
func newTorrentData() []byte {
return []byte("d8:announce35:http://tracker.example.com/announce4:infod6:lengthi1024e4:name9:test.flac12:piece lengthi16384e6:pieces20:01234567890123456789ee")
}
func newTorrentDataMP3() []byte {
return []byte("d8:announce35:http://tracker.example.com/announce4:infod6:lengthi1024e4:name8:test.mp312:piece lengthi16384e6:pieces20:01234567890123456789ee")
}
+146
View File
@@ -0,0 +1,146 @@
package component
import (
"context"
"fmt"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
metadataPb "homelab.lan/music-agregator/gen/metadata/v1"
"homelab.lan/music-agregator/internal/indexer"
"homelab.lan/music-agregator/internal/torrent"
)
type mockMetadataClient struct {
GetAlbumFunc func(ctx context.Context, in *metadataPb.GetAlbumRequest, opts ...grpc.CallOption) (*metadataPb.GetAlbumResponse, error)
GetArtistAlbumsFunc func(ctx context.Context, in *metadataPb.GetArtistAlbumsRequest, opts ...grpc.CallOption) (*metadataPb.GetArtistAlbumsResponse, error)
GetAlbumTracksFunc func(ctx context.Context, in *metadataPb.GetAlbumTracksRequest, opts ...grpc.CallOption) (*metadataPb.GetAlbumTracksResponse, error)
GetArtistFunc func(ctx context.Context, in *metadataPb.GetArtistRequest, opts ...grpc.CallOption) (*metadataPb.GetArtistResponse, error)
SearchArtistsFunc func(ctx context.Context, in *metadataPb.SearchArtistsRequest, opts ...grpc.CallOption) (*metadataPb.SearchArtistsResponse, error)
GetTrackFunc func(ctx context.Context, in *metadataPb.GetTrackRequest, opts ...grpc.CallOption) (*metadataPb.GetTrackResponse, error)
SearchAlbumsFunc func(ctx context.Context, in *metadataPb.SearchAlbumsRequest, opts ...grpc.CallOption) (*metadataPb.SearchAlbumsResponse, error)
SyncArtistFunc func(ctx context.Context, in *metadataPb.SyncArtistRequest, opts ...grpc.CallOption) (*metadataPb.SyncArtistResponse, error)
}
func (m *mockMetadataClient) GetAlbum(ctx context.Context, in *metadataPb.GetAlbumRequest, opts ...grpc.CallOption) (*metadataPb.GetAlbumResponse, error) {
if m.GetAlbumFunc != nil {
return m.GetAlbumFunc(ctx, in, opts...)
}
return nil, status.Error(codes.Unimplemented, "not mocked")
}
func (m *mockMetadataClient) GetArtistAlbums(ctx context.Context, in *metadataPb.GetArtistAlbumsRequest, opts ...grpc.CallOption) (*metadataPb.GetArtistAlbumsResponse, error) {
if m.GetArtistAlbumsFunc != nil {
return m.GetArtistAlbumsFunc(ctx, in, opts...)
}
return nil, status.Error(codes.Unimplemented, "not mocked")
}
func (m *mockMetadataClient) GetAlbumTracks(ctx context.Context, in *metadataPb.GetAlbumTracksRequest, opts ...grpc.CallOption) (*metadataPb.GetAlbumTracksResponse, error) {
if m.GetAlbumTracksFunc != nil {
return m.GetAlbumTracksFunc(ctx, in, opts...)
}
return nil, status.Error(codes.Unimplemented, "not mocked")
}
func (m *mockMetadataClient) GetArtist(ctx context.Context, in *metadataPb.GetArtistRequest, opts ...grpc.CallOption) (*metadataPb.GetArtistResponse, error) {
if m.GetArtistFunc != nil {
return m.GetArtistFunc(ctx, in, opts...)
}
return nil, status.Error(codes.Unimplemented, "not mocked")
}
func (m *mockMetadataClient) SearchArtists(ctx context.Context, in *metadataPb.SearchArtistsRequest, opts ...grpc.CallOption) (*metadataPb.SearchArtistsResponse, error) {
if m.SearchArtistsFunc != nil {
return m.SearchArtistsFunc(ctx, in, opts...)
}
return nil, status.Error(codes.Unimplemented, "not mocked")
}
func (m *mockMetadataClient) GetTrack(ctx context.Context, in *metadataPb.GetTrackRequest, opts ...grpc.CallOption) (*metadataPb.GetTrackResponse, error) {
if m.GetTrackFunc != nil {
return m.GetTrackFunc(ctx, in, opts...)
}
return nil, status.Error(codes.Unimplemented, "not mocked")
}
func (m *mockMetadataClient) SearchAlbums(ctx context.Context, in *metadataPb.SearchAlbumsRequest, opts ...grpc.CallOption) (*metadataPb.SearchAlbumsResponse, error) {
if m.SearchAlbumsFunc != nil {
return m.SearchAlbumsFunc(ctx, in, opts...)
}
return nil, status.Error(codes.Unimplemented, "not mocked")
}
func (m *mockMetadataClient) SyncArtist(ctx context.Context, in *metadataPb.SyncArtistRequest, opts ...grpc.CallOption) (*metadataPb.SyncArtistResponse, error) {
if m.SyncArtistFunc != nil {
return m.SyncArtistFunc(ctx, in, opts...)
}
return nil, status.Error(codes.Unimplemented, "not mocked")
}
type mockTorrentClient struct {
LoginFunc func(username, password string) (string, error)
ListFunc func() ([]torrent.TorrentInfo, error)
FindFunc func(opts torrent.FindOptions) ([]torrent.TorrentInfo, error)
AddTorrentFunc func(file torrent.TorrentFile) error
AddMagnetFunc func(magnetURI string) error
}
func (m *mockTorrentClient) Login(username, password string) (string, error) {
if m.LoginFunc != nil {
return m.LoginFunc(username, password)
}
return "", fmt.Errorf("not mocked")
}
func (m *mockTorrentClient) List() ([]torrent.TorrentInfo, error) {
if m.ListFunc != nil {
return m.ListFunc()
}
return nil, fmt.Errorf("not mocked")
}
func (m *mockTorrentClient) Find(opts torrent.FindOptions) ([]torrent.TorrentInfo, error) {
if m.FindFunc != nil {
return m.FindFunc(opts)
}
return nil, fmt.Errorf("not mocked")
}
func (m *mockTorrentClient) AddTorrent(file torrent.TorrentFile) error {
if m.AddTorrentFunc != nil {
return m.AddTorrentFunc(file)
}
return fmt.Errorf("not mocked")
}
func (m *mockTorrentClient) AddMagnet(magnetURI string) error {
if m.AddMagnetFunc != nil {
return m.AddMagnetFunc(magnetURI)
}
return fmt.Errorf("not mocked")
}
type mockSearcher struct {
SearchFunc func(query string, limit int32, indexer string) (*indexer.SearchResponse, error)
}
func (m *mockSearcher) Search(query string, limit int32, idx string) (*indexer.SearchResponse, error) {
if m.SearchFunc != nil {
return m.SearchFunc(query, limit, idx)
}
return nil, fmt.Errorf("not mocked")
}
type mockResolver struct {
ResolveFunc func(magnetURI string) ([]byte, error)
}
func (m *mockResolver) Resolve(magnetURI string) ([]byte, error) {
if m.ResolveFunc != nil {
return m.ResolveFunc(magnetURI)
}
return nil, fmt.Errorf("not mocked")
}
File diff suppressed because it is too large Load Diff
+174
View File
@@ -0,0 +1,174 @@
package component
import (
"context"
"net"
"os"
"path/filepath"
"runtime"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/stretchr/testify/require"
"github.com/testcontainers/testcontainers-go"
"github.com/testcontainers/testcontainers-go/modules/postgres"
"github.com/testcontainers/testcontainers-go/wait"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/test/bufconn"
pb "homelab.lan/music-agregator/gen/music_agregator/v1"
"homelab.lan/music-agregator/internal"
"homelab.lan/music-agregator/internal/database"
"homelab.lan/music-agregator/internal/metadata"
)
const bufSize = 1024 * 1024
type testMocks struct {
metadata *mockMetadataClient
torrent *mockTorrentClient
indexer *mockSearcher
magnet *mockResolver
}
type testSuite struct {
db *database.DB
grpcConn *grpc.ClientConn
client pb.MusicAgregatorServiceClient
pool *pgxpool.Pool
mocks *testMocks
}
func setupSuite(t *testing.T) *testSuite {
ctx := context.Background()
schemaPath := getSchemaPath(t)
schemaSQL, err := os.ReadFile(schemaPath)
require.NoError(t, err, "failed to read schema file")
pgContainer, err := postgres.Run(ctx,
"postgres:16-alpine",
postgres.WithDatabase("music_agregator_test"),
postgres.WithUsername("test"),
postgres.WithPassword("test"),
postgres.WithInitScripts(),
testcontainers.WithWaitStrategy(
wait.ForLog("database system is ready to accept connections").
WithOccurrence(2).
WithStartupTimeout(30*time.Second),
),
)
require.NoError(t, err, "failed to start postgres container")
t.Cleanup(func() {
if err := pgContainer.Terminate(ctx); err != nil {
t.Logf("failed to terminate postgres container: %v", err)
}
})
connStr, err := pgContainer.ConnectionString(ctx, "sslmode=disable")
require.NoError(t, err, "failed to get connection string")
pool, err := pgxpool.New(ctx, connStr)
require.NoError(t, err, "failed to create pgxpool")
t.Cleanup(func() {
pool.Close()
})
_, err = pool.Exec(ctx, string(schemaSQL))
require.NoError(t, err, "failed to apply schema")
db := &database.DB{Pool: pool}
mocks := &testMocks{
metadata: &mockMetadataClient{},
torrent: &mockTorrentClient{},
indexer: &mockSearcher{},
magnet: &mockResolver{},
}
metadataSvc := metadata.NewMetadataService(mocks.metadata, db)
service := internal.NewMusicAgregatorServiceWithDeps(
metadataSvc,
mocks.indexer,
mocks.torrent,
mocks.magnet,
nil,
db,
)
server := internal.NewMusicAgregatorServerWithService(service)
lis := bufconn.Listen(bufSize)
grpcServer := grpc.NewServer()
server.Register(grpcServer)
go func() {
if err := grpcServer.Serve(lis); err != nil {
t.Logf("grpc server error: %v", err)
}
}()
t.Cleanup(func() {
grpcServer.GracefulStop()
})
conn, err := grpc.NewClient(
"passthrough://bufnet",
grpc.WithContextDialer(func(ctx context.Context, _ string) (net.Conn, error) {
return lis.DialContext(ctx)
}),
grpc.WithTransportCredentials(insecure.NewCredentials()),
)
require.NoError(t, err, "failed to create grpc client connection")
t.Cleanup(func() {
conn.Close()
})
client := pb.NewMusicAgregatorServiceClient(conn)
return &testSuite{
db: db,
grpcConn: conn,
client: client,
pool: pool,
mocks: mocks,
}
}
func getSchemaPath(t *testing.T) string {
_, currentFile, _, ok := runtime.Caller(0)
require.True(t, ok, "failed to get current file path")
testDir := filepath.Dir(currentFile)
schemaPath := filepath.Join(testDir, "..", "..", "..", "containers", "database", "music-agregator", "002_schema.sql")
if _, err := os.Stat(schemaPath); os.IsNotExist(err) {
schemaPath = filepath.Join(testDir, "..", "..", "containers", "database", "music-agregator", "002_schema.sql")
}
return schemaPath
}
func cleanTables(t *testing.T, pool *pgxpool.Pool) {
ctx := context.Background()
tables := []string{
"download_files",
"downloads",
"torrents",
"tracks",
"albums",
"artists",
}
for _, table := range tables {
_, err := pool.Exec(ctx, "TRUNCATE TABLE "+table+" CASCADE")
require.NoError(t, err, "failed to truncate table %s", table)
}
}