diff --git a/GeoProfiles/Features/Profiles/Create/Create.cs b/GeoProfiles/Features/Profiles/Create/Create.cs new file mode 100644 index 0000000..1192905 --- /dev/null +++ b/GeoProfiles/Features/Profiles/Create/Create.cs @@ -0,0 +1,154 @@ +using System.Net.Mime; +using FluentValidation; +using GeoProfiles.Application.Auth; +using GeoProfiles.Infrastructure; +using GeoProfiles.Infrastructure.Examples; +using GeoProfiles.Infrastructure.Services; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using NetTopologySuite.Geometries; +using Swashbuckle.AspNetCore.Filters; + +namespace GeoProfiles.Features.Profiles.Create; + +public class Create( + GeoProfilesContext db, + ITerrainProfileService terrainProfileService, + ILogger logger) : ControllerBase +{ + [HttpPost("api/v1/{projectId:guid}/profile")] + [Authorize] + [Produces(MediaTypeNames.Application.Json)] + [Consumes(MediaTypeNames.Application.Json)] + [ProducesResponseType(typeof(ProfileResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status400BadRequest)] + [SwaggerRequestExample(typeof(ProfileRequest), typeof(ProfileRequestExample))] + [SwaggerResponseExample(StatusCodes.Status200OK, typeof(ProfileResponseExample))] + [SwaggerResponseExample(StatusCodes.Status400BadRequest, typeof(BadRequestExample))] + public async Task Action( + [FromRoute] Guid projectId, + [FromBody] ProfileRequest request, + CancellationToken cancellationToken = default) + { + logger.LogInformation("Creating profile for project {ProjectId}", projectId); + new Validator().ValidateAndThrow(request); + + var claims = User.Claims.ToList(); + + var sub = claims + .FirstOrDefault(c => c.Type == Claims.UserId)? + .Value; + + if (string.IsNullOrEmpty(sub) || !Guid.TryParse(sub, out var userId)) + { + logger.LogInformation("User is not authorized"); + + return Unauthorized(new Errors.UserUnauthorized("Unauthorized")); + } + + var user = await db.Users + .AsNoTracking() + .SingleOrDefaultAsync(u => u.Id == userId, cancellationToken: cancellationToken); + + if (user is null) + { + logger.LogInformation("User not found"); + + return NotFound(new Errors.UserNotFound("User not found")); + } + + var project = await db.Projects + .Where(p => p.UserId == userId && p.Id == projectId) + .SingleOrDefaultAsync(cancellationToken); + + if (project is null) + { + logger.LogInformation("Project not found"); + + return NotFound(new Errors.ProjectNotFound("Project not found")); + } + + var start = new Point(request.Start[0], request.Start[1]) {SRID = 4326}; + var end = new Point(request.End[0], request.End[1]) {SRID = 4326}; + + var profile = await terrainProfileService.BuildProfileAsync(start, end, projectId, ct: cancellationToken); + + var response = new ProfileResponse + { + ProfileId = profile.Id, + LengthM = await db.TerrainProfiles + .Where(tp => tp.Id == profile.Id) + .Select(tp => tp.LengthM) + .SingleAsync(cancellationToken) + }; + + var pts = await db.TerrainProfilePoints + .AsNoTracking() + .Where(tpp => tpp.ProfileId == profile.Id) + .OrderBy(tpp => tpp.Seq) + .Select(tpp => new ProfilePoint( + (double) tpp.DistM, + (double) tpp.ElevM)) + .ToListAsync(cancellationToken); + + response.Points = pts; + + return StatusCode(StatusCodes.Status201Created, response); + } + + private class Validator : AbstractValidator + { + public Validator() + { + RuleFor(x => x.Start) + .NotNull() + .WithMessage("'start' must be provided"); + + RuleFor(x => x.Start) + .Must(a => a.Length == 2) + .WithMessage("'start' must contain exactly two elements [lon, lat]") + .When(x => x.Start != null); + + RuleFor(x => x.Start) + .Must(a => a[0] >= -180 && a[0] <= 180) + .WithMessage("Longitude in 'start' must be between -180 and 180") + .When(x => x.Start != null && x.Start.Length == 2); + + RuleFor(x => x.Start) + .Must(a => a[1] >= -90 && a[1] <= 90) + .WithMessage("Latitude in 'start' must be between -90 and 90") + .When(x => x.Start != null && x.Start.Length == 2); + + RuleFor(x => x.End) + .NotNull() + .WithMessage("'end' must be provided"); + + RuleFor(x => x.End) + .Must(a => a.Length == 2) + .WithMessage("'end' must contain exactly two elements [lon, lat]") + .When(x => x.End != null); + + RuleFor(x => x.End) + .Must(a => a[0] >= -180 && a[0] <= 180) + .WithMessage("Longitude in 'end' must be between -180 and 180") + .When(x => x.End != null && x.End.Length == 2); + + RuleFor(x => x.End) + .Must(a => a[1] >= -90 && a[1] <= 90) + .WithMessage("Latitude in 'end' must be between -90 and 90") + .When(x => x.End != null && x.End.Length == 2); + + RuleFor(x => x) + .Must(x => + x.Start != null && x.End != null && + (x.Start[0] != x.End[0] || x.Start[1] != x.End[1]) + ) + .WithMessage("'start' and 'end' must be different points") + .When(x => + x.Start != null && x.End != null && + x.Start.Length == 2 && x.End.Length == 2 + ); + } + } +} \ No newline at end of file diff --git a/GeoProfiles/Features/Profiles/Create/Create.test.js b/GeoProfiles/Features/Profiles/Create/Create.test.js new file mode 100644 index 0000000..7117e8c --- /dev/null +++ b/GeoProfiles/Features/Profiles/Create/Create.test.js @@ -0,0 +1,178 @@ +const { httpClient } = require('../../../Testing/fixtures'); +const customExpect = require('../../../Testing/customExpect'); +const { generateAccessToken }= require('../../../Testing/auth'); + +const testData = { + ...require('../../../Testing/testData'), + ...require('../../../Testing/UserTestData'), + ...require('../../../Testing/testData'), + ...require('../../../Testing/testData'), + ...require('../../Projects/ProjectTestData'), + ...require('../../Projects/isolineTestData'), + ...require('./../ProfileTestData'), +}; + +const { + randomBboxWkt, + prepareProjectInDb, + getProjectFromDb +} = testData.projects; + +const { + getProfileFromDb, + getProfilePointsFromDb +} = testData.profile; + +async function makeProfileRequest(projectId, body) { + return await httpClient.post(`api/v1/${projectId}/profile`, body); +} + +describe('POST /api/v1/:projectId/profile', () => { + let user, token, project, projectRec; + let start, end; + + beforeAll(async () => { + // Arrange + user = await testData.users.prepareUserInDb({ + username: testData.random.alphaNumeric(8), + email: `${testData.random.alphaNumeric(5)}@example.com`, + passwordHash: testData.random.alphaNumeric(60), + }); + token = await generateAccessToken({ userId: user.id }); + + const isolines = [ + { level: 0, geomWkt: randomBboxWkt() }, + { level: 1, geomWkt: randomBboxWkt() } + ]; + project = await prepareProjectInDb({ userId: user.id }, isolines); + + // Read back bboxWkt, чтобы вычислить внутренние точки + projectRec = await getProjectFromDb(project.id); + const coords = projectRec.bboxWkt + .match(/\(\((.+)\)\)/)[1] + .split(',') + .map(pt => pt.trim().split(' ').map(Number)); + // возьмём первый и третий: [lon_min,lat_min], [lon_max,lat_max] + const [ [lonMin, latMin], , [lonMax, latMax] ] = coords; + + const deltaLon = (lonMax - lonMin) * 0.1; + const deltaLat = (latMax - latMin) * 0.1; + + start = [lonMin + deltaLon, latMin + deltaLat]; + end = [lonMax - deltaLon, latMax - deltaLat]; + }); + + beforeEach(() => { + delete httpClient.defaults.headers['Authorization']; + }); + + const validBody = () => ({ start, end }); + + describe('happy path', () => { + it('201 → profile persisted + all points returned', async () => { + // Arrange + httpClient.defaults.headers['Authorization'] = `Bearer ${token}`; + + // Act + const res = await makeProfileRequest(project.id, validBody()); + + // Assert HTTP + expect(res.status).toBe(201); + expect(typeof res.data.profileId).toBe('string'); + expect(typeof res.data.length_m).toBe('number'); + expect(Array.isArray(res.data.points)).toBe(true); + expect(res.data.points.length).toBeGreaterThan(0); + + // Assert DB: профиль + const prof = await getProfileFromDb(res.data.profileId); + expect(prof).not.toBeNull(); + expect(prof.lengthM).toBe(res.data.length_m); + + // Assert DB: точки + const ptsDb = await getProfilePointsFromDb(res.data.profileId); + expect(ptsDb.length).toBe(res.data.points.length); + + // каждая точка совпадает и в правильном порядке + ptsDb.forEach((dbPt, idx) => { + const apiPt = res.data.points[idx]; + expect(apiPt.distance).toBeCloseTo(dbPt.distM, 6); + expect(apiPt.elevation).toBeCloseTo(dbPt.elevM, 6); + }); + }); + }); + + describe('validation errors', () => { + beforeEach(() => { + httpClient.defaults.headers['Authorization'] = `Bearer ${token}`; + }); + + it('400 when start/end missing', async () => { + // Act + const res = await makeProfileRequest(project.id, {}); + + // Assert + customExpect.toBeValidationError(res); + }); + + it('400 when start == end', async () => { + // Act + const res = await makeProfileRequest(project.id, { start: [0,0], end: [0,0] }); + + // Assert + customExpect.toBeValidationError(res); + }); + }); + + describe('unauthorized', () => { + it('401 if no token', async () => { + // Act + const res = await makeProfileRequest(project.id, validBody()); + + // Assert + expect(res.status).toBe(401); + }); + + it('401 for invalid token', async () => { + // Arrange + httpClient.defaults.headers['Authorization'] = 'Bearer invalid'; + + // Act + const res = await makeProfileRequest(project.id, validBody()); + + // Assert + expect(res.status).toBe(401); + }); + }); + + describe('not found', () => { + beforeEach(() => { + httpClient.defaults.headers['Authorization'] = `Bearer ${token}`; + }); + + it('404 if project missing', async () => { + // Act + const fakeId = testData.random.uuid(); + const res = await makeProfileRequest(fakeId, validBody()); + + // Assert + expect(res.status).toBe(404); + }); + + it('404 if project belongs to another user', async () => { + // Arrange + const otherUser = await testData.users.prepareUserInDb({ + username: testData.random.alphaNumeric(8), + email: `${testData.random.alphaNumeric(5)}@example.com`, + passwordHash: testData.random.alphaNumeric(60), + }); + const iso = [{ level: 0, geomWkt: randomBboxWkt() }]; + const otherProj = await prepareProjectInDb({ userId: otherUser.id }, iso); + + // Act + const res = await makeProfileRequest(otherProj.id, validBody()); + + // Assert + expect(res.status).toBe(404); + }); + }); +}); \ No newline at end of file diff --git a/GeoProfiles/Features/Profiles/Create/ProfileRequest.cs b/GeoProfiles/Features/Profiles/Create/ProfileRequest.cs new file mode 100644 index 0000000..f3c9fc5 --- /dev/null +++ b/GeoProfiles/Features/Profiles/Create/ProfileRequest.cs @@ -0,0 +1,14 @@ +using Swashbuckle.AspNetCore.Filters; + +namespace GeoProfiles.Features.Profiles.Create; + +public record ProfileRequest(double[] Start, double[] End); + +public class ProfileRequestExample : IExamplesProvider +{ + public ProfileRequest GetExamples() => + new( + Start: [30.123, 59.987], + End: [30.456, 60.012] + ); +} \ No newline at end of file diff --git a/GeoProfiles/Features/Profiles/Create/ProfileResponse.cs b/GeoProfiles/Features/Profiles/Create/ProfileResponse.cs new file mode 100644 index 0000000..645c741 --- /dev/null +++ b/GeoProfiles/Features/Profiles/Create/ProfileResponse.cs @@ -0,0 +1,36 @@ +using System.Text.Json.Serialization; +using GeoProfiles.Infrastructure.Services; +using Swashbuckle.AspNetCore.Filters; + +namespace GeoProfiles.Features.Profiles.Create; + +public record ProfileResponse +{ + [JsonPropertyName("profileId")] public Guid ProfileId { get; init; } + + [JsonPropertyName("length_m")] public decimal LengthM { get; init; } + + public List Points { get; set; } = null!; +} + +public class ProfileResponseExample : IExamplesProvider +{ + public ProfileResponse GetExamples() => + new() + { + ProfileId = Guid.Parse("3fa85f64-5717-4562-b3fc-2c963f66afa6"), + LengthM = 1234.56m, + Points = + [ + new ProfilePoint(0.0, 10.5), + new ProfilePoint(100.0, 15.2), + new ProfilePoint(200.0, 20.1), + new ProfilePoint(300.0, 18.7), + new ProfilePoint(400.0, 22.4), + new ProfilePoint(500.0, 25.0), + new ProfilePoint(600.0, 23.8), + new ProfilePoint(700.0, 27.5), + new ProfilePoint(800.0, 30.0) + ] + }; +} \ No newline at end of file diff --git a/GeoProfiles/Features/Profiles/ProfileTestData.js b/GeoProfiles/Features/Profiles/ProfileTestData.js new file mode 100644 index 0000000..3dd9407 --- /dev/null +++ b/GeoProfiles/Features/Profiles/ProfileTestData.js @@ -0,0 +1,36 @@ + +const { db } = require('../../Testing/fixtures'); +const { convertObjectPropertiesToCamelCase } = require('../../Testing/utils'); + +async function getProfileFromDb(id) { + const row = await db + .select( + 'id', + 'project_id', + 'length_m', + db.raw('ST_AsText(start_pt) AS start_wkt'), + db.raw('ST_AsText(end_pt) AS end_wkt') + ) + .from('terrain_profiles') + .where({ id }) + .first(); + + return row ? convertObjectPropertiesToCamelCase(row) : null; +} + +async function getProfilePointsFromDb(profileId) { + const rows = await db + .select('seq', 'dist_m', 'elev_m') + .from('terrain_profile_points') + .where({ profile_id: profileId }) + .orderBy('seq'); + + return rows.map(convertObjectPropertiesToCamelCase); +} + +module.exports = { + profile: { + getProfileFromDb, + getProfilePointsFromDb, + }, +}; diff --git a/GeoProfiles/Features/Projects/Get/Get.cs b/GeoProfiles/Features/Projects/Get/Get.cs index de60830..e58a8d3 100644 --- a/GeoProfiles/Features/Projects/Get/Get.cs +++ b/GeoProfiles/Features/Projects/Get/Get.cs @@ -17,7 +17,7 @@ public class Get( [HttpGet("api/v1/projects/{id:guid}")] [Authorize] [Produces(MediaTypeNames.Application.Json)] - [ProducesResponseType(typeof(ProjectDto), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProjectDto), StatusCodes.Status201Created)] [ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status404NotFound)] [SwaggerResponseExample(StatusCodes.Status200OK, typeof(ProjectDtoExample))] [SwaggerResponseExample(StatusCodes.Status400BadRequest, typeof(ErrorResponse))] diff --git a/GeoProfiles/Features/Projects/ProjectTestData.js b/GeoProfiles/Features/Projects/ProjectTestData.js index e0ad3fd..b3434c7 100644 --- a/GeoProfiles/Features/Projects/ProjectTestData.js +++ b/GeoProfiles/Features/Projects/ProjectTestData.js @@ -125,5 +125,6 @@ module.exports = { getProjectFromDb, getProjectListFromDb, getIsolinesFromDb, + randomBboxWkt }, };