Repository navigation
chore(release): 同步成员任期修复到 main #236
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
bc1ac95
cf61f8e
d6c955c
985d142
359d3a3
4eaf4d8
b689bdf
d8f8558
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,228 @@ | ||
| using System.Net; | ||
| using System.Net.Http.Headers; | ||
| using System.Net.Http.Json; | ||
| using System.Text.Json; | ||
| using ClubHub.Api.Data; | ||
| using ClubHub.Api.Data.Entities; | ||
| using ClubHub.Api.Services; | ||
| using Microsoft.EntityFrameworkCore; | ||
| using Microsoft.Extensions.DependencyInjection; | ||
|
|
||
| namespace ClubHub.Api.Tests; | ||
|
|
||
| public sealed class ClubMemberTermEndpointTests | ||
| { | ||
| [Fact] | ||
| public async Task GetMembers_DefaultQueryIncludesFutureActiveTerm() | ||
| { | ||
| await using var factory = new ClubHubWebApplicationFactory(); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift 🔎 Supported by static analysis🏁 Script executed: #!/bin/bash
# 查找测试工厂与数据库提供程序配置
fd -e cs -e csproj . --exec grep -ln 'UseInMemoryDatabase\|UseOracle\|UseSqlite' {} \; | while IFS= read -r f; do
echo "=== $f ==="
grep -n 'UseInMemoryDatabase\|UseOracle\|UseSqlite\|WebApplicationFactory' "$f"
doneRepository: Palind-Rome/ClubHub Length of output: 3278 🤖 get_repo_knowledge executed:
Length of output: 11665 🏁 Script executed: #!/bin/bash
set -eu
printf '%s\n' '=== changed test and factory ==='
sed -n '1,260p' backend.Tests/ClubMemberTermEndpointTests.cs
sed -n '1,120p' backend.Tests/ClubHubWebApplicationFactory.cs
printf '%s\n' '=== member-term write implementation and callers ==='
rg -n -C 8 'ExecuteMemberTermWriteAsync|LockMemberTermUserRowAsync|IsInMemoryDatabase|MemberTerm' backend backend.Tests backend.OracleIntegrationTests -g '*.cs'
printf '%s\n' '=== existing relational test factory ==='
sed -n '340,420p' backend.Tests/BudgetApprovalFlowTests.cs
printf '%s\n' '=== Oracle integration project files ==='
find backend.OracleIntegrationTests -maxdepth 2 -type f -printRepository: Palind-Rome/ClubHub Length of output: 50375 🏁 Script executed: #!/bin/bash
set -eu
printf '%s\n' '=== project/test configuration ==='
find backend.Tests backend.OracleIntegrationTests -maxdepth 2 -type f \( -name '*.csproj' -o -name '*.cs' \) -print | sort
printf '%s\n' '=== relevant provider and transaction setup ==='
rg -n -C 10 'UseInMemoryDatabase|UseSqlite|UseOracle|BeginTransaction|IsolationLevel|ExecuteMemberTermWriteAsync|LockMemberTermUserRowAsync' backend backend.Tests backend.OracleIntegrationTests -g '*.cs' -g '*.csproj'Repository: Palind-Rome/ClubHub Length of output: 50375 🏁 Script executed: #!/bin/bash
set -eu
printf '%s\n' '=== member-term write block ==='
sed -n '1170,1305p' backend/Controllers/ClubsController.cs
printf '%s\n' '=== member-term helper definitions ==='
rg -n 'ExecuteMemberTermWriteAsync|LockMemberTermUserRowAsync|IsMemberTermWriteSuccess|IsMemberTermWriteRetryable|MemberTermWrite' backend/Controllers/ClubsController.csRepository: Palind-Rome/ClubHub Length of output: 5761 🏁 Script executed: #!/bin/bash
set -eu
sed -n '2525,2608p' backend/Controllers/ClubsController.csRepository: Palind-Rome/ClubHub Length of output: 3056 补充关系数据库并发测试
请在现有 SQLite 工厂中增加两个并发请求的测试,并在独立的 var responses = await Task.WhenAll(
CreateTermAsync(client1, termName, start, end),
CreateTermAsync(client2, termName, start, end));
Assert.Single(responses, response => response.StatusCode == HttpStatusCode.Created);
Assert.Single(responses, response => response.StatusCode == HttpStatusCode.Conflict);🤖 Prompt for AI AgentsSource: Coding guidelines |
||
| var dates = FutureTermDates(2); | ||
| using var client = await SeedClubAsync(factory, dates.Start, dates.End, "未来任期"); | ||
|
|
||
| using var response = await client.GetAsync("/api/v1/clubs/830002/members"); | ||
|
|
||
| Assert.Equal(HttpStatusCode.OK, response.StatusCode); | ||
| using var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); | ||
| var member = Assert.Single(document.RootElement.EnumerateArray()); | ||
| Assert.Equal("active", member.GetProperty("memberStatus").GetString()); | ||
| Assert.Equal("未来任期", member.GetProperty("termName").GetString()); | ||
| Assert.False(member.GetProperty("isCurrent").GetBoolean()); | ||
| } | ||
|
|
||
| [Fact] | ||
| public async Task CreateMemberTerm_DuplicateTermReturnsConflictWithoutEndingExistingTerm() | ||
| { | ||
| await using var factory = new ClubHubWebApplicationFactory(); | ||
| var dates = FutureTermDates(2); | ||
| using var client = await SeedClubAsync(factory, dates.Start, dates.End, "2030-2031学年"); | ||
|
|
||
| using var response = await CreateTermAsync( | ||
| client, | ||
| "2030-2031学年", | ||
| dates.Start, | ||
| dates.End); | ||
|
|
||
| Assert.Equal(HttpStatusCode.Conflict, response.StatusCode); | ||
| using var body = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync()); | ||
| Assert.Equal("CONFLICT", body.RootElement.GetProperty("code").GetString()); | ||
| Assert.Contains("同名任期", body.RootElement.GetProperty("message").GetString()); | ||
| await AssertExistingTermUnchangedAsync(factory, dates.Start, dates.End); | ||
| } | ||
|
|
||
| [Fact] | ||
| public async Task CreateMemberTerm_EarlierThanExistingActiveTermReturnsConflict() | ||
| { | ||
| await using var factory = new ClubHubWebApplicationFactory(); | ||
| var existingDates = FutureTermDates(3); | ||
| var requestedDates = FutureTermDates(2); | ||
| using var client = await SeedClubAsync( | ||
| factory, | ||
| existingDates.Start, | ||
| existingDates.End, | ||
| "较晚任期"); | ||
|
|
||
| using var response = await CreateTermAsync( | ||
| client, | ||
| "较早任期", | ||
| requestedDates.Start, | ||
| requestedDates.End); | ||
|
|
||
| Assert.Equal(HttpStatusCode.Conflict, response.StatusCode); | ||
| await AssertExistingTermUnchangedAsync(factory, existingDates.Start, existingDates.End); | ||
| } | ||
|
|
||
| [Fact] | ||
| public async Task CreateMemberTerm_SameStartWithDifferentNameReturnsConflict() | ||
| { | ||
| await using var factory = new ClubHubWebApplicationFactory(); | ||
| var dates = FutureTermDates(2); | ||
| using var client = await SeedClubAsync(factory, dates.Start, dates.End, "原任期"); | ||
|
|
||
| using var response = await CreateTermAsync( | ||
| client, | ||
| "同日起始的新任期", | ||
| dates.Start, | ||
| dates.End); | ||
|
|
||
| Assert.Equal(HttpStatusCode.Conflict, response.StatusCode); | ||
| await AssertExistingTermUnchangedAsync(factory, dates.Start, dates.End); | ||
| } | ||
|
|
||
| [Fact] | ||
| public async Task CreateMemberTerm_LaterTermClosesEarlierOverlappingTerm() | ||
| { | ||
| await using var factory = new ClubHubWebApplicationFactory(); | ||
| var existingStart = new DateTime(DateTime.UtcNow.Year, 7, 1); | ||
| var requestedDates = FutureTermDates(2); | ||
| var existingEnd = requestedDates.End; | ||
| using var client = await SeedClubAsync( | ||
| factory, | ||
| existingStart, | ||
| existingEnd, | ||
| "原任期"); | ||
|
|
||
| using var response = await CreateTermAsync( | ||
| client, | ||
| "新任期", | ||
| requestedDates.Start, | ||
| requestedDates.End); | ||
|
|
||
| Assert.Equal(HttpStatusCode.Created, response.StatusCode); | ||
| await using var scope = factory.Services.CreateAsyncScope(); | ||
| var db = scope.ServiceProvider.GetRequiredService<ClubHubDbContext>(); | ||
| var terms = await db.ClubMembers | ||
| .Where(member => member.ClubId == 830002 && member.UserId == 830003) | ||
| .OrderBy(member => member.TermStart) | ||
| .ToListAsync(); | ||
| Assert.Equal(2, terms.Count); | ||
| Assert.Equal("ended", terms[0].MemberStatus); | ||
| Assert.Equal(requestedDates.Start.AddDays(-1), terms[0].TermEnd); | ||
| Assert.Equal("active", terms[1].MemberStatus); | ||
| Assert.Equal(requestedDates.Start, terms[1].TermStart); | ||
| Assert.Equal(requestedDates.End, terms[1].TermEnd); | ||
| } | ||
|
|
||
| private static async Task<HttpClient> SeedClubAsync( | ||
| ClubHubWebApplicationFactory factory, | ||
| DateTime existingStart, | ||
| DateTime existingEnd, | ||
| string existingTermName) | ||
| { | ||
| await using var scope = factory.Services.CreateAsyncScope(); | ||
| var db = scope.ServiceProvider.GetRequiredService<ClubHubDbContext>(); | ||
| var now = DateTime.UtcNow; | ||
| var principal = new User | ||
| { | ||
| UserId = 830001, | ||
| Username = "member-term-principal", | ||
| PasswordHash = "unused", | ||
| RealName = "任期测试负责人", | ||
| AccountStatus = "normal", | ||
| CreatedAt = now | ||
| }; | ||
| db.Users.AddRange( | ||
| principal, | ||
| new User | ||
| { | ||
| UserId = 830003, | ||
| Username = "member-term-target", | ||
| PasswordHash = "unused", | ||
| RealName = "任期测试成员", | ||
| AccountStatus = "normal", | ||
| CreatedAt = now | ||
| }); | ||
| db.Clubs.Add(new Club | ||
| { | ||
| ClubId = 830002, | ||
| ClubName = "任期回归测试社团", | ||
| PresidentUserId = principal.UserId, | ||
| AuditStatus = "approved", | ||
| ClubStatus = "active", | ||
| CreatedAt = now | ||
| }); | ||
| db.ClubMembers.Add(new ClubMember | ||
| { | ||
| MemberId = 830004, | ||
| ClubId = 830002, | ||
| UserId = 830003, | ||
| PositionName = "成员", | ||
| TermName = existingTermName, | ||
| TermStart = existingStart, | ||
| TermEnd = existingEnd, | ||
| MemberStatus = "active", | ||
| JoinAt = now, | ||
| ContributionScore = 0 | ||
| }); | ||
| await db.SaveChangesAsync(); | ||
|
|
||
| var token = scope.ServiceProvider | ||
| .GetRequiredService<AuthTokenService>() | ||
| .CreateToken(principal); | ||
| var client = factory.CreateClient(); | ||
| client.DefaultRequestHeaders.Authorization = | ||
| new AuthenticationHeaderValue("Bearer", token); | ||
| return client; | ||
| } | ||
|
|
||
| private static async Task<HttpResponseMessage> CreateTermAsync( | ||
| HttpClient client, | ||
| string termName, | ||
| DateTime termStart, | ||
| DateTime termEnd) | ||
| { | ||
| return await client.PostAsJsonAsync( | ||
| "/api/v1/clubs/830002/members/terms", | ||
| new | ||
| { | ||
| userId = 830003, | ||
| positionName = "成员", | ||
| termName, | ||
| termStart, | ||
| termEnd, | ||
| memberStatus = "active", | ||
| contributionScore = 0, | ||
| closeCurrentTerm = true | ||
| }); | ||
| } | ||
|
|
||
| private static async Task AssertExistingTermUnchangedAsync( | ||
| ClubHubWebApplicationFactory factory, | ||
| DateTime expectedStart, | ||
| DateTime expectedEnd) | ||
| { | ||
| await using var scope = factory.Services.CreateAsyncScope(); | ||
| var db = scope.ServiceProvider.GetRequiredService<ClubHubDbContext>(); | ||
| var term = Assert.Single(await db.ClubMembers | ||
| .Where(member => member.ClubId == 830002 && member.UserId == 830003) | ||
| .ToListAsync()); | ||
| Assert.Equal("active", term.MemberStatus); | ||
| Assert.Equal(expectedStart, term.TermStart); | ||
| Assert.Equal(expectedEnd, term.TermEnd); | ||
| } | ||
|
|
||
| private static (DateTime Start, DateTime End) FutureTermDates(int yearsAhead) | ||
| { | ||
| var startYear = DateTime.UtcNow.Year + yearsAhead; | ||
| return (new DateTime(startYear, 7, 1), new DateTime(startYear + 1, 6, 30)); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -115,7 +115,7 @@ public void ApiErrorCodeUsesStandardRequiredValues() | |||||||||
| "REQUEST_FAILED" | ||||||||||
| }, | ||||||||||
| code => Assert.Contains(code, apiErrorSchema, StringComparison.Ordinal)); | ||||||||||
| Assert.Contains("pattern: '^(", apiErrorSchema, StringComparison.Ordinal); | ||||||||||
| Assert.Matches(@"pattern: ['\""]\^\(", apiErrorSchema); | ||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win 让契约测试校验完整的 当前断言只检查 因此,将 建议修改- Assert.Matches(@"pattern: ['\""]\^\(", apiErrorSchema);
+ Assert.Matches(
+ @"(?m)^\s+pattern:\s*['""]\^\(VALIDATION_ERROR\|UNAUTHORIZED\|FORBIDDEN\|NOT_FOUND\|CONFLICT\|PAYLOAD_TOO_LARGE\|RATE_LIMITED\|SERVICE_UNAVAILABLE\|INTERNAL_ERROR\|REQUEST_FAILED\)\$['""]\s*$",
+ apiErrorSchema);📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||
| Assert.Contains(" required:\n - code", NormalizeNewLines(apiErrorSchema), StringComparison.Ordinal); | ||||||||||
| } | ||||||||||
|
|
||||||||||
|
|
||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win
补全任期冲突的 OpenAPI 语义。
Line 1307 仅说明可以关闭已开始且重叠的有效任期。它没有明确其他非法重叠必须拒绝。
Line 1335 只列出“重复、同日起始或逆序重叠”,且“重复”没有限定为“重复同名”。未来任期重叠或未关闭重叠任期等场景可能同样返回 409,但当前契约没有描述。
请明确“仅允许关闭此前已开始且与新任期重叠的有效任期;其他非法重叠均返回 409”,并同步限定“重复同名”。
建议修改
As per path instructions:
api/**/*.yaml必须保持 OpenAPI 业务描述与成员任期规则一致,包括“仅允许关闭此前已开始且与新任期重叠的有效任期,否则返回 409”。Also applies to: 1335-1335
🧰 Tools
🪛 Checkov (3.3.13)
[high] 1-12664: Ensure that the global security field has rules defined
(CKV_OPENAPI_4)
[high] 1-12664: Ensure that security operations is not empty.
(CKV_OPENAPI_5)
🤖 Prompt for AI Agents
Source: Path instructions