AltHeroes Bot v2

Complete database work

Tim Burga 30f3a3d3 7daab45d

+821 -97
+7 -3
AltBot.Api/AltBot.Api.csproj
··· 1 - <Project Sdk="Microsoft.NET.Sdk.Web"> 1 + <Project Sdk="Microsoft.NET.Sdk.Web"> 2 2 3 3 <PropertyGroup> 4 4 <TargetFramework>net9.0</TargetFramework> 5 5 <ImplicitUsings>enable</ImplicitUsings> 6 6 <Nullable>enable</Nullable> 7 + <UserSecretsId>b4c692e6-a69d-404b-a94e-ed1bb0e4c797</UserSecretsId> 7 8 </PropertyGroup> 8 9 9 10 <ItemGroup> ··· 14 15 15 16 <ItemGroup> 16 17 <PackageReference Include="Microsoft.AspNetCore.OpenApi" /> 17 - <PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" /> 18 - <PackageReference Include="Scalar.AspNetCore" /> 18 + <PackageReference Include="Microsoft.EntityFrameworkCore.Design"> 19 + <PrivateAssets>all</PrivateAssets> 20 + <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> 21 + </PackageReference> 22 + <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" /> 19 23 </ItemGroup> 20 24 21 25 </Project>
+6 -8
AltBot.Api/Extensions.cs AltBot.Api/StartupExtensions.cs
··· 5 5 6 6 namespace AltBot.Api; 7 7 8 - public static class Extensions 8 + public static class StartupExtensions 9 9 { 10 10 // DTOs 11 11 record SubscriberCreate(Did Did, string? Handle); 12 12 record SubscriberUpdate(string? Handle, bool Active, string Rkey); 13 13 record PostCreate(Did Did, Cid Cid, string Rkey); 14 14 15 - public static void MapEndpoints(this WebApplication app) 15 + public static void MapApplicationEndpoints(this WebApplication app) 16 16 { 17 - app.MapGet("/health", () => Results.Ok("Healthy")); 18 - 19 17 // Subscribers endpoints 20 18 app.MapGet("/subscribers", async (DataContext db) => 21 19 { ··· 25 23 s.Did, 26 24 s.Handle, 27 25 s.Active, 28 - s.Timestamp, 26 + s.SeenAt, 29 27 s.Label 30 28 }) 31 29 .ToListAsync(); ··· 50 48 { 51 49 p.Cid, 52 50 p.Rkey, 53 - p.Timestamp 51 + p.SeenAt 54 52 }) 55 53 .ToListAsync(); 56 54 return Results.Ok(posts); ··· 70 68 Did = request.Did, 71 69 Handle = request.Handle, 72 70 Active = true, 73 - Timestamp = DateTime.UtcNow 71 + SeenAt = DateTime.UtcNow 74 72 }; 75 73 76 74 db.Subscribers.Add(subscriber); ··· 129 127 Did = request.Did, 130 128 Cid = request.Cid, 131 129 Rkey = request.Rkey, 132 - Timestamp = DateTime.UtcNow 130 + SeenAt = DateTime.UtcNow 133 131 }; 134 132 135 133 db.Posts.Add(post);
+14 -32
AltBot.Api/Program.cs
··· 1 1 using AltBot.Api; 2 + using AltBot.Core.Models; 2 3 using AltBot.Data; 3 4 using AltBot.ServiceDefaults; 4 5 using Microsoft.EntityFrameworkCore; 5 - using Scalar.AspNetCore; 6 6 7 7 var builder = WebApplication.CreateBuilder(args); 8 8 9 - // Add service defaults & Aspire client integrations. 10 9 builder.AddServiceDefaults(); 11 10 12 - // Add services to the container. 13 - builder.Services.AddProblemDetails(); 11 + builder.Services 12 + .AddProblemDetails() 13 + .AddOpenApi(); 14 14 15 - // Add DbContext 16 15 builder.Services.AddDbContext<DataContext>(options => 17 - options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection"))); 16 + options 17 + .UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection"), o => o.MapEnum<LabelLevel>("label")) 18 + .UseSnakeCaseNamingConvention()); 18 19 19 - // Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi 20 - builder.Services.AddOpenApi(); 20 + var app = builder.Build(); 21 21 22 - var app = builder.Build(); 22 + await app.Services.MigrateDatabaseAsync(); 23 23 24 - // Configure the HTTP request pipeline. 25 24 app.UseExceptionHandler(); 26 25 27 26 if (app.Environment.IsDevelopment()) 28 27 { 29 - app.MapOpenApi(); 30 - app.MapScalarApiReference(options => 31 - { 32 - List<ScalarServer> servers = []; 28 + app.MapOpenApi(); 29 + } 33 30 34 - string? httpsPort = Environment.GetEnvironmentVariable("ASPNETCORE_HTTPS_PORT"); 35 - if (httpsPort is not null) 36 - { 37 - servers.Add(new ScalarServer($"https://localhost:{httpsPort}")); 38 - } 31 + app.MapApplicationEndpoints(); 32 + app.MapDefaultEndpoints(); 39 33 40 - string? httpPort = Environment.GetEnvironmentVariable("ASPNETCORE_HTTP_PORT"); 41 - if (httpPort is not null) 42 - { 43 - servers.Add(new ScalarServer($"http://localhost:{httpPort}")); 44 - } 45 - 46 - options.Servers = servers; 47 - options.Title = "AltHeroes Bot Management API"; 48 - options.ShowSidebar = true; 49 - }); 50 - } 51 - 52 - app.MapEndpoints(); 34 + await app.RunAsync();
+1 -1
AltBot.Api/appsettings.json
··· 7 7 }, 8 8 "AllowedHosts": "*", 9 9 "ConnectionStrings": { 10 - "DefaultConnection": "Server=(localdb)\\mssqllocaldb;Database=LabelerBot;Trusted_Connection=True;MultipleActiveResultSets=true" 10 + "DefaultConnection": "" 11 11 } 12 12 }
+2
AltBot.AppHost/AltBot.AppHost.csproj
··· 17 17 18 18 <ItemGroup> 19 19 <PackageReference Include="Aspire.Hosting.AppHost" /> 20 + <PackageReference Include="Aspire.Hosting.PostgreSQL" /> 21 + <PackageReference Include="Scalar.Aspire" /> 20 22 </ItemGroup> 21 23 22 24 </Project>
+24 -3
AltBot.AppHost/AppHost.cs
··· 1 + using Scalar.Aspire; 2 + 1 3 var builder = DistributedApplication.CreateBuilder(args); 2 4 3 - var apiService = builder.AddProject<Projects.AltBot_Api>("apiservice") 4 - .WithHttpHealthCheck("/health"); 5 + var apiService = builder.AddProject<Projects.AltBot_Api>("apiservice"); 6 + 7 + builder.AddScalarApiReference().WithApiReference(apiService, options => 8 + { 9 + List<ScalarServer> servers = []; 10 + 11 + string? httpsPort = Environment.GetEnvironmentVariable("ASPNETCORE_HTTPS_PORT"); 12 + if (httpsPort is not null) 13 + { 14 + servers.Add(new ScalarServer($"https://localhost:{httpsPort}")); 15 + } 16 + 17 + string? httpPort = Environment.GetEnvironmentVariable("ASPNETCORE_HTTP_PORT"); 18 + if (httpPort is not null) 19 + { 20 + servers.Add(new ScalarServer($"http://localhost:{httpPort}")); 21 + } 22 + 23 + options.Servers = servers; 24 + options.ShowSidebar = true; 25 + }); 5 26 6 - builder.AddProject<Projects.AltBot_Worker>("altbot-worker"); 27 + builder.AddProject<Projects.AltBot_Worker>("botworker"); 7 28 8 29 builder.Build().Run();
+30
AltBot.Core.Tests/AltBot.Core.Tests.csproj
··· 1 + <?xml version="1.0" encoding="utf-8"?> 2 + <Project Sdk="Microsoft.NET.Sdk"> 3 + 4 + <PropertyGroup> 5 + <TargetFramework>net9.0</TargetFramework> 6 + <ImplicitUsings>enable</ImplicitUsings> 7 + <Nullable>enable</Nullable> 8 + <IsPackable>false</IsPackable> 9 + <IsTestProject>true</IsTestProject> 10 + </PropertyGroup> 11 + 12 + <ItemGroup> 13 + <PackageReference Include="Microsoft.NET.Test.Sdk" /> 14 + <PackageReference Include="Moq" /> 15 + <PackageReference Include="xunit" /> 16 + <PackageReference Include="xunit.runner.visualstudio"> 17 + <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> 18 + <PrivateAssets>all</PrivateAssets> 19 + </PackageReference> 20 + <PackageReference Include="coverlet.collector"> 21 + <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> 22 + <PrivateAssets>all</PrivateAssets> 23 + </PackageReference> 24 + </ItemGroup> 25 + 26 + <ItemGroup> 27 + <ProjectReference Include="..\AltBot.Core\AltBot.Core.csproj" /> 28 + </ItemGroup> 29 + 30 + </Project>
+175
AltBot.Core.Tests/Models/CidTests.cs
··· 1 + using AltBot.Core.Models; 2 + using Microsoft.Extensions.Logging; 3 + using Moq; 4 + using Xunit; 5 + 6 + namespace AltBot.Core.Tests.Models; 7 + 8 + public class CidTests 9 + { 10 + private readonly Mock<ILogger<Cid>> _loggerMock; 11 + 12 + public CidTests() 13 + { 14 + _loggerMock = new Mock<ILogger<Cid>>(); 15 + } 16 + 17 + [Theory] 18 + [InlineData("QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG")] 19 + [InlineData("bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi")] 20 + public void Create_ValidCid_ReturnsCidInstance(string validCid) 21 + { 22 + // Act 23 + var cid = Cid.Create(validCid); 24 + 25 + // Assert 26 + Assert.NotNull(cid); 27 + Assert.Equal(validCid, cid.Value); 28 + } 29 + 30 + [Theory] 31 + [InlineData("")] 32 + [InlineData(" ")] 33 + [InlineData(null)] 34 + [InlineData("invalid$characters")] 35 + public void Create_InvalidCid_ReturnsNull(string? invalidCid) 36 + { 37 + // Act 38 + var cid = Cid.Create(invalidCid!); 39 + 40 + // Assert 41 + Assert.Null(cid); 42 + } 43 + 44 + [Fact] 45 + public void Parse_ValidCid_ReturnsCidInstance() 46 + { 47 + // Arrange 48 + const string validCid = "QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG"; 49 + 50 + // Act 51 + var cid = Cid.Parse(validCid, null); 52 + 53 + // Assert 54 + Assert.NotNull(cid); 55 + Assert.Equal(validCid, cid.Value); 56 + } 57 + 58 + [Fact] 59 + public void Parse_InvalidCid_ThrowsFormatException() 60 + { 61 + // Arrange 62 + const string invalidCid = "invalid$characters"; 63 + 64 + // Act & Assert 65 + Assert.Throws<FormatException>(() => Cid.Parse(invalidCid, null)); 66 + } 67 + 68 + [Fact] 69 + public void TryParse_ValidCid_ReturnsTrue() 70 + { 71 + // Arrange 72 + const string validCid = "QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG"; 73 + 74 + // Act 75 + var success = Cid.TryParse(validCid, null, out var cid); 76 + 77 + // Assert 78 + Assert.True(success); 79 + Assert.NotNull(cid); 80 + Assert.Equal(validCid, cid.Value); 81 + } 82 + 83 + [Theory] 84 + [InlineData("")] 85 + [InlineData(null)] 86 + [InlineData("invalid$characters")] 87 + public void TryParse_InvalidCid_ReturnsFalse(string? invalidCid) 88 + { 89 + // Act 90 + var success = Cid.TryParse(invalidCid, null, out var cid); 91 + 92 + // Assert 93 + Assert.False(success); 94 + Assert.Null(cid); 95 + } 96 + 97 + [Fact] 98 + public void Equals_SameValue_ReturnsTrue() 99 + { 100 + // Arrange 101 + var cid1 = Cid.Parse("QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG", null); 102 + var cid2 = Cid.Parse("QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG", null); 103 + 104 + // Act & Assert 105 + Assert.True(cid1.Equals(cid2)); 106 + Assert.True(cid1 == cid2); 107 + } 108 + 109 + [Fact] 110 + public void Equals_DifferentValue_ReturnsFalse() 111 + { 112 + // Arrange 113 + var cid1 = Cid.Parse("QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG", null); 114 + var cid2 = Cid.Parse("bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi", null); 115 + 116 + // Act & Assert 117 + Assert.False(cid1.Equals(cid2)); 118 + Assert.False(cid1 == cid2); 119 + } 120 + 121 + [Fact] 122 + public void ToString_ReturnsValue() 123 + { 124 + // Arrange 125 + const string cidString = "QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG"; 126 + var cid = Cid.Parse(cidString, null); 127 + 128 + // Act 129 + var result = cid.ToString(); 130 + 131 + // Assert 132 + Assert.Equal(cidString, result); 133 + } 134 + 135 + [Fact] 136 + public void ImplicitConversion_ToString_ReturnsValue() 137 + { 138 + // Arrange 139 + const string cidString = "QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG"; 140 + var cid = Cid.Parse(cidString, null); 141 + 142 + // Act 143 + string result = cid; 144 + 145 + // Assert 146 + Assert.Equal(cidString, result); 147 + } 148 + 149 + [Fact] 150 + public void ExplicitConversion_FromString_ReturnsCid() 151 + { 152 + // Arrange 153 + const string cidString = "QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG"; 154 + 155 + // Act 156 + var cid = (Cid)cidString; 157 + 158 + // Assert 159 + Assert.Equal(cidString, cid.Value); 160 + } 161 + 162 + [Fact] 163 + public void Load_AnyString_ReturnsCid() 164 + { 165 + // Arrange 166 + const string cidString = "anyStringValue"; 167 + 168 + // Act 169 + var cid = Cid.Load(cidString); 170 + 171 + // Assert 172 + Assert.NotNull(cid); 173 + Assert.Equal(cidString, cid.Value); 174 + } 175 + }
+176
AltBot.Core.Tests/Models/DidTests.cs
··· 1 + using AltBot.Core.Models; 2 + using Microsoft.Extensions.Logging; 3 + using Moq; 4 + using Xunit; 5 + 6 + namespace AltBot.Core.Tests.Models; 7 + 8 + public class DidTests 9 + { 10 + private readonly Mock<ILogger<Did>> _loggerMock; 11 + 12 + public DidTests() 13 + { 14 + _loggerMock = new Mock<ILogger<Did>>(); 15 + } 16 + 17 + [Theory] 18 + [InlineData("did:web:example.com")] 19 + [InlineData("did:plc:7iza6de2dwap2sbkpav7c6c6")] 20 + public void Create_ValidDid_ReturnsDidInstance(string validDid) 21 + { 22 + // Act 23 + var did = Did.Create(validDid); 24 + 25 + // Assert 26 + Assert.NotNull(did); 27 + Assert.Equal(validDid, did.Value); 28 + } 29 + 30 + [Theory] 31 + [InlineData("")] 32 + [InlineData(" ")] 33 + [InlineData(null)] 34 + [InlineData("notenough:parts")] 35 + [InlineData("wrong:prefix:parts")] 36 + public void Create_InvalidDid_ReturnsNull(string? invalidDid) 37 + { 38 + // Act 39 + var did = Did.Create(invalidDid!); 40 + 41 + // Assert 42 + Assert.Null(did); 43 + } 44 + 45 + [Fact] 46 + public void Parse_ValidDid_ReturnsDidInstance() 47 + { 48 + // Arrange 49 + const string validDid = "did:web:example.com"; 50 + 51 + // Act 52 + var did = Did.Parse(validDid, null); 53 + 54 + // Assert 55 + Assert.NotNull(did); 56 + Assert.Equal(validDid, did.Value); 57 + } 58 + 59 + [Fact] 60 + public void Parse_InvalidDid_ThrowsFormatException() 61 + { 62 + // Arrange 63 + const string invalidDid = "invalid"; 64 + 65 + // Act & Assert 66 + Assert.Throws<FormatException>(() => Did.Parse(invalidDid, null)); 67 + } 68 + 69 + [Fact] 70 + public void TryParse_ValidDid_ReturnsTrue() 71 + { 72 + // Arrange 73 + const string validDid = "did:web:example.com"; 74 + 75 + // Act 76 + var success = Did.TryParse(validDid, null, out var did); 77 + 78 + // Assert 79 + Assert.True(success); 80 + Assert.NotNull(did); 81 + Assert.Equal(validDid, did.Value); 82 + } 83 + 84 + [Theory] 85 + [InlineData("")] 86 + [InlineData(null)] 87 + [InlineData("invalid")] 88 + public void TryParse_InvalidDid_ReturnsFalse(string? invalidDid) 89 + { 90 + // Act 91 + var success = Did.TryParse(invalidDid, null, out var did); 92 + 93 + // Assert 94 + Assert.False(success); 95 + Assert.Null(did); 96 + } 97 + 98 + [Fact] 99 + public void Equals_SameValue_ReturnsTrue() 100 + { 101 + // Arrange 102 + var did1 = Did.Parse("did:web:example.com", null); 103 + var did2 = Did.Parse("did:web:example.com", null); 104 + 105 + // Act & Assert 106 + Assert.True(did1.Equals(did2)); 107 + Assert.True(did1 == did2); 108 + } 109 + 110 + [Fact] 111 + public void Equals_DifferentValue_ReturnsFalse() 112 + { 113 + // Arrange 114 + var did1 = Did.Parse("did:web:example1.com", null); 115 + var did2 = Did.Parse("did:web:example2.com", null); 116 + 117 + // Act & Assert 118 + Assert.False(did1.Equals(did2)); 119 + Assert.False(did1 == did2); 120 + } 121 + 122 + [Fact] 123 + public void ToString_ReturnsValue() 124 + { 125 + // Arrange 126 + const string didString = "did:web:example.com"; 127 + var did = Did.Parse(didString, null); 128 + 129 + // Act 130 + var result = did.ToString(); 131 + 132 + // Assert 133 + Assert.Equal(didString, result); 134 + } 135 + 136 + [Fact] 137 + public void ImplicitConversion_ToString_ReturnsValue() 138 + { 139 + // Arrange 140 + const string didString = "did:web:example.com"; 141 + var did = Did.Parse(didString, null); 142 + 143 + // Act 144 + string result = did; 145 + 146 + // Assert 147 + Assert.Equal(didString, result); 148 + } 149 + 150 + [Fact] 151 + public void ExplicitConversion_FromString_ReturnsDid() 152 + { 153 + // Arrange 154 + const string didString = "did:web:example.com"; 155 + 156 + // Act 157 + var did = (Did)didString; 158 + 159 + // Assert 160 + Assert.Equal(didString, did.Value); 161 + } 162 + 163 + [Fact] 164 + public void Load_AnyString_ReturnsDid() 165 + { 166 + // Arrange 167 + const string didString = "any:string:value"; 168 + 169 + // Act 170 + var did = Did.Load(didString); 171 + 172 + // Assert 173 + Assert.NotNull(did); 174 + Assert.Equal(didString, did.Value); 175 + } 176 + }
+2 -2
AltBot.Core/Models/Cid.cs
··· 50 50 return false; 51 51 } 52 52 53 - // Basic CID validation - should be base32 or base58 encoded 54 - if (!Regex.IsMatch(cid, "^[123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz]+$")) 53 + // Very basic CID validation 54 + if (!Regex.IsMatch(cid, "^[123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz]+$")) 55 55 { 56 56 //_logger?.LogError("CID contains invalid characters"); 57 57 return false;
+16 -2
AltBot.Core/Models/Did.cs
··· 43 43 //_logger?.LogError("DID cannot be null or empty"); 44 44 return false; 45 45 } 46 - 47 - if (did.Split(':').Length < 3) 46 + 47 + var parts = did.Split(':'); 48 + if (parts.Length != 3) 48 49 { 49 50 //_logger?.LogError("DID requires prefix, method, and method-specific content"); 51 + return false; 52 + } 53 + 54 + if (parts[0] != "did") 55 + { 56 + //_logger?.LogError("DID must start with 'did' prefix"); 57 + return false; 58 + }; 59 + 60 + // Basic method name validation 61 + if (parts[1] is not "plc" and not "web") 62 + { 63 + //_logger?.LogError("DID method must be 'plc' or 'web'"); 50 64 return false; 51 65 } 52 66
+1 -1
AltBot.Core/Models/ImagePost.cs
··· 7 7 public required Cid Cid { get; set; } 8 8 public string? Rkey { get; set; } 9 9 public bool ValidAlt { get; set; } 10 - public DateTime Timestamp { get; set; } 10 + public DateTime SeenAt { get; set; } 11 11 public virtual Subscriber? Subscriber { get; set; } 12 12 }
-10
AltBot.Core/Models/LabelLevel.cs
··· 1 - namespace AltBot.Core.Models; 2 - 3 - public enum LabelLevel 4 - { 5 - None = 0, 6 - Bronze = 70, 7 - Silver = 85, 8 - Gold = 95, 9 - Hero = 100 10 - }
+1 -1
AltBot.Core/Models/LabelLevelEnum.cs
··· 1 - namespace AltBot.Data; 1 + namespace AltBot.Core.Models; 2 2 3 3 public enum LabelLevel 4 4 {
+1 -1
AltBot.Core/Models/Subscriber.cs
··· 4 4 public class Subscriber 5 5 { 6 6 public required Did Did { get; set; } 7 - public DateTime Timestamp { get; set; } 7 + public DateTime SeenAt { get; set; } 8 8 public bool Active { get; set; } 9 9 public string? Handle { get; set; } 10 10 public string? Rkey { get; set; }
+1 -1
AltBot.Data/AltBot.Data.csproj
··· 13 13 </ItemGroup> 14 14 15 15 <ItemGroup> 16 + <PackageReference Include="EFCore.NamingConventions" /> 16 17 <PackageReference Include="Microsoft.EntityFrameworkCore" /> 17 18 <PackageReference Include="Microsoft.EntityFrameworkCore.Design"> 18 19 <PrivateAssets>all</PrivateAssets> 19 20 <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> 20 21 </PackageReference> 21 - <PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" /> 22 22 <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" /> 23 23 </ItemGroup> 24 24
+4 -17
AltBot.Data/DataContext.cs
··· 4 4 5 5 namespace AltBot.Data; 6 6 7 - public class DataContext(DbContextOptions<DataContext> options) : DbContext(options) 7 + public class DataContext : DbContext 8 8 { 9 + public DataContext(DbContextOptions<DataContext> options) : base(options) { } 10 + 9 11 public DbSet<ImagePost> Posts { get; set; } 10 12 public DbSet<Subscriber> Subscribers { get; set; } 11 13 ··· 17 19 v => v.Value, 18 20 v => Did.Load(v)); 19 21 20 - var labelConverter = new ValueConverter<LabelLevel, string>( 21 - v => v.ToString(), 22 - v => Enum.Parse<LabelLevel>(v)); 23 - 24 22 modelBuilder.Entity<ImagePost>(entity => 25 23 { 26 - entity.ToTable("imagePost"); 24 + entity.ToTable("image_post"); 27 25 28 26 entity.HasKey(x => new { x.Did, x.Cid }); 29 27 30 28 entity.Property(x => x.Did) 31 - .HasColumnName("did") 32 29 .HasMaxLength(2048) 33 30 .HasConversion(didConverter); 34 31 35 32 entity.Property(x => x.Cid) 36 - .HasColumnName("cid") 37 33 .HasMaxLength(1000) 38 34 .HasConversion( 39 35 v => v.ToString(), 40 36 v => Cid.Load(v)); 41 37 42 38 entity.Property(x => x.Rkey) 43 - .HasColumnName("rKey") 44 39 .HasMaxLength(512); 45 40 46 41 entity.HasOne(x => x.Subscriber) ··· 56 51 entity.HasKey(x => x.Did); 57 52 58 53 entity.Property(x => x.Did) 59 - .HasColumnName("did") 60 54 .HasMaxLength(2048) 61 55 .HasConversion(didConverter); 62 56 ··· 67 61 .IsUnicode() 68 62 .HasMaxLength(250); 69 63 70 - entity.Property(x => x.Label) 71 - .HasColumnName("label") 72 - .HasConversion(labelConverter) 73 - .HasDefaultValue(LabelLevel.None); 74 - 75 - 76 64 entity.Property(x => x.Rkey) 77 - .HasColumnName("RKey") 78 65 .HasMaxLength(100); 79 66 }); 80 67 }
+20
AltBot.Data/DataContextFactory.cs
··· 1 + using AltBot.Core.Models; 2 + using Microsoft.EntityFrameworkCore; 3 + using Microsoft.EntityFrameworkCore.Design; 4 + 5 + namespace AltBot.Data; 6 + 7 + /// <summary> 8 + /// This factory is used to create the DataContext at design time for EF Core tools (e.g., migrations). 9 + /// </summary> 10 + public class DataContextFactory : IDesignTimeDbContextFactory<DataContext> 11 + { 12 + public DataContext CreateDbContext(string[] args) 13 + { 14 + var optionsBuilder = new DbContextOptionsBuilder<DataContext>(); 15 + optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test", options => options.MapEnum<LabelLevel>("label")) 16 + .UseSnakeCaseNamingConvention(); 17 + 18 + return new DataContext(optionsBuilder.Options); 19 + } 20 + }
+118
AltBot.Data/Migrations/20251019172111_Initial.Designer.cs
··· 1 + // <auto-generated /> 2 + using System; 3 + using AltBot.Core.Models; 4 + using AltBot.Data; 5 + using Microsoft.EntityFrameworkCore; 6 + using Microsoft.EntityFrameworkCore.Infrastructure; 7 + using Microsoft.EntityFrameworkCore.Migrations; 8 + using Microsoft.EntityFrameworkCore.Storage.ValueConversion; 9 + using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; 10 + 11 + #nullable disable 12 + 13 + namespace AltBot.Data.Migrations 14 + { 15 + [DbContext(typeof(DataContext))] 16 + [Migration("20251019172111_Initial")] 17 + partial class Initial 18 + { 19 + /// <inheritdoc /> 20 + protected override void BuildTargetModel(ModelBuilder modelBuilder) 21 + { 22 + #pragma warning disable 612, 618 23 + modelBuilder 24 + .HasAnnotation("ProductVersion", "9.0.10") 25 + .HasAnnotation("Relational:MaxIdentifierLength", 63); 26 + 27 + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "label", new[] { "bronze", "gold", "hero", "none", "silver" }); 28 + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); 29 + 30 + modelBuilder.Entity("AltBot.Core.Models.ImagePost", b => 31 + { 32 + b.Property<string>("Did") 33 + .HasMaxLength(2048) 34 + .HasColumnType("character varying(2048)") 35 + .HasColumnName("did"); 36 + 37 + b.Property<string>("Cid") 38 + .HasMaxLength(1000) 39 + .HasColumnType("character varying(1000)") 40 + .HasColumnName("cid"); 41 + 42 + b.Property<string>("Rkey") 43 + .HasMaxLength(512) 44 + .HasColumnType("character varying(512)") 45 + .HasColumnName("rkey"); 46 + 47 + b.Property<DateTime>("SeenAt") 48 + .HasColumnType("timestamp with time zone") 49 + .HasColumnName("seen_at"); 50 + 51 + b.Property<bool>("ValidAlt") 52 + .HasColumnType("boolean") 53 + .HasColumnName("valid_alt"); 54 + 55 + b.HasKey("Did", "Cid") 56 + .HasName("pk_image_post"); 57 + 58 + b.ToTable("image_post", (string)null); 59 + }); 60 + 61 + modelBuilder.Entity("AltBot.Core.Models.Subscriber", b => 62 + { 63 + b.Property<string>("Did") 64 + .HasMaxLength(2048) 65 + .HasColumnType("character varying(2048)") 66 + .HasColumnName("did"); 67 + 68 + b.Property<bool>("Active") 69 + .ValueGeneratedOnAdd() 70 + .HasColumnType("boolean") 71 + .HasDefaultValue(true) 72 + .HasColumnName("active"); 73 + 74 + b.Property<string>("Handle") 75 + .HasMaxLength(250) 76 + .IsUnicode(true) 77 + .HasColumnType("character varying(250)") 78 + .HasColumnName("handle"); 79 + 80 + b.Property<LabelLevel>("Label") 81 + .HasColumnType("label") 82 + .HasColumnName("label"); 83 + 84 + b.Property<string>("Rkey") 85 + .HasMaxLength(100) 86 + .HasColumnType("character varying(100)") 87 + .HasColumnName("rkey"); 88 + 89 + b.Property<DateTime>("SeenAt") 90 + .HasColumnType("timestamp with time zone") 91 + .HasColumnName("seen_at"); 92 + 93 + b.HasKey("Did") 94 + .HasName("pk_subscriber"); 95 + 96 + b.ToTable("subscriber", (string)null); 97 + }); 98 + 99 + modelBuilder.Entity("AltBot.Core.Models.ImagePost", b => 100 + { 101 + b.HasOne("AltBot.Core.Models.Subscriber", "Subscriber") 102 + .WithMany("Posts") 103 + .HasForeignKey("Did") 104 + .OnDelete(DeleteBehavior.Cascade) 105 + .IsRequired() 106 + .HasConstraintName("FK_ImagePost_Subscriber"); 107 + 108 + b.Navigation("Subscriber"); 109 + }); 110 + 111 + modelBuilder.Entity("AltBot.Core.Models.Subscriber", b => 112 + { 113 + b.Navigation("Posts"); 114 + }); 115 + #pragma warning restore 612, 618 116 + } 117 + } 118 + }
+66
AltBot.Data/Migrations/20251019172111_Initial.cs
··· 1 + using System; 2 + using AltBot.Core.Models; 3 + using Microsoft.EntityFrameworkCore.Migrations; 4 + 5 + #nullable disable 6 + 7 + namespace AltBot.Data.Migrations 8 + { 9 + /// <inheritdoc /> 10 + public partial class Initial : Migration 11 + { 12 + /// <inheritdoc /> 13 + protected override void Up(MigrationBuilder migrationBuilder) 14 + { 15 + migrationBuilder.AlterDatabase() 16 + .Annotation("Npgsql:Enum:label", "bronze,gold,hero,none,silver"); 17 + 18 + migrationBuilder.CreateTable( 19 + name: "subscriber", 20 + columns: table => new 21 + { 22 + did = table.Column<string>(type: "character varying(2048)", maxLength: 2048, nullable: false), 23 + seen_at = table.Column<DateTime>(type: "timestamp with time zone", nullable: false), 24 + active = table.Column<bool>(type: "boolean", nullable: false, defaultValue: true), 25 + handle = table.Column<string>(type: "character varying(250)", maxLength: 250, nullable: true), 26 + rkey = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: true), 27 + label = table.Column<LabelLevel>(type: "label", nullable: false) 28 + }, 29 + constraints: table => 30 + { 31 + table.PrimaryKey("pk_subscriber", x => x.did); 32 + }); 33 + 34 + migrationBuilder.CreateTable( 35 + name: "image_post", 36 + columns: table => new 37 + { 38 + did = table.Column<string>(type: "character varying(2048)", maxLength: 2048, nullable: false), 39 + cid = table.Column<string>(type: "character varying(1000)", maxLength: 1000, nullable: false), 40 + rkey = table.Column<string>(type: "character varying(512)", maxLength: 512, nullable: true), 41 + valid_alt = table.Column<bool>(type: "boolean", nullable: false), 42 + seen_at = table.Column<DateTime>(type: "timestamp with time zone", nullable: false) 43 + }, 44 + constraints: table => 45 + { 46 + table.PrimaryKey("pk_image_post", x => new { x.did, x.cid }); 47 + table.ForeignKey( 48 + name: "FK_ImagePost_Subscriber", 49 + column: x => x.did, 50 + principalTable: "subscriber", 51 + principalColumn: "did", 52 + onDelete: ReferentialAction.Cascade); 53 + }); 54 + } 55 + 56 + /// <inheritdoc /> 57 + protected override void Down(MigrationBuilder migrationBuilder) 58 + { 59 + migrationBuilder.DropTable( 60 + name: "image_post"); 61 + 62 + migrationBuilder.DropTable( 63 + name: "subscriber"); 64 + } 65 + } 66 + }
+115
AltBot.Data/Migrations/DataContextModelSnapshot.cs
··· 1 + // <auto-generated /> 2 + using System; 3 + using AltBot.Core.Models; 4 + using AltBot.Data; 5 + using Microsoft.EntityFrameworkCore; 6 + using Microsoft.EntityFrameworkCore.Infrastructure; 7 + using Microsoft.EntityFrameworkCore.Storage.ValueConversion; 8 + using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; 9 + 10 + #nullable disable 11 + 12 + namespace AltBot.Data.Migrations 13 + { 14 + [DbContext(typeof(DataContext))] 15 + partial class DataContextModelSnapshot : ModelSnapshot 16 + { 17 + protected override void BuildModel(ModelBuilder modelBuilder) 18 + { 19 + #pragma warning disable 612, 618 20 + modelBuilder 21 + .HasAnnotation("ProductVersion", "9.0.10") 22 + .HasAnnotation("Relational:MaxIdentifierLength", 63); 23 + 24 + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "label", new[] { "bronze", "gold", "hero", "none", "silver" }); 25 + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); 26 + 27 + modelBuilder.Entity("AltBot.Core.Models.ImagePost", b => 28 + { 29 + b.Property<string>("Did") 30 + .HasMaxLength(2048) 31 + .HasColumnType("character varying(2048)") 32 + .HasColumnName("did"); 33 + 34 + b.Property<string>("Cid") 35 + .HasMaxLength(1000) 36 + .HasColumnType("character varying(1000)") 37 + .HasColumnName("cid"); 38 + 39 + b.Property<string>("Rkey") 40 + .HasMaxLength(512) 41 + .HasColumnType("character varying(512)") 42 + .HasColumnName("rkey"); 43 + 44 + b.Property<DateTime>("SeenAt") 45 + .HasColumnType("timestamp with time zone") 46 + .HasColumnName("seen_at"); 47 + 48 + b.Property<bool>("ValidAlt") 49 + .HasColumnType("boolean") 50 + .HasColumnName("valid_alt"); 51 + 52 + b.HasKey("Did", "Cid") 53 + .HasName("pk_image_post"); 54 + 55 + b.ToTable("image_post", (string)null); 56 + }); 57 + 58 + modelBuilder.Entity("AltBot.Core.Models.Subscriber", b => 59 + { 60 + b.Property<string>("Did") 61 + .HasMaxLength(2048) 62 + .HasColumnType("character varying(2048)") 63 + .HasColumnName("did"); 64 + 65 + b.Property<bool>("Active") 66 + .ValueGeneratedOnAdd() 67 + .HasColumnType("boolean") 68 + .HasDefaultValue(true) 69 + .HasColumnName("active"); 70 + 71 + b.Property<string>("Handle") 72 + .HasMaxLength(250) 73 + .IsUnicode(true) 74 + .HasColumnType("character varying(250)") 75 + .HasColumnName("handle"); 76 + 77 + b.Property<LabelLevel>("Label") 78 + .HasColumnType("label") 79 + .HasColumnName("label"); 80 + 81 + b.Property<string>("Rkey") 82 + .HasMaxLength(100) 83 + .HasColumnType("character varying(100)") 84 + .HasColumnName("rkey"); 85 + 86 + b.Property<DateTime>("SeenAt") 87 + .HasColumnType("timestamp with time zone") 88 + .HasColumnName("seen_at"); 89 + 90 + b.HasKey("Did") 91 + .HasName("pk_subscriber"); 92 + 93 + b.ToTable("subscriber", (string)null); 94 + }); 95 + 96 + modelBuilder.Entity("AltBot.Core.Models.ImagePost", b => 97 + { 98 + b.HasOne("AltBot.Core.Models.Subscriber", "Subscriber") 99 + .WithMany("Posts") 100 + .HasForeignKey("Did") 101 + .OnDelete(DeleteBehavior.Cascade) 102 + .IsRequired() 103 + .HasConstraintName("FK_ImagePost_Subscriber"); 104 + 105 + b.Navigation("Subscriber"); 106 + }); 107 + 108 + modelBuilder.Entity("AltBot.Core.Models.Subscriber", b => 109 + { 110 + b.Navigation("Posts"); 111 + }); 112 + #pragma warning restore 612, 618 113 + } 114 + } 115 + }
+14
AltBot.Data/StartupExtensions.cs
··· 1 + using Microsoft.EntityFrameworkCore; 2 + using Microsoft.Extensions.DependencyInjection; 3 + 4 + namespace AltBot.Data; 5 + 6 + public static class StartupExtensions 7 + { 8 + public static async Task MigrateDatabaseAsync(this IServiceProvider services) 9 + { 10 + using var scope = services.CreateScope(); 11 + var db = scope.ServiceProvider.GetRequiredService<DataContext>(); 12 + await db.Database.MigrateAsync(); 13 + } 14 + }
+2 -2
AltBot.ServiceDefaults/Extensions.cs AltBot.ServiceDefaults/StartupExtensions.cs
··· 16 16 // Adds common .NET Aspire services: service discovery, resilience, health checks, and OpenTelemetry. 17 17 // This project should be referenced by each service project in your solution. 18 18 // To learn more about using this project, see https://aka.ms/dotnet/aspire/service-defaults 19 - public static class Extensions 19 + public static class StartupExtensions 20 20 { 21 21 private const string HealthEndpointPath = "/health"; 22 22 private const string AlivenessEndpointPath = "/alive"; ··· 39 39 // Turn on service discovery by default 40 40 http.AddServiceDiscovery(); 41 41 }); 42 - 42 + 43 43 return builder; 44 44 } 45 45
-1
AltBot.Worker/AltBot.Worker.csproj
··· 10 10 11 11 <ItemGroup> 12 12 <PackageReference Include="idunno.AtProto" /> 13 - <PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" /> 14 13 </ItemGroup> 15 14 16 15 <ItemGroup>
+6
AltBot.sln
··· 14 14 EndProject 15 15 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AltBot.Worker", "AltBot.Worker\AltBot.Worker.csproj", "{B0853F52-805C-69D3-53B7-5E7EF2EA6C8F}" 16 16 EndProject 17 + Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AltBot.Core.Tests", "AltBot.Core.Tests\AltBot.Core.Tests.csproj", "{7A4C8182-D660-6BCD-8C7C-7DAB839D1A5E}" 18 + EndProject 17 19 Global 18 20 GlobalSection(SolutionConfigurationPlatforms) = preSolution 19 21 Debug|Any CPU = Debug|Any CPU ··· 44 46 {B0853F52-805C-69D3-53B7-5E7EF2EA6C8F}.Debug|Any CPU.Build.0 = Debug|Any CPU 45 47 {B0853F52-805C-69D3-53B7-5E7EF2EA6C8F}.Release|Any CPU.ActiveCfg = Release|Any CPU 46 48 {B0853F52-805C-69D3-53B7-5E7EF2EA6C8F}.Release|Any CPU.Build.0 = Release|Any CPU 49 + {7A4C8182-D660-6BCD-8C7C-7DAB839D1A5E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 50 + {7A4C8182-D660-6BCD-8C7C-7DAB839D1A5E}.Debug|Any CPU.Build.0 = Debug|Any CPU 51 + {7A4C8182-D660-6BCD-8C7C-7DAB839D1A5E}.Release|Any CPU.ActiveCfg = Release|Any CPU 52 + {7A4C8182-D660-6BCD-8C7C-7DAB839D1A5E}.Release|Any CPU.Build.0 = Release|Any CPU 47 53 EndGlobalSection 48 54 GlobalSection(SolutionProperties) = preSolution 49 55 HideSolutionNode = FALSE
+19 -12
Directory.Packages.props
··· 7 7 <!-- Aspire --> 8 8 <PackageVersion Include="Aspire.Hosting.AppHost" Version="9.5.1" /> 9 9 <PackageVersion Include="Aspire.Hosting.Testing" Version="9.5.1" /> 10 + <PackageVersion Include="Aspire.Hosting.PostgreSQL" Version="9.5.1" /> 11 + <PackageVersion Include="Scalar.Aspire" Version="0.6.0" /> 10 12 <!-- Entity Framework --> 11 - <PackageVersion Include="idunno.AtProto" Version="1.1.0" /> 12 - <PackageVersion Include="idunno.Bluesky" Version="1.1.0" /> 13 - <PackageVersion Include="Microsoft.EntityFrameworkCore" Version="9.0.9" /> 14 - <PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.9" /> 15 - <PackageVersion Include="Microsoft.EntityFrameworkCore.SqlServer" Version="9.0.9" /> 16 - <PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.9" /> 17 - <PackageVersion Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" Version="1.23.0" /> 13 + <PackageVersion Include="Microsoft.EntityFrameworkCore" Version="9.0.10" /> 14 + <PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.10" /> 15 + <PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.10" /> 18 16 <PackageVersion Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" /> 17 + <PackageVersion Include="EFCore.NamingConventions" Version="9.0.0" /> 19 18 <!-- ASP.NET Core --> 20 - <PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="9.0.9" /> 21 - <PackageVersion Include="Microsoft.Extensions.Http.Resilience" Version="9.9.0" /> 19 + <PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="9.0.10" /> 20 + <PackageVersion Include="Microsoft.Extensions.Http.Resilience" Version="9.10.0" /> 22 21 <PackageVersion Include="Microsoft.Extensions.ServiceDiscovery" Version="9.5.1" /> 22 + <PackageVersion Include="Scalar.AspNetCore" Version="0.9.0" /> 23 + <PackageVersion Include="Swashbuckle.AspNetCore" Version="6.5.0" /> 23 24 <!-- Testing --> 24 25 <PackageVersion Include="MSTest" Version="3.11.0" /> 26 + <PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.0.0" /> 27 + <PackageVersion Include="Moq" Version="4.20.72" /> 28 + <PackageVersion Include="xunit" Version="2.9.3" /> 29 + <PackageVersion Include="xunit.runner.visualstudio" Version="3.1.5" /> 30 + <PackageVersion Include="coverlet.collector" Version="6.0.4" /> 25 31 <!-- OpenTelemetry --> 26 - <PackageVersion Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.13.0" /> 27 - <PackageVersion Include="OpenTelemetry.Extensions.Hosting" Version="1.13.0" /> 32 + <PackageVersion Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.13.1" /> 33 + <PackageVersion Include="OpenTelemetry.Extensions.Hosting" Version="1.13.1" /> 28 34 <PackageVersion Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.12.0" /> 29 35 <PackageVersion Include="OpenTelemetry.Instrumentation.Http" Version="1.12.0" /> 30 36 <PackageVersion Include="OpenTelemetry.Instrumentation.Runtime" Version="1.12.0" /> 31 37 <!-- Serilog --> 32 - <PackageVersion Include="Scalar.AspNetCore" Version="2.8.10" /> 33 38 <PackageVersion Include="Serilog.AspNetCore" Version="9.0.0" /> 34 39 <PackageVersion Include="Serilog.Enrichers.Environment" Version="3.0.1" /> 35 40 <PackageVersion Include="Serilog.Enrichers.Thread" Version="4.0.0" /> 36 41 <PackageVersion Include="Serilog.Settings.Configuration" Version="9.0.0" /> 37 42 <PackageVersion Include="Serilog.Sinks.Console" Version="6.0.0" /> 38 43 <PackageVersion Include="Serilog.Sinks.Debug" Version="3.0.0" /> 44 + <!-- Other --> 45 + <PackageVersion Include="idunno.AtProto" Version="1.1.0" /> 39 46 </ItemGroup> 40 47 </Project>