feat(proj): init

This commit is contained in:
vl.arkhangelskii
2026-09-21 03:53:15 +03:00
commit 3e34f391b3
258 changed files with 20968 additions and 0 deletions
+164
View File
@@ -0,0 +1,164 @@
using TileServer.Application.Accounts;
using TileServer.Domain;
using TileServer.Domain.Accounts;
using TileServer.Domain.Exceptions;
namespace TileServer.UnitTests;
public sealed class AccountSlugTests
{
[Theory]
[InlineData("alice", "alice")]
[InlineData("Alice.K", "alice-k")]
[InlineData("user_name", "user-name")]
[InlineData("42go", "u-42go")]
public void FromLogin_ProducesResourceName(string login, string expected)
{
var slug = AccountSlug.FromLogin(login);
Assert.Equal(expected, slug);
Assert.True(ResourceName.IsValid(slug));
}
[Fact]
public void WithSuffix_StaysValid()
{
var slug = AccountSlug.WithSuffix("alice", 2);
Assert.Equal("alice-2", slug);
Assert.True(ResourceName.IsValid(slug));
}
}
public sealed class ApiTokenHashTests
{
[Fact]
public void Compute_Verify_RoundTrip()
{
var plaintext = ApiTokenHash.CreatePlaintext();
Assert.StartsWith("ts_", plaintext);
var hash = ApiTokenHash.Compute(plaintext);
Assert.True(ApiTokenHash.Verify(plaintext, hash));
Assert.False(ApiTokenHash.Verify(plaintext + "x", hash));
Assert.Equal(plaintext[..11], ApiTokenHash.DisplayPrefix(plaintext));
}
}
public sealed class ApiTokenServiceTests
{
[Fact]
public async Task Resolve_RejectsRevokedToken()
{
var user = new User
{
Id = Guid.NewGuid(),
YandexId = "1",
Login = "alice",
DisplayName = "Alice",
Slug = "alice"
};
var plaintext = ApiTokenHash.CreatePlaintext();
var token = new ApiToken
{
Id = Guid.NewGuid(),
UserId = user.Id,
Name = "test",
Hash = ApiTokenHash.Compute(plaintext),
Prefix = ApiTokenHash.DisplayPrefix(plaintext),
CreatedAt = DateTimeOffset.UtcNow,
RevokedAt = DateTimeOffset.UtcNow
};
var service = new ApiTokenService(new MemoryUsers(user), new MemoryTokens(token));
var error = await Assert.ThrowsAsync<UnauthenticatedException>(
() => service.ResolveAsync("alice", plaintext, null, CancellationToken.None));
Assert.Contains("revoked", error.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task Resolve_AcceptsActiveTokenForSlug()
{
var user = new User
{
Id = Guid.NewGuid(),
YandexId = "1",
Login = "alice",
DisplayName = "Alice",
Slug = "alice"
};
var plaintext = ApiTokenHash.CreatePlaintext();
var token = new ApiToken
{
Id = Guid.NewGuid(),
UserId = user.Id,
Name = "test",
Hash = ApiTokenHash.Compute(plaintext),
Prefix = ApiTokenHash.DisplayPrefix(plaintext),
CreatedAt = DateTimeOffset.UtcNow
};
var service = new ApiTokenService(new MemoryUsers(user), new MemoryTokens(token));
var access = await service.ResolveAsync("alice", null, plaintext, CancellationToken.None);
Assert.Equal(user.Id, access.User.Id);
Assert.Equal(token.Id, access.Token.Id);
}
private sealed class MemoryUsers(User user) : IUserRepository
{
public Task<User?> FindByIdAsync(Guid id, CancellationToken ct)
=> Task.FromResult(id == user.Id ? user : null);
public Task<User?> FindByYandexIdAsync(string yandexId, CancellationToken ct)
=> Task.FromResult(yandexId == user.YandexId ? user : null);
public Task<User?> FindBySlugAsync(string slug, CancellationToken ct)
=> Task.FromResult(slug == user.Slug ? user : null);
public Task<bool> SlugExistsAsync(string slug, Guid? exceptUserId, CancellationToken ct)
=> Task.FromResult(slug == user.Slug && exceptUserId != user.Id);
public Task AddAsync(User incoming, CancellationToken ct) => Task.CompletedTask;
public Task SaveChangesAsync(CancellationToken ct) => Task.CompletedTask;
}
private sealed class MemoryTokens(ApiToken token) : IApiTokenRepository
{
public Task<IReadOnlyList<ApiToken>> ListByUserAsync(Guid userId, CancellationToken ct)
=> Task.FromResult<IReadOnlyList<ApiToken>>(token.UserId == userId ? [token] : []);
public Task<ApiToken?> FindByIdAsync(Guid userId, Guid tokenId, CancellationToken ct)
=> Task.FromResult(token.UserId == userId && token.Id == tokenId ? token : null);
public Task<ApiToken?> FindByHashAsync(string hash, CancellationToken ct)
=> Task.FromResult(token.Hash == hash ? token : null);
public Task<int> CountActiveAsync(Guid userId, CancellationToken ct)
=> Task.FromResult(token.UserId == userId && !token.IsRevoked ? 1 : 0);
public Task AddAsync(ApiToken incoming, CancellationToken ct) => Task.CompletedTask;
public Task SaveChangesAsync(CancellationToken ct) => Task.CompletedTask;
}
}
public sealed class UsageAccumulatorTests
{
[Fact]
public void Drain_SumsBatchedIncrements()
{
var userId = Guid.NewGuid();
var tokenId = Guid.NewGuid();
var acc = new UsageAccumulator();
acc.RecordTile(userId, tokenId, 100);
acc.RecordTile(userId, tokenId, 40);
acc.RecordStyle(userId, tokenId, 20);
var deltas = acc.Drain();
var row = Assert.Single(deltas);
Assert.Equal(userId, row.UserId);
Assert.Equal(tokenId, row.TokenId);
Assert.Equal(2, row.TileRequests);
Assert.Equal(1, row.StyleRequests);
Assert.Equal(160, row.Bytes);
Assert.Empty(acc.Drain());
}
}
@@ -0,0 +1,177 @@
using System.Text.Json.Nodes;
using Microsoft.Extensions.Options;
using TileServer.Application.Configuration;
using TileServer.Application.Extracts;
using TileServer.Application.Styles;
using TileServer.Application.Tiles;
using TileServer.Domain.Extracts;
using TileServer.Domain.Tiles;
namespace TileServer.UnitTests;
public sealed class StyleServiceTests
{
[Fact]
public void Get_BindsAbsoluteTilesAndSourceMaxZoom()
{
var extract = new Extract
{
Id = "central-fed-district",
Name = "ЦФО",
Url = new Uri("https://download.geofabrik.de/russia/central-fed-district-latest.osm.pbf"),
CenterLon = 37.6,
CenterLat = 55.7,
MinZoom = 0,
MaxZoom = 14
};
var metadata = new TilesetMetadata(
extract.Id,
extract.Name,
"pbf",
29.2,
48.7,
47.6,
59.6,
38.4,
54.2,
5,
0,
14,
[]);
var template = new JsonObject
{
["version"] = 8,
["name"] = "OSM Bright",
["sources"] = new JsonObject
{
["openmaptiles"] = new JsonObject
{
["type"] = "vector",
["url"] = "/api/v1/tiles/central-fed-district.json"
}
}
};
var service = new StyleService(
new MemoryStyleCatalog(template),
new MemoryExtractCatalog(extract),
new MemoryTileStore(metadata),
Options.Create(new TileServerOptions
{
GlyphsUrl = "https://fonts.example/{fontstack}/{range}.pbf"
}));
var style = service.Get("osm-bright", extract.Id, "https://tile-server.ru");
var source = style["sources"]!["openmaptiles"]!.AsObject();
Assert.Null(source["url"]);
Assert.Equal(0, source["minzoom"]!.GetValue<int>());
Assert.Equal(14, source["maxzoom"]!.GetValue<int>());
Assert.Equal("xyz", source["scheme"]!.GetValue<string>());
Assert.Equal(
"https://tile-server.ru/api/v1/tiles/central-fed-district/{z}/{x}/{y}.pbf?v=3",
source["tiles"]!.AsArray()[0]!.GetValue<string>());
Assert.Null(source["bounds"]);
}
[Fact]
public void Bind_UsesPersonalTileUrlAndToken()
{
var extract = new Extract
{
Id = "central-fed-district",
Name = "ЦФО",
Url = new Uri("https://download.geofabrik.de/russia/central-fed-district-latest.osm.pbf"),
CenterLon = 37.6,
CenterLat = 55.7,
MinZoom = 0,
MaxZoom = 14
};
var metadata = new TilesetMetadata(
extract.Id,
extract.Name,
"pbf",
29.2,
48.7,
47.6,
59.6,
38.4,
54.2,
5,
0,
14,
[]);
var template = new JsonObject
{
["version"] = 8,
["sources"] = new JsonObject
{
["openmaptiles"] = new JsonObject
{
["type"] = "vector"
}
}
};
var service = new StyleService(
new MemoryStyleCatalog(template),
new MemoryExtractCatalog(extract),
new MemoryTileStore(metadata),
Options.Create(new TileServerOptions()));
var tilesUrl = "https://tile-server.ru/u/alice/tiles/central-fed-district/{z}/{x}/{y}.pbf?v=3&token=ts_abc";
var style = service.Bind(template, extract.Id, tilesUrl);
var tile = style["sources"]!["openmaptiles"]!["tiles"]!.AsArray()[0]!.GetValue<string>();
Assert.Contains("/u/alice/", tile, StringComparison.Ordinal);
Assert.Contains("token=ts_abc", tile, StringComparison.Ordinal);
}
private sealed class MemoryStyleCatalog(JsonObject style) : IStyleCatalog
{
public IReadOnlyList<MapStyleInfo> List() => [new("osm-bright", DateTimeOffset.UnixEpoch)];
public JsonObject GetRequired(string name)
=> JsonNode.Parse(style.ToJsonString())!.AsObject();
public Task SaveAsync(string name, JsonObject incoming, CancellationToken ct) => Task.CompletedTask;
public Task SeedDefaultsAsync(CancellationToken ct) => Task.CompletedTask;
}
private sealed class MemoryExtractCatalog(Extract extract) : IExtractCatalog
{
public IReadOnlyList<Extract> GetAll() => [extract];
public Extract? Find(string id) => id == extract.Id ? extract : null;
public Extract GetRequired(string id)
=> Find(id) ?? throw new InvalidOperationException(id);
public Task AddOrUpdateAsync(Extract incoming, CancellationToken ct) => Task.CompletedTask;
public Task RemoveAsync(string id, CancellationToken ct) => Task.CompletedTask;
}
private sealed class MemoryTileStore(TilesetMetadata metadata) : ITileStore
{
public bool Exists(string extractId) => true;
public Task<TileData?> GetTileAsync(string extractId, TileCoordinate tile, CancellationToken ct)
=> Task.FromResult<TileData?>(null);
public TilesetMetadata? GetMetadata(string extractId) => metadata;
public string GetActivePath(string extractId) => extractId;
public string GetStagingPath(string extractId) => extractId + ".building.mbtiles";
public void Activate(string extractId, string stagingPath)
{
}
}
}
@@ -0,0 +1,49 @@
using TileServer.Domain;
using TileServer.Domain.Tiles;
namespace TileServer.UnitTests;
public sealed class TileCoordinateTests
{
[Fact]
public void Ancestor_ShiftsToParentZoom()
{
var child = new TileCoordinate(3, 5, 6);
Assert.Equal(new TileCoordinate(1, 1, 1), child.Ancestor(1));
Assert.Equal(child, child.Ancestor(3));
}
[Fact]
public void ToTmsY_FlipsXyzOrigin()
{
var tile = new TileCoordinate(3, 1, 0);
Assert.Equal(7, tile.ToTmsY());
}
[Fact]
public void ClampMaxZoom_CapsAtPlanetilerLimit()
{
Assert.Equal(15, PlanetilerLimits.ClampMaxZoom(18));
Assert.Equal(15, PlanetilerLimits.ClampMaxZoom(15));
Assert.Equal(0, PlanetilerLimits.ClampMaxZoom(-1));
}
[Theory]
[InlineData(0, 0, 0, true)]
[InlineData(1, 2, 0, false)]
[InlineData(-1, 0, 0, false)]
public void IsValid_RespectsZoomBounds(int z, int x, int y, bool expected)
=> Assert.Equal(expected, new TileCoordinate(z, x, y).IsValid);
}
public sealed class ResourceNameTests
{
[Theory]
[InlineData("central-fed-district", true)]
[InlineData("osm-bright", true)]
[InlineData("Dark", false)]
[InlineData("1south", false)]
[InlineData("", false)]
public void IsValid_AcceptsLowercaseIds(string value, bool expected)
=> Assert.Equal(expected, ResourceName.IsValid(value));
}
@@ -0,0 +1,31 @@
using System.IO.Compression;
using TileServer.Domain.Tiles;
namespace TileServer.UnitTests;
public sealed class TileDataTests
{
[Fact]
public void Uncompressed_ReturnsRawBytesWhenNotGzipped()
{
var payload = new byte[] { 0x1A, 0x2B, 0x3C };
var tile = new TileData(payload, false);
Assert.Same(payload, tile.Uncompressed());
}
[Fact]
public void Uncompressed_InflatesGzipPayload()
{
var raw = "mvt-payload"u8.ToArray();
using var buffer = new MemoryStream();
using (var gzip = new GZipStream(buffer, CompressionLevel.SmallestSize, leaveOpen: true))
{
gzip.Write(raw);
}
var tile = new TileData(buffer.ToArray(), true);
Assert.True(TileData.IsGzip(tile.Bytes));
Assert.Equal(raw, tile.Uncompressed());
}
}
@@ -0,0 +1,29 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
<PackageReference Include="xunit" Version="2.5.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.3" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\TileServer.Domain\TileServer.Domain.csproj" />
<ProjectReference Include="..\..\src\TileServer.Application\TileServer.Application.csproj" />
<ProjectReference Include="..\..\src\TileServer.Infrastructure\TileServer.Infrastructure.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,66 @@
using Microsoft.Extensions.Logging.Abstractions;
using TileServer.Domain.Tiles;
using TileServer.Infrastructure.Tiles;
using TileServer.Infrastructure.Tiles.Mvt;
namespace TileServer.UnitTests;
public sealed class VectorTileOverzoomerTests
{
[Fact]
public void Overzoom_MapsParentPointIntoNwChild()
{
var parent = EncodePoint(100, 100, "poi");
var overzoomer = new VectorTileOverzoomer(NullLogger<VectorTileOverzoomer>.Instance);
var child = overzoomer.Overzoom(parent, new TileCoordinate(0, 0, 0), new TileCoordinate(1, 0, 0));
var decoded = MvtCodec.Decode(child);
var layer = Assert.Single(decoded.Layers);
Assert.Equal("poi", layer.Name);
var feature = Assert.Single(layer.Features);
var path = Assert.Single(MvtCodec.DecodeGeometry(feature.Geometry));
var point = Assert.Single(path);
Assert.Equal(200, point.X);
Assert.Equal(200, point.Y);
}
[Fact]
public void Overzoom_DropsPointOutsideChild()
{
var parent = EncodePoint(100, 100, "poi");
var overzoomer = new VectorTileOverzoomer(NullLogger<VectorTileOverzoomer>.Instance);
var child = overzoomer.Overzoom(parent, new TileCoordinate(0, 0, 0), new TileCoordinate(1, 1, 1));
var decoded = MvtCodec.Decode(child);
Assert.Empty(decoded.Layers);
}
private static TileData EncodePoint(int x, int y, string layerName)
{
var tile = new MvtTile
{
Layers =
[
new MvtLayer
{
Name = layerName,
Extent = 4096,
Version = 2,
Keys = ["class"],
Values = [new MvtValue { StringValue = "test" }],
Features =
[
new MvtFeature
{
Id = 1,
Type = MvtCodec.Point,
Tags = [0, 0],
Geometry = MvtCodec.EncodeGeometry([[(x, y)]], close: false)
}
]
}
]
};
return MvtCodec.EncodeGzip(tile);
}
}