diff --git a/src/Domain/Entities/ArrInstance.cs b/src/Domain/Entities/ArrInstance.cs new file mode 100644 index 0000000..2475fb0 --- /dev/null +++ b/src/Domain/Entities/ArrInstance.cs @@ -0,0 +1,49 @@ +using Krautwatch.Domain.Enums; + +namespace Krautwatch.Domain.Entities; + +/// +/// A configured Sonarr or Radarr instance that Krautwatch calls **outbound** — to test connectivity and +/// (per #6) to fetch the monitored-series list that becomes the crawl work-list. +/// +/// +/// This is the opposite direction to Krautwatch:ApiKey, which is the key `*arr` apps use to call +/// us. Here is their key, held so we can authenticate to them. +/// +/// The last-test fields are a cache of the most recent connectivity check so the UI can show state +/// without re-probing every instance on each page load. +/// +/// +public class ArrInstance +{ + public Guid Id { get; set; } = Guid.NewGuid(); + + /// Operator-facing label, so several instances of the same kind stay distinguishable. + public string Name { get; set; } = string.Empty; + + public ArrKind Kind { get; set; } + + /// + /// Absolute base URL, e.g. http://sonarr:8989. May include a path when the instance sits + /// behind a reverse proxy on a subpath. + /// + public string BaseUrl { get; set; } = string.Empty; + + /// + /// The instance's API key. A credential: never returned by read models — query DTOs carry a masked + /// form only, so the UI cannot be used to harvest keys. + /// + public string ApiKey { get; set; } = string.Empty; + + /// Lets an instance be kept but skipped, rather than deleted and re-entered. + public bool Enabled { get; set; } = true; + + public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; + + // ── Cached outcome of the last connectivity test ────────── + public DateTimeOffset? LastTestedAt { get; set; } + public bool? LastTestOk { get; set; } + + /// App version on success, or an actionable reason on failure. + public string? LastTestMessage { get; set; } +} diff --git a/src/Domain/Enums/ArrKind.cs b/src/Domain/Enums/ArrKind.cs new file mode 100644 index 0000000..40230e0 --- /dev/null +++ b/src/Domain/Enums/ArrKind.cs @@ -0,0 +1,15 @@ +namespace Krautwatch.Domain.Enums; + +/// +/// Which `*arr` application an instance is, since the two drive different content and expose +/// different API shapes (Sonarr: series; Radarr: movies). +/// +/// +/// Prowlarr is deliberately absent: it is configured *pointing at* Krautwatch as an indexer, so there +/// is nothing for us to call outbound and no instance record to keep (DR-010). +/// +public enum ArrKind +{ + Sonarr = 0, + Radarr = 1, +} diff --git a/src/Domain/Interfaces/IArr.cs b/src/Domain/Interfaces/IArr.cs new file mode 100644 index 0000000..cb9fce2 --- /dev/null +++ b/src/Domain/Interfaces/IArr.cs @@ -0,0 +1,61 @@ +using Krautwatch.Domain.Entities; + +namespace Krautwatch.Domain.Interfaces; + +/// Persistence for configured Sonarr/Radarr instances. +public interface IArrInstanceRepository +{ + Task> GetAllAsync(CancellationToken ct = default); + Task GetByIdAsync(Guid id, CancellationToken ct = default); + + /// Enabled instances only — the work-list for outbound calls such as #6's monitored-series poll. + Task> GetEnabledAsync(CancellationToken ct = default); + + Task AddAsync(ArrInstance instance, CancellationToken ct = default); + Task UpdateAsync(ArrInstance instance, CancellationToken ct = default); + Task DeleteAsync(Guid id, CancellationToken ct = default); +} + +/// +/// Outbound HTTP boundary to a Sonarr/Radarr instance. Starts with connectivity checking; #6 extends the +/// same port with the monitored-series fetch, which is why it is a port rather than a UI helper. +/// +public interface IArrClient +{ + Task TestConnectionAsync(ArrInstance instance, CancellationToken ct = default); +} + +/// +/// Outcome of a connectivity test. Failure modes are distinguished deliberately: "it doesn't work" is +/// the single most common self-hosting complaint, and the operator can only act on a specific cause. +/// +public record ArrConnectionResult(bool Ok, ArrConnectionFailure Failure, string Message) +{ + public static ArrConnectionResult Success(string appName, string version) => + new(true, ArrConnectionFailure.None, $"{appName} {version}"); + + public static ArrConnectionResult Fail(ArrConnectionFailure failure, string message) => + new(false, failure, message); +} + +public enum ArrConnectionFailure +{ + None = 0, + + /// DNS/TCP/timeout — wrong host or port, or the instance is down. + Unreachable = 1, + + /// TLS handshake or certificate rejection — common with self-signed certs. + TlsFailure = 2, + + /// 401/403 — reached it, but the API key is wrong. + Unauthorized = 3, + + /// 404 — reached a server but not the API; usually a reverse-proxy subpath left off the base URL. + ApiNotFound = 4, + + /// 200, but the response is not an `*arr` system-status payload — usually the wrong port entirely. + NotAnArrInstance = 5, + + Unexpected = 99, +} diff --git a/src/Infrastructure/InfrastructureServiceExtensions.cs b/src/Infrastructure/InfrastructureServiceExtensions.cs index 245fdc6..af8d9bd 100644 --- a/src/Infrastructure/InfrastructureServiceExtensions.cs +++ b/src/Infrastructure/InfrastructureServiceExtensions.cs @@ -60,6 +60,7 @@ public static IServiceCollection AddInfrastructure( services.AddScoped(); // Auth — local credential store + password hashing behind Domain ports (#48). + services.AddScoped(); services.AddScoped(); services.AddSingleton(); diff --git a/src/Infrastructure/Persistence/AppDbContext.cs b/src/Infrastructure/Persistence/AppDbContext.cs index ff03c87..e6309f6 100644 --- a/src/Infrastructure/Persistence/AppDbContext.cs +++ b/src/Infrastructure/Persistence/AppDbContext.cs @@ -15,6 +15,7 @@ public class AppDbContext(DbContextOptions options) : DbContext(op public DbSet Settings => Set(); public DbSet Proxies => Set(); public DbSet AdminAccounts => Set(); + public DbSet ArrInstances => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { @@ -217,6 +218,27 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) e.Property(x => x.PasswordHash).IsRequired().HasMaxLength(500); }); + // -------------------------------------------------------- + // ArrInstance — configured Sonarr/Radarr instances we call OUTBOUND (#4). + // BaseUrl is uniquely indexed: #5 bootstraps instances from env vars and matches them by base + // URL, so duplicates have to be impossible at the schema level rather than by convention. + // -------------------------------------------------------- + modelBuilder.Entity(e => + { + e.HasKey(x => x.Id); + e.Property(x => x.Name).IsRequired().HasMaxLength(100); + e.Property(x => x.BaseUrl).IsRequired().HasMaxLength(500); + e.Property(x => x.ApiKey).IsRequired().HasMaxLength(200); + e.Property(x => x.LastTestMessage).HasMaxLength(500); + + // Stored as text like the other enums, so the column stays readable in the database. + e.Property(x => x.Kind) + .HasConversion(v => v.ToString(), v => Enum.Parse(v)) + .HasMaxLength(20); + + e.HasIndex(x => x.BaseUrl).IsUnique(); + }); + // -------------------------------------------------------- // TickerQ — job scheduler tables (TimeTickers, CronTickers, etc.) // UseModelCustomizerForMigrations() is the alternative but we use diff --git a/src/Infrastructure/Persistence/ArrInstanceRepository.cs b/src/Infrastructure/Persistence/ArrInstanceRepository.cs new file mode 100644 index 0000000..9b0d4ec --- /dev/null +++ b/src/Infrastructure/Persistence/ArrInstanceRepository.cs @@ -0,0 +1,50 @@ +using Krautwatch.Domain.Entities; +using Krautwatch.Domain.Interfaces; +using Microsoft.EntityFrameworkCore; + +namespace Krautwatch.Infrastructure.Persistence; + +/// EF-backed store for configured Sonarr/Radarr instances. +/// +/// Listings group by kind then name. Note that Kind is persisted as text, so SQL orders it +/// alphabetically by name (Radarr before Sonarr) rather than by enum value — stable and fine for +/// display, but not the enum's declaration order. +/// +public class ArrInstanceRepository(AppDbContext db) : IArrInstanceRepository +{ + public async Task> GetAllAsync(CancellationToken ct = default) => + await db.ArrInstances + .OrderBy(i => i.Kind) + .ThenBy(i => i.Name) + .AsNoTracking() + .ToListAsync(ct); + + public Task GetByIdAsync(Guid id, CancellationToken ct = default) => + db.ArrInstances.FirstOrDefaultAsync(i => i.Id == id, ct); + + public async Task> GetEnabledAsync(CancellationToken ct = default) => + await db.ArrInstances + .Where(i => i.Enabled) + .OrderBy(i => i.Kind) + .ThenBy(i => i.Name) + .AsNoTracking() + .ToListAsync(ct); + + public async Task AddAsync(ArrInstance instance, CancellationToken ct = default) + { + db.ArrInstances.Add(instance); + await db.SaveChangesAsync(ct); + } + + public async Task UpdateAsync(ArrInstance instance, CancellationToken ct = default) + { + db.ArrInstances.Update(instance); + await db.SaveChangesAsync(ct); + } + + public async Task DeleteAsync(Guid id, CancellationToken ct = default) + { + // ExecuteDelete so removal doesn't need the entity loaded, and is a no-op if it's already gone. + await db.ArrInstances.Where(i => i.Id == id).ExecuteDeleteAsync(ct); + } +} diff --git a/src/Infrastructure/Persistence/Migrations/20260727080445_AddArrInstances.Designer.cs b/src/Infrastructure/Persistence/Migrations/20260727080445_AddArrInstances.Designer.cs new file mode 100644 index 0000000..2f0ced9 --- /dev/null +++ b/src/Infrastructure/Persistence/Migrations/20260727080445_AddArrInstances.Designer.cs @@ -0,0 +1,479 @@ +// +using System; +using Krautwatch.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Krautwatch.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260727080445_AddArrInstances")] + partial class AddArrInstances + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Krautwatch.Domain.Entities.AdminAccount", b => + { + b.Property("Id") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastLoginAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.ToTable("AdminAccounts"); + }); + + modelBuilder.Entity("Krautwatch.Domain.Entities.AppSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CatalogProviderKey") + .IsRequired() + .HasColumnType("text"); + + b.Property("CatalogRefreshIntervalHours") + .HasColumnType("integer"); + + b.Property("DownloadDirectory") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("MaxConcurrentDownloads") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("Settings"); + + b.HasData( + new + { + Id = 1, + CatalogProviderKey = "mediathekview", + CatalogRefreshIntervalHours = 6, + DownloadDirectory = "/downloads", + MaxConcurrentDownloads = 2 + }); + }); + + modelBuilder.Entity("Krautwatch.Domain.Entities.ArrInstance", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApiKey") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("BaseUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("LastTestMessage") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("LastTestOk") + .HasColumnType("boolean"); + + b.Property("LastTestedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("BaseUrl") + .IsUnique(); + + b.ToTable("ArrInstances"); + }); + + modelBuilder.Entity("Krautwatch.Domain.Entities.Channel", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ProviderKey") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.ToTable("Channels"); + }); + + modelBuilder.Entity("Krautwatch.Domain.Entities.DownloadJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CompletedAt") + .HasColumnType("text"); + + b.Property("ContentLengthBytes") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .IsRequired() + .HasColumnType("text"); + + b.Property("EpisodeId") + .IsRequired() + .HasColumnType("text"); + + b.Property("ErrorMessage") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("FileSizeBytes") + .HasColumnType("bigint"); + + b.Property("GeoRestricted") + .HasColumnType("boolean"); + + b.Property("OutputPath") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("ProgressPercent") + .HasColumnType("double precision"); + + b.Property("Quality") + .IsRequired() + .HasColumnType("text"); + + b.Property("StartedAt") + .HasColumnType("text"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("StreamType") + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("StreamUrl") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("TempPath") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("WorkerId") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("EpisodeId"); + + b.HasIndex("Status"); + + b.ToTable("DownloadJobs"); + }); + + modelBuilder.Entity("Krautwatch.Domain.Entities.Episode", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("AbsoluteEpisodeNumber") + .HasColumnType("integer"); + + b.Property("AvailableUntil") + .HasColumnType("text"); + + b.Property("BroadcastDate") + .IsRequired() + .HasColumnType("text"); + + b.Property("ContentType") + .IsRequired() + .HasColumnType("text"); + + b.Property("Description") + .HasMaxLength(5000) + .HasColumnType("character varying(5000)"); + + b.Property("Duration") + .HasColumnType("double precision"); + + b.Property("EpisodeNumber") + .HasColumnType("integer"); + + b.Property("GeoRestricted") + .HasColumnType("boolean"); + + b.Property("SeasonNumber") + .HasColumnType("integer"); + + b.Property("ShowId") + .IsRequired() + .HasColumnType("text"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.HasKey("Id"); + + b.HasIndex("BroadcastDate"); + + b.HasIndex("ContentType"); + + b.HasIndex("ShowId"); + + b.HasIndex("ShowId", "SeasonNumber", "EpisodeNumber"); + + b.ToTable("Episodes"); + }); + + modelBuilder.Entity("Krautwatch.Domain.Entities.EpisodeStream", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("EpisodeId") + .IsRequired() + .HasColumnType("text"); + + b.Property("Format") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("Quality") + .IsRequired() + .HasColumnType("text"); + + b.Property("Url") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.HasKey("Id"); + + b.HasIndex("EpisodeId"); + + b.ToTable("EpisodeStreams"); + }); + + modelBuilder.Entity("Krautwatch.Domain.Entities.Proxy", b => + { + b.Property("Id") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("AnonymityLevel") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Country") + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("CreatedAt") + .IsRequired() + .HasColumnType("text"); + + b.Property("Host") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("LastProbeOk") + .HasColumnType("boolean"); + + b.Property("LastProbedAt") + .HasColumnType("text"); + + b.Property("Latency") + .HasColumnType("double precision"); + + b.Property("Port") + .HasColumnType("integer"); + + b.Property("Protocol") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("ResponseTime") + .HasColumnType("integer"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("SourceLastChecked") + .HasColumnType("text"); + + b.Property("Speed") + .HasColumnType("integer"); + + b.Property("UpTime") + .HasColumnType("double precision"); + + b.Property("UpdatedAt") + .IsRequired() + .HasColumnType("text"); + + b.Property("VerifiedEgressCountry") + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.HasKey("Id"); + + b.HasIndex("Country"); + + b.ToTable("Proxies"); + }); + + modelBuilder.Entity("Krautwatch.Domain.Entities.Show", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("ChannelId") + .IsRequired() + .HasColumnType("text"); + + b.Property("SeriesType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("TvdbId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId"); + + b.ToTable("Shows"); + }); + + modelBuilder.Entity("Krautwatch.Domain.Entities.DownloadJob", b => + { + b.HasOne("Krautwatch.Domain.Entities.Episode", "Episode") + .WithMany() + .HasForeignKey("EpisodeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Episode"); + }); + + modelBuilder.Entity("Krautwatch.Domain.Entities.Episode", b => + { + b.HasOne("Krautwatch.Domain.Entities.Show", "Show") + .WithMany("Episodes") + .HasForeignKey("ShowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Show"); + }); + + modelBuilder.Entity("Krautwatch.Domain.Entities.EpisodeStream", b => + { + b.HasOne("Krautwatch.Domain.Entities.Episode", null) + .WithMany("Streams") + .HasForeignKey("EpisodeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Krautwatch.Domain.Entities.Show", b => + { + b.HasOne("Krautwatch.Domain.Entities.Channel", "Channel") + .WithMany() + .HasForeignKey("ChannelId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Channel"); + }); + + modelBuilder.Entity("Krautwatch.Domain.Entities.Episode", b => + { + b.Navigation("Streams"); + }); + + modelBuilder.Entity("Krautwatch.Domain.Entities.Show", b => + { + b.Navigation("Episodes"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Infrastructure/Persistence/Migrations/20260727080445_AddArrInstances.cs b/src/Infrastructure/Persistence/Migrations/20260727080445_AddArrInstances.cs new file mode 100644 index 0000000..6d9925e --- /dev/null +++ b/src/Infrastructure/Persistence/Migrations/20260727080445_AddArrInstances.cs @@ -0,0 +1,48 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Krautwatch.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddArrInstances : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "ArrInstances", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + Name = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + Kind = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), + BaseUrl = table.Column(type: "character varying(500)", maxLength: 500, nullable: false), + ApiKey = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + Enabled = table.Column(type: "boolean", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + LastTestedAt = table.Column(type: "timestamp with time zone", nullable: true), + LastTestOk = table.Column(type: "boolean", nullable: true), + LastTestMessage = table.Column(type: "character varying(500)", maxLength: 500, nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_ArrInstances", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_ArrInstances_BaseUrl", + table: "ArrInstances", + column: "BaseUrl", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "ArrInstances"); + } + } +} diff --git a/src/Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs b/src/Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs index ccf850d..81942ab 100644 --- a/src/Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs +++ b/src/Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs @@ -86,6 +86,56 @@ protected override void BuildModel(ModelBuilder modelBuilder) }); }); + modelBuilder.Entity("Krautwatch.Domain.Entities.ArrInstance", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApiKey") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("BaseUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("LastTestMessage") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("LastTestOk") + .HasColumnType("boolean"); + + b.Property("LastTestedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("BaseUrl") + .IsUnique(); + + b.ToTable("ArrInstances"); + }); + modelBuilder.Entity("Krautwatch.Domain.Entities.Channel", b => { b.Property("Id") diff --git a/tests/Infrastructure.Tests/ArrInstanceRepositoryTests.cs b/tests/Infrastructure.Tests/ArrInstanceRepositoryTests.cs new file mode 100644 index 0000000..0214a4a --- /dev/null +++ b/tests/Infrastructure.Tests/ArrInstanceRepositoryTests.cs @@ -0,0 +1,143 @@ +using Krautwatch.Domain.Entities; +using Krautwatch.Domain.Enums; +using Krautwatch.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Shouldly; +using Xunit; + +namespace Krautwatch.Infrastructure.Tests; + +[Collection(PostgresCollection.Name)] +public class ArrInstanceRepositoryTests(PostgresFixture postgres) : IAsyncLifetime +{ + private DbContextOptions _options = null!; + + public async ValueTask InitializeAsync() => _options = await postgres.CreateDatabaseAsync(); + + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + + private ArrInstanceRepository Repo() => new(new AppDbContext(_options)); + + private static ArrInstance Instance( + string name = "Sonarr", + ArrKind kind = ArrKind.Sonarr, + string baseUrl = "http://sonarr:8989", + bool enabled = true) => new() + { + Name = name, + Kind = kind, + BaseUrl = baseUrl, + ApiKey = "key-abc123", + Enabled = enabled, + }; + + [Fact] + public async Task Adds_then_reads_back_an_instance() + { + var instance = Instance(); + await Repo().AddAsync(instance, TestContext.Current.CancellationToken); + + var found = await Repo().GetByIdAsync(instance.Id, TestContext.Current.CancellationToken); + + found.ShouldNotBeNull(); + found.Name.ShouldBe("Sonarr"); + found.Kind.ShouldBe(ArrKind.Sonarr); + found.BaseUrl.ShouldBe("http://sonarr:8989"); + found.ApiKey.ShouldBe("key-abc123"); + found.Enabled.ShouldBeTrue(); + } + + [Fact] + public async Task Kind_round_trips_as_text() + { + // Stored as text rather than an int so the column stays readable in the database. + await Repo().AddAsync(Instance(kind: ArrKind.Radarr, baseUrl: "http://radarr:7878"), + TestContext.Current.CancellationToken); + + var all = await Repo().GetAllAsync(TestContext.Current.CancellationToken); + + all.ShouldHaveSingleItem().Kind.ShouldBe(ArrKind.Radarr); + } + + [Fact] + public async Task Duplicate_base_urls_are_rejected_by_the_schema() + { + // #5 bootstraps instances from env vars and matches on base URL, so duplicates must be + // impossible at the schema level rather than prevented by convention. + await Repo().AddAsync(Instance(baseUrl: "http://sonarr:8989"), TestContext.Current.CancellationToken); + + await Should.ThrowAsync(async () => + await Repo().AddAsync( + Instance(name: "Duplicate", baseUrl: "http://sonarr:8989"), + TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task GetEnabled_skips_disabled_instances() + { + await Repo().AddAsync(Instance(name: "On", baseUrl: "http://on:8989"), + TestContext.Current.CancellationToken); + await Repo().AddAsync(Instance(name: "Off", baseUrl: "http://off:8989", enabled: false), + TestContext.Current.CancellationToken); + + var enabled = await Repo().GetEnabledAsync(TestContext.Current.CancellationToken); + + enabled.ShouldHaveSingleItem().Name.ShouldBe("On"); + (await Repo().GetAllAsync(TestContext.Current.CancellationToken)).Count.ShouldBe(2); + } + + [Fact] + public async Task Updates_the_cached_test_outcome() + { + var instance = Instance(); + await Repo().AddAsync(instance, TestContext.Current.CancellationToken); + + var loaded = await Repo().GetByIdAsync(instance.Id, TestContext.Current.CancellationToken); + loaded!.LastTestOk = true; + loaded.LastTestMessage = "Sonarr 4.0.10"; + loaded.LastTestedAt = DateTimeOffset.UtcNow; + await Repo().UpdateAsync(loaded, TestContext.Current.CancellationToken); + + var reread = await Repo().GetByIdAsync(instance.Id, TestContext.Current.CancellationToken); + reread!.LastTestOk.ShouldBe(true); + reread.LastTestMessage.ShouldBe("Sonarr 4.0.10"); + reread.LastTestedAt.ShouldNotBeNull(); + } + + [Fact] + public async Task Deletes_an_instance() + { + var instance = Instance(); + await Repo().AddAsync(instance, TestContext.Current.CancellationToken); + + await Repo().DeleteAsync(instance.Id, TestContext.Current.CancellationToken); + + (await Repo().GetByIdAsync(instance.Id, TestContext.Current.CancellationToken)).ShouldBeNull(); + } + + [Fact] + public async Task Deleting_something_absent_is_a_no_op() + { + await Repo().DeleteAsync(Guid.NewGuid(), TestContext.Current.CancellationToken); + + (await Repo().GetAllAsync(TestContext.Current.CancellationToken)).ShouldBeEmpty(); + } + + [Fact] + public async Task Orders_by_kind_then_name() + { + await Repo().AddAsync(Instance(name: "Zeta", ArrKind.Sonarr, "http://z:8989"), + TestContext.Current.CancellationToken); + await Repo().AddAsync(Instance(name: "Movies", ArrKind.Radarr, "http://m:7878"), + TestContext.Current.CancellationToken); + await Repo().AddAsync(Instance(name: "Alpha", ArrKind.Sonarr, "http://a:8989"), + TestContext.Current.CancellationToken); + + var all = await Repo().GetAllAsync(TestContext.Current.CancellationToken); + + // Grouped by kind, then alphabetical within a kind. Kind is persisted as TEXT, so SQL sorts it + // alphabetically ("Radarr" < "Sonarr") rather than by enum value — asserted here so the + // behaviour is pinned rather than assumed. + all.Select(i => i.Name).ShouldBe(["Movies", "Alpha", "Zeta"]); + } +}