Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions GeoProfiles/Features/Profiles/Get/FullProfileResponse.cs
Original file line number Diff line number Diff line change
@@ -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<ProfilePoint> Points { get; init; } = null!;
}

public class ProfileResponseExample : IExamplesProvider<FullProfileResponse>
{
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<ProfilePoint>
{
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)
}
};
}
95 changes: 95 additions & 0 deletions GeoProfiles/Features/Profiles/Get/Get.cs
Original file line number Diff line number Diff line change
@@ -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<Get> 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<IActionResult> 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<ProfileGetRequest>
{
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");
}
}
}
149 changes: 149 additions & 0 deletions GeoProfiles/Features/Profiles/Get/Get.test.js
Original file line number Diff line number Diff line change
@@ -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);
});
});
12 changes: 12 additions & 0 deletions GeoProfiles/Features/Profiles/Get/ProfileGetRequest.cs
Original file line number Diff line number Diff line change
@@ -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; }
}
2 changes: 2 additions & 0 deletions GeoProfiles/Infrastructure/Errors.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,6 @@ public record UserUnauthorized(string Message);
public record UserNotFound(string Message);

public record ProjectNotFound(string Message);

public record ResourceNotFound(string Message);
}
Loading