Skip to content

Commit bca366e

Browse files
feat: rating database schema & api (#129)
1 parent 6d0556b commit bca366e

File tree

9 files changed

+681
-0
lines changed

9 files changed

+681
-0
lines changed
Lines changed: 266 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,266 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.Linq;
4+
using System.Linq.Expressions;
5+
using System.Security.Claims;
6+
using System.Threading;
7+
using System.Threading.Tasks;
8+
using AutoMapper;
9+
using FluentAssertions;
10+
using Microsoft.AspNetCore.Http;
11+
using Microsoft.EntityFrameworkCore;
12+
using Microsoft.EntityFrameworkCore.Query;
13+
using Microsoft.Extensions.Caching.Distributed;
14+
using Microsoft.Extensions.Logging;
15+
using Moq;
16+
using SorobanSecurityPortalApi.Authorization;
17+
using SorobanSecurityPortalApi.Common;
18+
using SorobanSecurityPortalApi.Common.Data;
19+
using SorobanSecurityPortalApi.Data.Processors;
20+
using SorobanSecurityPortalApi.Models.DbModels;
21+
using SorobanSecurityPortalApi.Models.Mapping;
22+
using SorobanSecurityPortalApi.Models.ViewModels;
23+
using SorobanSecurityPortalApi.Services.ControllersServices;
24+
using Xunit;
25+
26+
namespace SorobanSecurityPortalApi.Tests.Services
27+
{
28+
public class RatingServiceTests
29+
{
30+
private readonly Mock<Db> _dbMock;
31+
private readonly Mock<DbSet<RatingModel>> _ratingSetMock;
32+
private readonly Mock<IDistributedCache> _cacheMock;
33+
private readonly Mock<IHttpContextAccessor> _httpContextAccessorMock;
34+
private readonly Mock<ILoginProcessor> _loginProcessorMock;
35+
36+
private readonly Mock<IDbQuery> _dbQueryMock;
37+
private readonly Mock<ILogger<Db>> _loggerMock;
38+
private readonly Mock<IDataSourceProvider> _dataSourceProviderMock;
39+
40+
private readonly IMapper _mapper;
41+
private readonly RatingService _service;
42+
private readonly List<RatingModel> _dataStore;
43+
44+
public RatingServiceTests()
45+
{
46+
// Setup Data Store
47+
_dataStore = new List<RatingModel>();
48+
_ratingSetMock = CreateDbSetMock(_dataStore);
49+
50+
// Mock Db Dependencies
51+
_dbQueryMock = new Mock<IDbQuery>();
52+
_loggerMock = new Mock<ILogger<Db>>();
53+
_dataSourceProviderMock = new Mock<IDataSourceProvider>();
54+
55+
_dbMock = new Mock<Db>(
56+
_dbQueryMock.Object,
57+
_loggerMock.Object,
58+
_dataSourceProviderMock.Object
59+
) { CallBase = true };
60+
61+
_dbMock.Object.Rating = _ratingSetMock.Object;
62+
63+
_dbMock.Setup(x => x.SaveChangesAsync(It.IsAny<CancellationToken>())).ReturnsAsync(1);
64+
65+
var mappingConfig = new MapperConfiguration(mc => mc.AddProfile(new RatingModelProfile()));
66+
_mapper = mappingConfig.CreateMapper();
67+
_cacheMock = new Mock<IDistributedCache>();
68+
69+
_httpContextAccessorMock = new Mock<IHttpContextAccessor>();
70+
_loginProcessorMock = new Mock<ILoginProcessor>();
71+
var userContext = new UserContextAccessor(_httpContextAccessorMock.Object, _loginProcessorMock.Object);
72+
73+
// Initialize Service
74+
_service = new RatingService(_dbMock.Object, _cacheMock.Object, userContext, _mapper);
75+
}
76+
77+
// --- TESTS ---
78+
79+
[Fact]
80+
public async Task AddOrUpdateRating_Should_AddToStore_WhenNew()
81+
{
82+
SetupLoggedInUser(10);
83+
var request = new CreateRatingRequest
84+
{
85+
EntityType = EntityType.Protocol,
86+
EntityId = 1,
87+
Score = 5,
88+
Review = "Fresh Review"
89+
};
90+
91+
await _service.AddOrUpdateRating(request);
92+
93+
_dataStore.Should().HaveCount(1);
94+
_dataStore[0].Review.Should().Be("Fresh Review");
95+
}
96+
97+
[Fact]
98+
public async Task AddOrUpdateRating_Should_UpdateStore_WhenExists()
99+
{
100+
SetupLoggedInUser(10);
101+
_dataStore.Add(new RatingModel { UserId = 10, EntityId = 1, EntityType = EntityType.Protocol, Score = 1, Review = "Old" });
102+
103+
var request = new CreateRatingRequest
104+
{
105+
EntityType = EntityType.Protocol,
106+
EntityId = 1,
107+
Score = 5,
108+
Review = "Updated"
109+
};
110+
111+
await _service.AddOrUpdateRating(request);
112+
113+
_dataStore.Should().HaveCount(1);
114+
_dataStore[0].Review.Should().Be("Updated");
115+
}
116+
117+
[Fact]
118+
public async Task GetSummary_Should_Calculate_FromStore()
119+
{
120+
_dataStore.Add(new RatingModel { UserId = 1, EntityId = 100, EntityType = EntityType.Protocol, Score = 5 });
121+
_dataStore.Add(new RatingModel { UserId = 2, EntityId = 100, EntityType = EntityType.Protocol, Score = 5 });
122+
_dataStore.Add(new RatingModel { UserId = 3, EntityId = 100, EntityType = EntityType.Protocol, Score = 1 });
123+
124+
var summary = await _service.GetSummary(EntityType.Protocol, 100);
125+
126+
summary.TotalReviews.Should().Be(3);
127+
summary.AverageScore.Should().Be(3.7f);
128+
}
129+
130+
[Fact]
131+
public async Task DeleteRating_Should_RemoveFromStore_WhenOwner()
132+
{
133+
int userId = 50;
134+
SetupLoggedInUser(userId);
135+
var rating = new RatingModel { Id = 1, UserId = userId, EntityId = 1, EntityType = EntityType.Protocol };
136+
_dataStore.Add(rating);
137+
138+
_ratingSetMock.Setup(x => x.FindAsync(1)).ReturnsAsync(rating);
139+
140+
await _service.DeleteRating(1);
141+
142+
_ratingSetMock.Verify(x => x.Remove(rating), Times.Once);
143+
}
144+
145+
[Fact]
146+
public async Task DeleteRating_Should_Throw_If_Hacker()
147+
{
148+
SetupLoggedInUser(99, isAdmin: false);
149+
var rating = new RatingModel { Id = 1, UserId = 50, EntityId = 1, EntityType = EntityType.Protocol };
150+
_dataStore.Add(rating);
151+
_ratingSetMock.Setup(x => x.FindAsync(1)).ReturnsAsync(rating);
152+
153+
await Assert.ThrowsAsync<UnauthorizedAccessException>(() => _service.DeleteRating(1));
154+
}
155+
156+
[Fact]
157+
public async Task DeleteRating_Should_Allow_Admin()
158+
{
159+
SetupLoggedInUser(999, isAdmin: true);
160+
var rating = new RatingModel { Id = 1, UserId = 50, EntityId = 1, EntityType = EntityType.Protocol };
161+
_dataStore.Add(rating);
162+
_ratingSetMock.Setup(x => x.FindAsync(1)).ReturnsAsync(rating);
163+
164+
await _service.DeleteRating(1);
165+
_ratingSetMock.Verify(x => x.Remove(rating), Times.Once);
166+
}
167+
168+
[Fact]
169+
public async Task GetSummary_Should_ReturnZeroes_WhenNoRatingsExist()
170+
{
171+
var summary = await _service.GetSummary(EntityType.Protocol, 999);
172+
summary.TotalReviews.Should().Be(0);
173+
}
174+
175+
[Fact]
176+
public async Task GetRatings_Should_Paginate_Correctly()
177+
{
178+
for (int i = 1; i <= 15; i++)
179+
{
180+
_dataStore.Add(new RatingModel
181+
{
182+
Id = i, UserId = i, EntityId = 50, EntityType = EntityType.Protocol, Score = 5,
183+
CreatedAt = DateTime.UtcNow.AddMinutes(i)
184+
});
185+
}
186+
187+
var result = await _service.GetRatings(EntityType.Protocol, 50, page: 2, pageSize: 10);
188+
result.Should().HaveCount(5);
189+
result.First().Id.Should().Be(5);
190+
}
191+
192+
// --- HELPERS ---
193+
194+
private void SetupLoggedInUser(int userId, bool isAdmin = false)
195+
{
196+
var claims = new List<Claim> { new Claim(ClaimTypes.NameIdentifier, "user@test.com") };
197+
var identity = new ClaimsIdentity(claims, "Test");
198+
var principal = new ClaimsPrincipal(identity);
199+
200+
_httpContextAccessorMock.Setup(x => x.HttpContext).Returns(new DefaultHttpContext { User = principal });
201+
202+
var loginModel = new LoginModel { LoginId = userId, Login = "user@test.com", Role = isAdmin ? RoleEnum.Admin : RoleEnum.User };
203+
_loginProcessorMock.Setup(x => x.GetByLogin(It.IsAny<string>(), It.IsAny<LoginTypeEnum>())).ReturnsAsync(loginModel);
204+
_loginProcessorMock.Setup(x => x.GetById(userId)).ReturnsAsync(loginModel);
205+
}
206+
207+
private static Mock<DbSet<T>> CreateDbSetMock<T>(List<T> sourceList) where T : class
208+
{
209+
var queryable = sourceList.AsQueryable();
210+
var dbSetMock = new Mock<DbSet<T>>();
211+
212+
dbSetMock.As<IQueryable<T>>().Setup(m => m.Provider).Returns(new TestAsyncQueryProvider<T>(queryable.Provider));
213+
dbSetMock.As<IQueryable<T>>().Setup(m => m.Expression).Returns(queryable.Expression);
214+
dbSetMock.As<IQueryable<T>>().Setup(m => m.ElementType).Returns(queryable.ElementType);
215+
dbSetMock.As<IQueryable<T>>().Setup(m => m.GetEnumerator()).Returns(queryable.GetEnumerator());
216+
dbSetMock.As<IAsyncEnumerable<T>>().Setup(m => m.GetAsyncEnumerator(It.IsAny<CancellationToken>()))
217+
.Returns(new TestAsyncEnumerator<T>(queryable.GetEnumerator()));
218+
219+
dbSetMock.Setup(d => d.Add(It.IsAny<T>())).Callback<T>(sourceList.Add);
220+
dbSetMock.Setup(d => d.AddRange(It.IsAny<IEnumerable<T>>())).Callback<IEnumerable<T>>(sourceList.AddRange);
221+
222+
return dbSetMock;
223+
}
224+
}
225+
226+
// --- INFRASTRUCTURE ---
227+
228+
internal class TestAsyncQueryProvider<TEntity> : IAsyncQueryProvider
229+
{
230+
private readonly IQueryProvider _inner;
231+
internal TestAsyncQueryProvider(IQueryProvider inner) => _inner = inner;
232+
public IQueryable CreateQuery(Expression expression) => new TestAsyncEnumerable<TEntity>(expression);
233+
public IQueryable<TElement> CreateQuery<TElement>(Expression expression) => new TestAsyncEnumerable<TElement>(expression);
234+
public object? Execute(Expression expression) => _inner.Execute(expression);
235+
public TResult Execute<TResult>(Expression expression) => _inner.Execute<TResult>(expression);
236+
public TResult ExecuteAsync<TResult>(Expression expression, CancellationToken cancellationToken)
237+
{
238+
var expectedResultType = typeof(TResult).GetGenericArguments()[0];
239+
var executionResult = typeof(IQueryProvider)
240+
.GetMethod(nameof(IQueryProvider.Execute), 1, new[] { typeof(Expression) })!
241+
.MakeGenericMethod(expectedResultType)
242+
.Invoke(this, new[] { expression });
243+
return (TResult)typeof(Task).GetMethod(nameof(Task.FromResult))!
244+
.MakeGenericMethod(expectedResultType)
245+
.Invoke(null, new[] { executionResult })!;
246+
}
247+
}
248+
249+
internal class TestAsyncEnumerable<T> : EnumerableQuery<T>, IAsyncEnumerable<T>, IQueryable<T>
250+
{
251+
public TestAsyncEnumerable(IEnumerable<T> enumerable) : base(enumerable) { }
252+
public TestAsyncEnumerable(Expression expression) : base(expression) { }
253+
public IAsyncEnumerator<T> GetAsyncEnumerator(CancellationToken cancellationToken = default) =>
254+
new TestAsyncEnumerator<T>(this.AsEnumerable().GetEnumerator());
255+
IQueryProvider IQueryable.Provider => new TestAsyncQueryProvider<T>(this);
256+
}
257+
258+
internal class TestAsyncEnumerator<T> : IAsyncEnumerator<T>
259+
{
260+
private readonly IEnumerator<T> _inner;
261+
public TestAsyncEnumerator(IEnumerator<T> inner) => _inner = inner;
262+
public ValueTask DisposeAsync() { _inner.Dispose(); return ValueTask.CompletedTask; }
263+
public ValueTask<bool> MoveNextAsync() => ValueTask.FromResult(_inner.MoveNext());
264+
public T Current => _inner.Current;
265+
}
266+
}

Backend/SorobanSecurityPortalApi/Common/Data/Db.cs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ public class Db : DbContext
2727
public DbSet<ForumPostModel> ForumPost { get; set; }
2828

2929

30+
public virtual DbSet<RatingModel> Rating { get; set; }
3031
private readonly IDbQuery _dbQuery;
3132
private readonly ILogger<Db> _logger;
3233
private readonly IDataSourceProvider _dataSourceProvider;
@@ -122,6 +123,13 @@ protected override void OnModelCreating(ModelBuilder builder)
122123
}
123124
);
124125

