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
15 changes: 10 additions & 5 deletions api/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1036,7 +1036,7 @@ paths:
/api/v1/clubs/{clubId}/members:
get:
summary: 查询社团成员与干部任期记录
description: 系统管理员、社团管理员、本社团负责人、干部、成员和指导老师可以只读查看成员及任期;默认仅返回当前有效记录,可选择包含历史记录,并可按届、部门或小组筛选。社团管理员不参与内部任期维护。
description: 系统管理员、社团管理员、本社团负责人、干部、成员和指导老师可以只读查看成员及任期;默认返回状态有效且尚未到期的当前或未来任期,可选择包含历史记录,并可按届、部门或小组筛选。社团管理员不参与内部任期维护。
operationId: getClubMembers
parameters:
- $ref: "#/components/parameters/Page"
Expand Down Expand Up @@ -1304,7 +1304,7 @@ paths:
/api/v1/clubs/{clubId}/members/terms:
post:
summary: 新增社团成员或干部任期
description: 系统管理员、本社团负责人或指导老师可以为成员新增职位任期;新增任期时可关闭该成员原有效任期以保留历史记录。
description: 系统管理员、本社团负责人或指导老师可以为成员新增职位任期;新增任期时可关闭该成员此前开始且与新任期重叠的有效任期以保留历史记录。同一成员在同一社团不得重复创建同名任期,选择关闭旧任期时也不得用更早或同日起始的新记录覆盖已有任期。
operationId: createClubMemberTerm
parameters:
- name: clubId
Expand Down Expand Up @@ -1332,7 +1332,11 @@ paths:
"404":
description: 当前用户、社团或目标成员不存在
"409":
description: 社团状态不允许维护
description: 社团状态不允许维护,或目标成员存在重复、同日起始或逆序重叠任期
Comment thread
coderabbitai[bot] marked this conversation as resolved.
content:
application/json:
schema:
$ref: "#/components/schemas/ApiError"

/api/v1/clubs/{clubId}/members/{memberId}:
patch:
Expand Down Expand Up @@ -6639,7 +6643,7 @@ components:
properties:
code:
type: string
pattern: '^(VALIDATION_ERROR|UNAUTHORIZED|FORBIDDEN|NOT_FOUND|CONFLICT|PAYLOAD_TOO_LARGE|RATE_LIMITED|SERVICE_UNAVAILABLE|INTERNAL_ERROR|REQUEST_FAILED)$'
pattern: "^(VALIDATION_ERROR|UNAUTHORIZED|FORBIDDEN|NOT_FOUND|CONFLICT|PAYLOAD_TOO_LARGE|RATE_LIMITED|SERVICE_UNAVAILABLE|INTERNAL_ERROR|REQUEST_FAILED)$"
description: >-
固定的 HTTP 错误类别:VALIDATION_ERROR、UNAUTHORIZED、FORBIDDEN、NOT_FOUND、CONFLICT、
PAYLOAD_TOO_LARGE、RATE_LIMITED、SERVICE_UNAVAILABLE、INTERNAL_ERROR、REQUEST_FAILED。
Expand Down Expand Up @@ -7319,7 +7323,8 @@ components:
description: 该话题的回复列表;回复对象的该字段为空数组。
items:
$ref: "#/components/schemas/ForumPost"
required: [id, clubId, userId, content, isTop, postStatus, createdAt, replies]
required:
[id, clubId, userId, content, isTop, postStatus, createdAt, replies]

CreateForumPostRequest:
type: object
Expand Down
228 changes: 228 additions & 0 deletions backend.Tests/ClubMemberTermEndpointTests.cs
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();
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));
}
}
2 changes: 1 addition & 1 deletion backend.Tests/OpenApiRestContractTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Assert.Contains(" required:\n - code", NormalizeNewLines(apiErrorSchema), StringComparison.Ordinal);
}

Expand Down
Loading
Loading