feat(proj): init
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user