From f62c5f5d50b0df44ccfc465a82ca04c78e07e0d2 Mon Sep 17 00:00:00 2001 From: Encapsulateed Date: Mon, 26 May 2025 20:45:26 +0300 Subject: [PATCH 1/2] Add endpoint for profiles list --- GeoProfiles/Features/Profiles/List/List.cs | 73 +++++++++ .../Features/Profiles/List/List.test.js | 146 ++++++++++++++++++ .../Features/Profiles/List/ProfileListItem.cs | 46 ++++++ .../Services/ITerrainProfileService.cs | 2 +- 4 files changed, 266 insertions(+), 1 deletion(-) create mode 100644 GeoProfiles/Features/Profiles/List/List.cs create mode 100644 GeoProfiles/Features/Profiles/List/List.test.js create mode 100644 GeoProfiles/Features/Profiles/List/ProfileListItem.cs diff --git a/GeoProfiles/Features/Profiles/List/List.cs b/GeoProfiles/Features/Profiles/List/List.cs new file mode 100644 index 0000000..422ada0 --- /dev/null +++ b/GeoProfiles/Features/Profiles/List/List.cs @@ -0,0 +1,73 @@ +using System.Net.Mime; +using System.Security.Claims; +using FluentValidation; +using GeoProfiles.Application.Auth; +using GeoProfiles.Infrastructure; +using GeoProfiles.Infrastructure.Examples; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Swashbuckle.AspNetCore.Filters; + +namespace GeoProfiles.Features.Profiles.List; + +public class List(GeoProfilesContext db, ILogger logger) : ControllerBase +{ + [HttpGet("api/v1/{projectId:guid}/list")] + [Authorize] + [Produces(MediaTypeNames.Application.Json)] + [ProducesResponseType(typeof(ProfileList), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status400BadRequest)] + [SwaggerResponseExample(StatusCodes.Status200OK, typeof(ProfileListExample))] + [SwaggerResponseExample(StatusCodes.Status400BadRequest, typeof(BadRequestExample))] + public async Task Action( + [FromRoute] Guid projectId, + CancellationToken cancellationToken = default) + { + new Validator().ValidateAndThrow(projectId); + + var sub = User.FindFirstValue(Claims.UserId); + + if (!Guid.TryParse(sub, out var userId)) + { + logger.LogInformation("User is not authorized"); + return Unauthorized(new Errors.UserUnauthorized("User is not authorized")); + } + + var project = await db.Projects + .AsNoTracking() + .Where(x => x.Id == projectId && x.UserId == userId) + .SingleOrDefaultAsync(cancellationToken); + + if (project is null) + { + logger.LogInformation("Project not found or not owned by user"); + return NotFound(new Errors.ProjectNotFound("Project not found or not owned by user")); + } + + var items = await db.TerrainProfiles + .AsNoTracking() + .Where(tp => tp.ProjectId == projectId) + .OrderBy(tp => tp.CreatedAt) + .Select(tp => new ProfileListItem + { + Id = tp.Id, + Start = new double[] {tp.StartPt.X, tp.StartPt.Y}, + End = new double[] {tp.EndPt.X, tp.EndPt.Y}, + LengthM = tp.LengthM, + CreatedAt = tp.CreatedAt + }) + .ToListAsync(cancellationToken); + + return Ok(new ProfileList {Items = items}); + } + + + private class Validator : AbstractValidator + { + public Validator() + { + RuleFor(x => x).NotEmpty(); + } + } +} \ No newline at end of file diff --git a/GeoProfiles/Features/Profiles/List/List.test.js b/GeoProfiles/Features/Profiles/List/List.test.js new file mode 100644 index 0000000..b8b9511 --- /dev/null +++ b/GeoProfiles/Features/Profiles/List/List.test.js @@ -0,0 +1,146 @@ +// __tests__/features/profiles/list/listProfile.test.js + +const { httpClient } = require('../../../Testing/fixtures'); +const customExpect = require('../../../Testing/customExpect'); +const { generateAccessToken } = require('../../../Testing/auth'); + +const testData = { + ...require('../../../Testing/testData'), + ...require('../../../Testing/UserTestData'), + ...require('../../Projects/ProjectTestData'), + ...require('../../Projects/isolineTestData'), + ...require('./../ProfileTestData'), +}; + +const { + randomBboxWkt, + prepareProjectInDb, + getProjectFromDb +} = testData.projects; + +const { + getProfileFromDb +} = testData.profile; + +async function makeProfile(projectId, body) { + return await httpClient.post(`api/v1/${projectId}/profile`, body); +} + +async function makeList(projectId) { + return await httpClient.get(`api/v1/${projectId}/list`); +} + +describe('GET /api/v1/:projectId/list', () => { + let user, token, project, projectRec; + let start, end; + let body1, body2; + + beforeAll(async () => { + // Arrange: create user + token + 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 }); + + // Arrange: project with isolines + const isolines = [ + { level: 0, geomWkt: randomBboxWkt() }, + { level: 1, geomWkt: randomBboxWkt() } + ]; + project = await prepareProjectInDb({ userId: user.id }, isolines); + + // Read bbox to pick start/end inside it + projectRec = await getProjectFromDb(project.id); + const coords = projectRec.bboxWkt + .match(/\(\((.+)\)\)/)[1] + .split(',') + .map(pt => pt.trim().split(' ').map(Number)); + const [[lonMin, latMin], , [lonMax, latMax]] = coords; + const dx = (lonMax - lonMin) * 0.1; + const dy = (latMax - latMin) * 0.1; + start = [lonMin + dx, latMin + dy]; + end = [lonMax - dx, latMax - dy]; + + // Prepare two different profile requests + body1 = { start, end }; + body2 = { + start: [start[0] - dx, start[1]], + end: [end[0] - dx, end[1]] + }; + + httpClient.defaults.headers['Authorization'] = `Bearer ${token}`; + await makeProfile(project.id, body1); + + await new Promise(r => setTimeout(r, 50)); + await makeProfile(project.id, body2); + }); + + beforeEach(() => { + delete httpClient.defaults.headers['Authorization']; + }); + + it('200 returns all created profiles in order', async () => { + // Arrange + httpClient.defaults.headers['Authorization'] = `Bearer ${token}`; + + // Act + const res = await makeList(project.id); + + // Assert HTTP + expect(res.status).toBe(200); + expect(Array.isArray(res.data.items)).toBe(true); + expect(res.data.items).toHaveLength(2); + + // Check first item matches body1 + const item1 = res.data.items[0]; + expect(item1.start).toEqual(body1.start); + expect(item1.end).toEqual(body1.end); + expect(typeof item1.length_m).toBe('number'); + expect(typeof item1.created_at).toBe('string'); + + // DB consistency + const db1 = await getProfileFromDb(item1.id); + expect(db1).not.toBeNull(); + expect(item1.length_m).toBe(db1.lengthM); + + const item2 = res.data.items[1]; + expect(item2.start).toEqual(body2.start); + expect(item2.end).toEqual(body2.end); + + const db2 = await getProfileFromDb(item2.id); + expect(item2.length_m).toBe(db2.lengthM); + }); + + it('401 if not authenticated', async () => { + // Act + const res = await makeList(project.id); + + // Assert + expect(res.status).toBe(401); + }); + + it('404 for invalid projectId format', async () => { + // Arrange + httpClient.defaults.headers['Authorization'] = `Bearer ${token}`; + + // Act + const res = await httpClient.get('api/v1/not-a-guid/list'); + + // Assert + expect(res.status).toBe(404); + }); + + it('404 if project does not exist or not owned', async () => { + // Arrange + httpClient.defaults.headers['Authorization'] = `Bearer ${token}`; + const fakeId = testData.random.uuid(); + + // Act + const res = await makeList(fakeId); + + // Assert + expect(res.status).toBe(404); + }); +}); diff --git a/GeoProfiles/Features/Profiles/List/ProfileListItem.cs b/GeoProfiles/Features/Profiles/List/ProfileListItem.cs new file mode 100644 index 0000000..de26c64 --- /dev/null +++ b/GeoProfiles/Features/Profiles/List/ProfileListItem.cs @@ -0,0 +1,46 @@ +using System.Text.Json.Serialization; +using Swashbuckle.AspNetCore.Filters; + +namespace GeoProfiles.Features.Profiles.List; + +public record ProfileListItem +{ + [JsonPropertyName("id")] public Guid Id { get; init; } + + [JsonPropertyName("start")] public double[] Start { get; init; } = null!; + + [JsonPropertyName("end")] public double[] End { get; init; } = null!; + + [JsonPropertyName("length_m")] public decimal LengthM { get; init; } + + [JsonPropertyName("created_at")] public DateTime CreatedAt { get; init; } +} + +public record ProfileList +{ + public IList Items { get; init; } = null!; +} + +public class ProfileListExample : IExamplesProvider +{ + public ProfileList GetExamples() => new ProfileList + { + Items = new List + { + new ProfileListItem { + Id = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"), + Start = [30.123, 59.987], + End = [30.456, 60.012], + LengthM = 1234.56m, + CreatedAt = DateTime.Parse("2025-05-01T12:34:56Z") + }, + new ProfileListItem { + Id = Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"), + Start = [30.200, 59.990], + End = [30.500, 60.000], + LengthM = 1500.00m, + CreatedAt = DateTime.Parse("2025-05-02T08:15:00Z") + } + } + }; +} \ No newline at end of file diff --git a/GeoProfiles/Infrastructure/Services/ITerrainProfileService.cs b/GeoProfiles/Infrastructure/Services/ITerrainProfileService.cs index aaa7979..f540be4 100644 --- a/GeoProfiles/Infrastructure/Services/ITerrainProfileService.cs +++ b/GeoProfiles/Infrastructure/Services/ITerrainProfileService.cs @@ -43,7 +43,7 @@ public async Task BuildProfileAsync( // TODO доделать догенерацию изолиний // var expanded = project.Bbox.Buffer(samplingMeters * 5); // await _isolSvc.GenerateMore(projectId, expanded); - throw new NotImplementedException(); + //throw new NotImplementedException(); } var totalDist = start.Distance(end); From f8c632e84d0337f324d2f96f2287c660e46c3350 Mon Sep 17 00:00:00 2001 From: Encapsulateed Date: Mon, 26 May 2025 20:45:41 +0300 Subject: [PATCH 2/2] update test --- GeoProfiles/Features/Profiles/List/List.test.js | 1 - 1 file changed, 1 deletion(-) diff --git a/GeoProfiles/Features/Profiles/List/List.test.js b/GeoProfiles/Features/Profiles/List/List.test.js index b8b9511..02def36 100644 --- a/GeoProfiles/Features/Profiles/List/List.test.js +++ b/GeoProfiles/Features/Profiles/List/List.test.js @@ -1,4 +1,3 @@ -// __tests__/features/profiles/list/listProfile.test.js const { httpClient } = require('../../../Testing/fixtures'); const customExpect = require('../../../Testing/customExpect');