diff --git a/GeoProfiles/Features/Profiles/Get/FullProfileResponse.cs b/GeoProfiles/Features/Profiles/Get/FullProfileResponse.cs new file mode 100644 index 0000000..6bac5bd --- /dev/null +++ b/GeoProfiles/Features/Profiles/Get/FullProfileResponse.cs @@ -0,0 +1,40 @@ +using System.Text.Json.Serialization; +using GeoProfiles.Infrastructure.Services; +using Swashbuckle.AspNetCore.Filters; + +namespace GeoProfiles.Features.Profiles.Get; + +public record FullProfileResponse +{ + [JsonPropertyName("profileId")] public Guid ProfileId { 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; } + + [JsonPropertyName("points")] public IList Points { get; init; } = null!; +} + +public class ProfileResponseExample : IExamplesProvider +{ + public FullProfileResponse GetExamples() => new() + { + ProfileId = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"), + Start = [30.100, 59.900], + End = [30.500, 60.200], + LengthM = 500.0m, + CreatedAt = DateTime.Parse("2025-05-20T10:00:00Z"), + Points = new List + { + new(0.0, 100.0), + new(125.0, 110.0), + new(250.0, 120.0), + new(375.0, 115.0), + new(500.0, 105.0) + } + }; +} \ No newline at end of file diff --git a/GeoProfiles/Features/Profiles/Get/Get.cs b/GeoProfiles/Features/Profiles/Get/Get.cs new file mode 100644 index 0000000..f4b998f --- /dev/null +++ b/GeoProfiles/Features/Profiles/Get/Get.cs @@ -0,0 +1,95 @@ +using System.Security.Claims; +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 Swashbuckle.AspNetCore.Filters; + +namespace GeoProfiles.Features.Profiles.Get; + +public class Get(GeoProfilesContext db, ILogger logger) : ControllerBase +{ + [HttpGet("api/v1/{projectId:guid}/profile/{profileId:guid}")] + [Authorize] + [Produces("application/json")] + [ProducesResponseType(typeof(FullProfileResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status404NotFound)] + [SwaggerResponseExample(StatusCodes.Status200OK, typeof(ProfileResponseExample))] + [SwaggerResponseExample(StatusCodes.Status400BadRequest, typeof(BadRequestExample))] + public async Task Action( + [FromRoute] ProfileGetRequest request, + CancellationToken cancellationToken = default) + { + new Validator().ValidateAndThrow(request); + + 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(p => p.Id == request.ProjectId && p.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")); + } + + var profile = await db.TerrainProfiles + .AsNoTracking() + .Where(tp => tp.Id == request.ProfileId && tp.ProjectId == request.ProjectId) + .SingleOrDefaultAsync(cancellationToken); + + if (profile is null) + { + logger.LogInformation("Profile not found or does not belong to project"); + return NotFound(new Errors.ResourceNotFound("Profile not found")); + } + + var points = await db.TerrainProfilePoints + .AsNoTracking() + .Where(p => p.ProfileId == request.ProfileId) + .OrderBy(p => p.Seq) + .Select(p => new ProfilePoint( + (double) p.DistM, + (double) p.ElevM)) + .ToListAsync(cancellationToken); + + var response = new FullProfileResponse + { + ProfileId = profile.Id, + Start = [profile.StartPt.X, profile.StartPt.Y], + End = [profile.EndPt.X, profile.EndPt.Y], + LengthM = profile.LengthM, + CreatedAt = profile.CreatedAt, + Points = points + }; + + return Ok(response); + } + + private class Validator : AbstractValidator + { + public Validator() + { + RuleFor(x => x.ProjectId) + .NotEmpty() + .WithMessage("'projectId' must be provided and be a valid GUID"); + + RuleFor(x => x.ProfileId) + .NotEmpty() + .WithMessage("'profileId' must be provided and be a valid GUID"); + } + } +} \ No newline at end of file diff --git a/GeoProfiles/Features/Profiles/Get/Get.test.js b/GeoProfiles/Features/Profiles/Get/Get.test.js new file mode 100644 index 0000000..59477fb --- /dev/null +++ b/GeoProfiles/Features/Profiles/Get/Get.test.js @@ -0,0 +1,149 @@ +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, + getProfilePointsFromDb +} = testData.profile; + +async function makeProfile(projectId, body) { + return await httpClient.post(`api/v1/${projectId}/profile`, body); +} + +async function makeGet(projectId, profileId) { + return await httpClient.get(`api/v1/${projectId}/profile/${profileId}`); +} + +describe('GET /api/v1/:projectId/profile/:profileId', () => { + let user, token, project, projectRec; + let start, end; + let profileRes, profileId; + + 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()}, + {level: 2, 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]; + + // Create a profile + httpClient.defaults.headers['Authorization'] = `Bearer ${token}`; + profileRes = await makeProfile(project.id, {start, end}); + profileId = profileRes.data.profileId; + }); + + beforeEach(() => { + delete httpClient.defaults.headers['Authorization']; + }); + + it('200 returns full profile info with points', async () => { + // Arrange + httpClient.defaults.headers['Authorization'] = `Bearer ${token}`; + + // Act + const res = await makeGet(project.id, profileId); + + // Assert HTTP + expect(res.status).toBe(200); + expect(res.data.profileId).toBe(profileId); + expect(Array.isArray(res.data.start)).toBe(true); + expect(Array.isArray(res.data.end)).toBe(true); + expect(typeof res.data.length_m).toBe('number'); + expect(typeof res.data.created_at).toBe('string'); + expect(Array.isArray(res.data.points)).toBe(true); + expect(res.data.points.length).toBeGreaterThan(0); + + // Assert DB consistency + const profDb = await getProfileFromDb(profileId); + expect(profDb).not.toBeNull(); + expect(res.data.length_m).toBe(profDb.lengthM); + + const ptsDb = await getProfilePointsFromDb(profileId); + expect(ptsDb.length).toBe(res.data.points.length); + + // Each point matches + ptsDb.forEach((dbPt, idx) => { + const apiPt = res.data.points[idx]; + expect(apiPt.distance).toBeCloseTo(dbPt.distM, 6); + expect(apiPt.elevation).toBeCloseTo(dbPt.elevM, 6); + }); + }); + + it('401 if no token', async () => { + // Act + const res = await makeGet(project.id, profileId); + + // Assert + expect(res.status).toBe(401); + }); + + it('404 for invalid GUIDs', async () => { + // Arrange + httpClient.defaults.headers['Authorization'] = `Bearer ${token}`; + + // invalid projectId + let res = await httpClient.get('api/v1/not-a-guid/profile/not-a-guid'); + expect(res.status).toBe(404); + }); + + it('404 if project not found or not owned', async () => { + // Arrange + httpClient.defaults.headers['Authorization'] = `Bearer ${token}`; + const fakeProject = testData.random.uuid(); + + // Act + const res = await makeGet(fakeProject, profileId); + + // Assert + expect(res.status).toBe(404); + }); + + it('404 if profile not found or not in project', async () => { + // Arrange + httpClient.defaults.headers['Authorization'] = `Bearer ${token}`; + const fakeProfile = testData.random.uuid(); + + // Act + const res = await makeGet(project.id, fakeProfile); + + // Assert + expect(res.status).toBe(404); + }); +}); diff --git a/GeoProfiles/Features/Profiles/Get/ProfileGetRequest.cs b/GeoProfiles/Features/Profiles/Get/ProfileGetRequest.cs new file mode 100644 index 0000000..0c1b6a8 --- /dev/null +++ b/GeoProfiles/Features/Profiles/Get/ProfileGetRequest.cs @@ -0,0 +1,12 @@ +using Microsoft.AspNetCore.Mvc; + +namespace GeoProfiles.Features.Profiles.Get; + +public class ProfileGetRequest +{ + [FromRoute(Name = "projectId")] + public Guid ProjectId { get; set; } + + [FromRoute(Name = "profileId")] + public Guid ProfileId { get; set; } +} \ No newline at end of file diff --git a/GeoProfiles/Infrastructure/Errors.cs b/GeoProfiles/Infrastructure/Errors.cs index f62bd73..257140e 100644 --- a/GeoProfiles/Infrastructure/Errors.cs +++ b/GeoProfiles/Infrastructure/Errors.cs @@ -9,4 +9,6 @@ public record UserUnauthorized(string Message); public record UserNotFound(string Message); public record ProjectNotFound(string Message); + + public record ResourceNotFound(string Message); } \ No newline at end of file