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
154 changes: 154 additions & 0 deletions GeoProfiles/Features/Profiles/Create/Create.cs
Original file line number Diff line number Diff line change
@@ -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<Create> 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<IActionResult> 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<ProfileRequest>
{
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
);
}
}
}
178 changes: 178 additions & 0 deletions GeoProfiles/Features/Profiles/Create/Create.test.js
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
14 changes: 14 additions & 0 deletions GeoProfiles/Features/Profiles/Create/ProfileRequest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
using Swashbuckle.AspNetCore.Filters;

namespace GeoProfiles.Features.Profiles.Create;

public record ProfileRequest(double[] Start, double[] End);

public class ProfileRequestExample : IExamplesProvider<ProfileRequest>
{
public ProfileRequest GetExamples() =>
new(
Start: [30.123, 59.987],
End: [30.456, 60.012]
);
}
36 changes: 36 additions & 0 deletions GeoProfiles/Features/Profiles/Create/ProfileResponse.cs
Original file line number Diff line number Diff line change
@@ -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<ProfilePoint> Points { get; set; } = null!;
}

public class ProfileResponseExample : IExamplesProvider<ProfileResponse>
{
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)
]
};
}
Loading
Loading