126+
builder.Entity<RatingModel>()
127+
.HasIndex(r => new { r.UserId, r.EntityType, r.EntityId })
128+
.IsUnique();
129+
130+
builder.Entity<RatingModel>()
131+
.HasIndex(r => new { r.EntityType, r.EntityId });
132+
125133
builder.HasDbFunction(typeof(TrigramExtensions).GetMethod(nameof(TrigramExtensions.TrigramSimilarity))!)
126134
.HasName("similarity"); // PostgreSQL built-in function
127135
foreach (var entity in builder.Model.GetEntityTypes())
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
using System.Collections.Generic;
2+
using System.Threading.Tasks;
3+
using Microsoft.AspNetCore.Authorization;
4+
using Microsoft.AspNetCore.Mvc;
5+
using SorobanSecurityPortalApi.Models.DbModels;
6+
using SorobanSecurityPortalApi.Models.ViewModels;
7+
using SorobanSecurityPortalApi.Services.ControllersServices;
8+
9+
namespace SorobanSecurityPortalApi.Controllers
10+
{
11+
[ApiController]
12+
[Route("api/v1/ratings")]
13+
public class RatingController : ControllerBase
14+
{
15+
private readonly IRatingService _ratingService;
16+
17+
public RatingController(IRatingService ratingService)
18+
{
19+
_ratingService = ratingService;
20+
}
21+
22+
[HttpGet("summary")]
23+
public async Task<IActionResult> GetSummary([FromQuery] EntityType entityType, [FromQuery] int entityId)
24+
{
25+
// Ensure entityId is positive
26+
if (entityId <= 0)
27+
{
28+
return BadRequest("EntityId must be a positive integer.");
29+
}
30+
31+
var result = await _ratingService.GetSummary(entityType, entityId);
32+
return Ok(result);
33+
}
34+
35+
[HttpGet]
36+
public async Task<IActionResult> GetRatings([FromQuery] EntityType entityType, [FromQuery] int entityId, [FromQuery] int page = 1)
37+
{
38+
// Ensure entityId is positive
39+
if (entityId <= 0)
40+
{
41+
return BadRequest("EntityId must be a positive integer.");
42+
}
43+
44+
var result = await _ratingService.GetRatings(entityType, entityId, page);
45+
return Ok(result);
46+
}
47+
48+
[HttpPost]
49+
[Authorize]
50+
public async Task<IActionResult> CreateOrUpdate([FromBody] CreateRatingRequest request)
51+
{
52+
53+
if (request == null)
54+
{
55+
return BadRequest("Request body cannot be null.");
56+
}
57+
58+
if (request.Score < 1 || request.Score > 5)
59+
{
60+
return BadRequest("Score must be between 1 and 5.");
61+
}
62+
63+
var result = await _ratingService.AddOrUpdateRating(request);
64+
return Ok(result);
65+
}
66+
67+
[HttpDelete("{id}")]
68+
[Authorize]
69+
public async Task<IActionResult> Delete(int id)
70+
{
71+
if (id <= 0) return BadRequest("Rating ID must be a positive integer.");
72+
73+
try
74+
{
75+
await _ratingService.DeleteRating(id);
76+
return NoContent();
77+
}
78+
catch (System.UnauthorizedAccessException)
79+
{
80+
return Forbid();
81+
}
82+
catch (KeyNotFoundException)
83+
{
84+
return NotFound($"Rating with id {id} not found.");
85+
}
86+
}
87+
}
88+
}

0 commit comments

Comments
 (0)