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
73 changes: 73 additions & 0 deletions GeoProfiles/Features/Profiles/List/List.cs
Original file line number Diff line number Diff line change
@@ -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<List> 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<IActionResult> 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<Guid>
{
public Validator()
{
RuleFor(x => x).NotEmpty();
}
}
}
145 changes: 145 additions & 0 deletions GeoProfiles/Features/Profiles/List/List.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@

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);
});
});
46 changes: 46 additions & 0 deletions GeoProfiles/Features/Profiles/List/ProfileListItem.cs
Original file line number Diff line number Diff line change
@@ -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<ProfileListItem> Items { get; init; } = null!;
}

public class ProfileListExample : IExamplesProvider<ProfileList>
{
public ProfileList GetExamples() => new ProfileList
{
Items = new List<ProfileListItem>
{
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")
}
}
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
public class TerrainProfileService(
GeoProfilesContext db,
IElevationProvider elevProv,
IIsolineGeneratorService isolSvc)

Check warning on line 28 in GeoProfiles/Infrastructure/Services/ITerrainProfileService.cs

View workflow job for this annotation

GitHub Actions / build

Parameter 'isolSvc' is unread.

Check warning on line 28 in GeoProfiles/Infrastructure/Services/ITerrainProfileService.cs

View workflow job for this annotation

GitHub Actions / build

Parameter 'isolSvc' is unread.

Check warning on line 28 in GeoProfiles/Infrastructure/Services/ITerrainProfileService.cs

View workflow job for this annotation

GitHub Actions / test-app-pr

Parameter 'isolSvc' is unread.

Check warning on line 28 in GeoProfiles/Infrastructure/Services/ITerrainProfileService.cs

View workflow job for this annotation

GitHub Actions / test-app-pr

Parameter 'isolSvc' is unread.
: ITerrainProfileService
{
public async Task<TerrainProfileData> BuildProfileAsync(
Expand All @@ -43,7 +43,7 @@
// TODO доделать догенерацию изолиний
// var expanded = project.Bbox.Buffer(samplingMeters * 5);
// await _isolSvc.GenerateMore(projectId, expanded);
throw new NotImplementedException();
//throw new NotImplementedException();
}

var totalDist = start.Distance(end);
Expand Down
Loading