fix(service-appointment): 状态冲突/越权改为 4xx,修并发下 500 与弱断言漏判
CI E2E (live stack) 在 main@63c18d8 上失败:test_appointment_concurrent_safety 并发「用户取消」与「运营确认」得到 [200, 500]。根因是 ServiceAppointmentService 四处状态/权限守卫抛裸 RuntimeException,GlobalExceptionHandler 无对应映射, 只能兜底成 HTTP 500 —— 「预约不存在」「状态不允许」「越权」被报成服务端故障。 改动: - ServiceAppointmentService:四处守卫改用 BusinessException + ApiResponseCode (不存在→404 NOT_FOUND,越权→403 FORBIDDEN,状态冲突→409 REDUNDANT_OPERATION)。 - ReferralService.confirmReferral:同一家族的四条守卫同样收敛(预约/报名的 不存在、越权、非转介类型)。 - tests/e2e/service_appointment_test.py:把 3 处 `!= 200` 的弱断言收紧为 4xx。 弱断言会把 500 当成「越权被拒」放行 —— 这正是该缺陷长期未被发现的原因, 此前只有并发用例的 `< 500` 断言咬了它一口。 - ServiceAppointmentOwnerGuardTest:新增「终态不可再确认/取消」「预约不存在」 用例,并把越权断言从 RuntimeException 收紧到具体业务码(403)。 验证: - 定向后端测试 35 passed(ServiceAppointment* / MatchmakerReferralPhase13Test)。 - 本机真实栈(重建 backend 镜像):service_appointment_test.py 8/8、全量 E2E 65 passed。 - 负向注入(把守卫改回裸 RuntimeException):2 例精准变红,工作区哈希前后一致。
This commit is contained in:
+27
-4
@@ -8,6 +8,8 @@ import com.danyang.match.platform.permission.mapper.OperatorMapper;
|
|||||||
import com.danyang.match.platform.service_appointment.entity.ServiceAppointment;
|
import com.danyang.match.platform.service_appointment.entity.ServiceAppointment;
|
||||||
import com.danyang.match.platform.service_appointment.mapper.ServiceAppointmentMapper;
|
import com.danyang.match.platform.service_appointment.mapper.ServiceAppointmentMapper;
|
||||||
import com.danyang.match.platform.shared.constant.Constants;
|
import com.danyang.match.platform.shared.constant.Constants;
|
||||||
|
import com.danyang.match.platform.shared.enums.ApiResponseCode;
|
||||||
|
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
@@ -126,12 +128,26 @@ public class ReferralService {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户确认转介(预约 / 活动报名)。
|
||||||
|
*
|
||||||
|
* <p>拒绝路径统一走 {@link BusinessException}(带语义化业务码 → 4xx),
|
||||||
|
* 不再用裸 {@code RuntimeException}:后者在 {@code GlobalExceptionHandler}
|
||||||
|
* 只会命中兜底分支变成 HTTP 500,「无权操作」被报成服务端故障。
|
||||||
|
*/
|
||||||
@Transactional
|
@Transactional
|
||||||
public Map<String, Object> confirmReferral(String userId, String targetId, String targetType) {
|
public Map<String, Object> confirmReferral(String userId, String targetId, String targetType) {
|
||||||
if ("service_station".equals(targetType)) {
|
if ("service_station".equals(targetType)) {
|
||||||
ServiceAppointment appt = appointmentMapper.selectById(targetId);
|
ServiceAppointment appt = appointmentMapper.selectById(targetId);
|
||||||
if (appt == null || !userId.equals(appt.getUserId())) throw new RuntimeException("预约不存在或无权操作");
|
if (appt == null) {
|
||||||
if (!Constants.REFERRAL_SOURCE_MATCHMAKER_REFERRAL.equals(appt.getSource())) throw new RuntimeException("该预约不是转介类型");
|
throw new BusinessException(ApiResponseCode.NOT_FOUND, "预约不存在");
|
||||||
|
}
|
||||||
|
if (!userId.equals(appt.getUserId())) {
|
||||||
|
throw new BusinessException(ApiResponseCode.FORBIDDEN, "无权确认他人的转介预约");
|
||||||
|
}
|
||||||
|
if (!Constants.REFERRAL_SOURCE_MATCHMAKER_REFERRAL.equals(appt.getSource())) {
|
||||||
|
throw new BusinessException(ApiResponseCode.REDUNDANT_OPERATION, "该预约不是转介类型");
|
||||||
|
}
|
||||||
appt.setUserConfirmedAt(Instant.now());
|
appt.setUserConfirmedAt(Instant.now());
|
||||||
appt.setStatus(Constants.APPOINTMENT_STATUS_PENDING_CONFIRMATION);
|
appt.setStatus(Constants.APPOINTMENT_STATUS_PENDING_CONFIRMATION);
|
||||||
appt.setUpdatedAt(Instant.now());
|
appt.setUpdatedAt(Instant.now());
|
||||||
@@ -144,8 +160,15 @@ public class ReferralService {
|
|||||||
return result;
|
return result;
|
||||||
} else if ("activity".equals(targetType)) {
|
} else if ("activity".equals(targetType)) {
|
||||||
ActivityRegistration reg = activityRegistrationMapper.selectById(targetId);
|
ActivityRegistration reg = activityRegistrationMapper.selectById(targetId);
|
||||||
if (reg == null || !userId.equals(reg.getUserId())) throw new RuntimeException("报名不存在或无权操作");
|
if (reg == null) {
|
||||||
if (!Constants.REFERRAL_SOURCE_MATCHMAKER_REFERRAL.equals(reg.getSource())) throw new RuntimeException("该报名不是转介类型");
|
throw new BusinessException(ApiResponseCode.NOT_FOUND, "报名不存在");
|
||||||
|
}
|
||||||
|
if (!userId.equals(reg.getUserId())) {
|
||||||
|
throw new BusinessException(ApiResponseCode.FORBIDDEN, "无权确认他人的转介报名");
|
||||||
|
}
|
||||||
|
if (!Constants.REFERRAL_SOURCE_MATCHMAKER_REFERRAL.equals(reg.getSource())) {
|
||||||
|
throw new BusinessException(ApiResponseCode.REDUNDANT_OPERATION, "该报名不是转介类型");
|
||||||
|
}
|
||||||
reg.setUserConfirmedAt(Instant.now());
|
reg.setUserConfirmedAt(Instant.now());
|
||||||
reg.setUpdatedAt(Instant.now());
|
reg.setUpdatedAt(Instant.now());
|
||||||
activityRegistrationMapper.updateById(reg);
|
activityRegistrationMapper.updateById(reg);
|
||||||
|
|||||||
+28
-8
@@ -8,6 +8,8 @@ import com.danyang.match.platform.service_appointment.mapper.ServiceAppointmentM
|
|||||||
import com.danyang.match.platform.service_appointment.entity.ServiceAppointment;
|
import com.danyang.match.platform.service_appointment.entity.ServiceAppointment;
|
||||||
import com.danyang.match.platform.service_appointment.mapper.ServiceLocationMapper;
|
import com.danyang.match.platform.service_appointment.mapper.ServiceLocationMapper;
|
||||||
import com.danyang.match.platform.shared.constant.Constants;
|
import com.danyang.match.platform.shared.constant.Constants;
|
||||||
|
import com.danyang.match.platform.shared.enums.ApiResponseCode;
|
||||||
|
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException;
|
||||||
import com.danyang.match.platform.shared.security.DataScopeGuard;
|
import com.danyang.match.platform.shared.security.DataScopeGuard;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
@@ -134,12 +136,21 @@ public class ServiceAppointmentService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 确认预约(工作人员)
|
* 确认预约(工作人员)
|
||||||
|
*
|
||||||
|
* <p>状态闸门用 {@link BusinessException} + 语义化业务码,**不用裸 RuntimeException**:
|
||||||
|
* 后者落进 {@code GlobalExceptionHandler#handleUnknownException} 变成 HTTP 500。
|
||||||
|
* E2E {@code test_appointment_concurrent_safety} 让「用户取消」与「运营确认」并发,
|
||||||
|
* 落败的一侧必然读到已变更的状态 —— 那是**正常的并发状态冲突**,
|
||||||
|
* 却被旧代码报成 5xx,使调用方(含监控)把「别重试」误读为服务端故障。
|
||||||
*/
|
*/
|
||||||
@Transactional
|
@Transactional
|
||||||
public ServiceAppointmentDTO confirmAppointment(String appointmentId, String operatorId) {
|
public ServiceAppointmentDTO confirmAppointment(String appointmentId, String operatorId) {
|
||||||
ServiceAppointment appt = appointmentMapper.selectById(appointmentId);
|
ServiceAppointment appt = appointmentMapper.selectById(appointmentId);
|
||||||
if (appt == null || !"submitted".equals(appt.getStatus())) {
|
if (appt == null) {
|
||||||
throw new RuntimeException("预约不存在或状态不正确");
|
throw new BusinessException(ApiResponseCode.NOT_FOUND, "预约不存在");
|
||||||
|
}
|
||||||
|
if (!"submitted".equals(appt.getStatus())) {
|
||||||
|
throw new BusinessException(ApiResponseCode.REDUNDANT_OPERATION, "预约当前状态不允许确认");
|
||||||
}
|
}
|
||||||
assertOperatorScope(appointmentId, operatorId);
|
assertOperatorScope(appointmentId, operatorId);
|
||||||
appt.setStatus(Constants.APPOINTMENT_STATUS_CONFIRMED);
|
appt.setStatus(Constants.APPOINTMENT_STATUS_CONFIRMED);
|
||||||
@@ -162,8 +173,11 @@ public class ServiceAppointmentService {
|
|||||||
@Transactional
|
@Transactional
|
||||||
public ServiceAppointmentDTO completeAppointment(String appointmentId, String operatorId) {
|
public ServiceAppointmentDTO completeAppointment(String appointmentId, String operatorId) {
|
||||||
ServiceAppointment appt = appointmentMapper.selectById(appointmentId);
|
ServiceAppointment appt = appointmentMapper.selectById(appointmentId);
|
||||||
if (appt == null || !"confirmed".equals(appt.getStatus())) {
|
if (appt == null) {
|
||||||
throw new RuntimeException("预约状态不正确");
|
throw new BusinessException(ApiResponseCode.NOT_FOUND, "预约不存在");
|
||||||
|
}
|
||||||
|
if (!"confirmed".equals(appt.getStatus())) {
|
||||||
|
throw new BusinessException(ApiResponseCode.REDUNDANT_OPERATION, "预约当前状态不允许完成");
|
||||||
}
|
}
|
||||||
assertOperatorScope(appointmentId, operatorId);
|
assertOperatorScope(appointmentId, operatorId);
|
||||||
appt.setStatus(Constants.APPOINTMENT_STATUS_COMPLETED);
|
appt.setStatus(Constants.APPOINTMENT_STATUS_COMPLETED);
|
||||||
@@ -178,8 +192,11 @@ public class ServiceAppointmentService {
|
|||||||
@Transactional
|
@Transactional
|
||||||
public ServiceAppointmentDTO cancelAppointment(String appointmentId, String userId, String reason) {
|
public ServiceAppointmentDTO cancelAppointment(String appointmentId, String userId, String reason) {
|
||||||
ServiceAppointment appt = appointmentMapper.selectById(appointmentId);
|
ServiceAppointment appt = appointmentMapper.selectById(appointmentId);
|
||||||
if (appt == null || !userId.equals(appt.getUserId())) {
|
if (appt == null) {
|
||||||
throw new RuntimeException("预约不存在或无权操作");
|
throw new BusinessException(ApiResponseCode.NOT_FOUND, "预约不存在");
|
||||||
|
}
|
||||||
|
if (!userId.equals(appt.getUserId())) {
|
||||||
|
throw new BusinessException(ApiResponseCode.FORBIDDEN, "无权操作该预约");
|
||||||
}
|
}
|
||||||
appt.setStatus(Constants.APPOINTMENT_STATUS_CANCELLED);
|
appt.setStatus(Constants.APPOINTMENT_STATUS_CANCELLED);
|
||||||
appt.setCancelReason(reason);
|
appt.setCancelReason(reason);
|
||||||
@@ -191,9 +208,12 @@ public class ServiceAppointmentService {
|
|||||||
@Transactional
|
@Transactional
|
||||||
public ServiceAppointmentDTO cancelAppointmentByOperator(String appointmentId, String operatorId, String reason) {
|
public ServiceAppointmentDTO cancelAppointmentByOperator(String appointmentId, String operatorId, String reason) {
|
||||||
ServiceAppointment appt = appointmentMapper.selectById(appointmentId);
|
ServiceAppointment appt = appointmentMapper.selectById(appointmentId);
|
||||||
if (appt == null || Constants.APPOINTMENT_STATUS_COMPLETED.equals(appt.getStatus())
|
if (appt == null) {
|
||||||
|
throw new BusinessException(ApiResponseCode.NOT_FOUND, "预约不存在");
|
||||||
|
}
|
||||||
|
if (Constants.APPOINTMENT_STATUS_COMPLETED.equals(appt.getStatus())
|
||||||
|| Constants.APPOINTMENT_STATUS_CANCELLED.equals(appt.getStatus())) {
|
|| Constants.APPOINTMENT_STATUS_CANCELLED.equals(appt.getStatus())) {
|
||||||
throw new RuntimeException("预约不存在或状态不允许取消");
|
throw new BusinessException(ApiResponseCode.REDUNDANT_OPERATION, "预约当前状态不允许取消");
|
||||||
}
|
}
|
||||||
assertOperatorScope(appointmentId, operatorId);
|
assertOperatorScope(appointmentId, operatorId);
|
||||||
appt.setStatus(Constants.APPOINTMENT_STATUS_CANCELLED);
|
appt.setStatus(Constants.APPOINTMENT_STATUS_CANCELLED);
|
||||||
|
|||||||
+55
-1
@@ -8,6 +8,8 @@ import com.danyang.match.platform.service_appointment.service.ServiceAppointment
|
|||||||
import com.danyang.match.platform.audit.entity.AuditLog;
|
import com.danyang.match.platform.audit.entity.AuditLog;
|
||||||
import com.danyang.match.platform.audit.mapper.AuditLogMapper;
|
import com.danyang.match.platform.audit.mapper.AuditLogMapper;
|
||||||
import com.danyang.match.platform.shared.constant.Constants;
|
import com.danyang.match.platform.shared.constant.Constants;
|
||||||
|
import com.danyang.match.platform.shared.enums.ApiResponseCode;
|
||||||
|
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler;
|
||||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
import org.junit.jupiter.api.AfterEach;
|
import org.junit.jupiter.api.AfterEach;
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
@@ -174,13 +176,65 @@ class ServiceAppointmentOwnerGuardTest {
|
|||||||
@Test
|
@Test
|
||||||
void userCannotCancelOthersAppointment() {
|
void userCannotCancelOthersAppointment() {
|
||||||
ServiceAppointment a = insert(USER_B, Constants.APPOINTMENT_STATUS_SUBMITTED);
|
ServiceAppointment a = insert(USER_B, Constants.APPOINTMENT_STATUS_SUBMITTED);
|
||||||
assertThrows(RuntimeException.class,
|
// 必须断言**具体业务码**,不能只断言 RuntimeException:
|
||||||
|
// 裸 RuntimeException 在 GlobalExceptionHandler 里会兜底成 HTTP 500,
|
||||||
|
// 于是「越权被拒」在调用方与监控眼里变成服务端故障(E2E 曾因此漏判)。
|
||||||
|
GlobalExceptionHandler.BusinessException ex = assertThrows(
|
||||||
|
GlobalExceptionHandler.BusinessException.class,
|
||||||
() -> appointmentService.cancelAppointment(a.getId(), USER_A, "越权尝试"),
|
() -> appointmentService.cancelAppointment(a.getId(), USER_A, "越权尝试"),
|
||||||
"取消他人预约必须被拒绝");
|
"取消他人预约必须被拒绝");
|
||||||
|
assertEquals(ApiResponseCode.FORBIDDEN, ex.getCode(), "越权取消必须是 403 而非 500");
|
||||||
assertEquals(Constants.APPOINTMENT_STATUS_SUBMITTED,
|
assertEquals(Constants.APPOINTMENT_STATUS_SUBMITTED,
|
||||||
appointmentMapper.selectById(a.getId()).getStatus(), "越权取消不得改变状态");
|
appointmentMapper.selectById(a.getId()).getStatus(), "越权取消不得改变状态");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void cancellingUnknownAppointmentIsNotFound() {
|
||||||
|
GlobalExceptionHandler.BusinessException ex = assertThrows(
|
||||||
|
GlobalExceptionHandler.BusinessException.class,
|
||||||
|
() -> appointmentService.cancelAppointment(
|
||||||
|
UUID.randomUUID().toString(), USER_A, "不存在的预约"));
|
||||||
|
assertEquals(ApiResponseCode.NOT_FOUND, ex.getCode(), "预约不存在必须是 404 而非 500");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 并发取消/确认的落败方必然读到已变更状态 —— 那是**正常的状态冲突**,必须是 409。
|
||||||
|
*
|
||||||
|
* <p>E2E {@code test_appointment_concurrent_safety} 断言「并发不得出现 5xx」,
|
||||||
|
* CI 上确实咬出过一次 `[200, 500]`:确认请求读到 cancelled 后抛裸 RuntimeException。
|
||||||
|
* 此处把该路径在单测层锁死,避免只有并发时序才暴露。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void confirmOnTerminalStatusIsConflictNotServerError() {
|
||||||
|
ServiceAppointment cancelled = insert(USER_A, Constants.APPOINTMENT_STATUS_CANCELLED);
|
||||||
|
|
||||||
|
GlobalExceptionHandler.BusinessException ex = assertThrows(
|
||||||
|
GlobalExceptionHandler.BusinessException.class,
|
||||||
|
() -> appointmentService.confirmAppointment(cancelled.getId(), OPERATOR),
|
||||||
|
"已取消的预约不得再被确认");
|
||||||
|
|
||||||
|
assertEquals(ApiResponseCode.REDUNDANT_OPERATION, ex.getCode(),
|
||||||
|
"状态冲突必须是 409(可判定、不可重试),不能是 500");
|
||||||
|
assertEquals(Constants.APPOINTMENT_STATUS_CANCELLED,
|
||||||
|
appointmentMapper.selectById(cancelled.getId()).getStatus(),
|
||||||
|
"被拒绝的确认不得改动状态");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void operatorCancelOnCompletedStatusIsConflictNotServerError() {
|
||||||
|
ServiceAppointment completed = insert(USER_A, Constants.APPOINTMENT_STATUS_COMPLETED);
|
||||||
|
|
||||||
|
GlobalExceptionHandler.BusinessException ex = assertThrows(
|
||||||
|
GlobalExceptionHandler.BusinessException.class,
|
||||||
|
() -> appointmentService.cancelAppointmentByOperator(completed.getId(), OPERATOR, "试图取消"),
|
||||||
|
"已完成预约不得被运营取消");
|
||||||
|
|
||||||
|
assertEquals(ApiResponseCode.REDUNDANT_OPERATION, ex.getCode(), "状态冲突必须是 409 而非 500");
|
||||||
|
assertEquals(Constants.APPOINTMENT_STATUS_COMPLETED,
|
||||||
|
appointmentMapper.selectById(completed.getId()).getStatus(),
|
||||||
|
"被拒绝的取消不得改动状态");
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void userListOnlyReturnsOwnAppointments() {
|
void userListOnlyReturnsOwnAppointments() {
|
||||||
insert(USER_A, Constants.APPOINTMENT_STATUS_SUBMITTED);
|
insert(USER_A, Constants.APPOINTMENT_STATUS_SUBMITTED);
|
||||||
|
|||||||
@@ -296,22 +296,27 @@ def test_cancel_before_confirmation(api, new_user, station_id):
|
|||||||
|
|
||||||
|
|
||||||
def test_owner_scope_on_detail_and_cancel(api, new_user, station_id):
|
def test_owner_scope_on_detail_and_cancel(api, new_user, station_id):
|
||||||
"""归属隔离(V1.1 计划第 488 行):详情与取消只能操作本人预约。"""
|
"""归属隔离(V1.1 计划第 488 行):详情与取消只能操作本人预约。
|
||||||
|
|
||||||
|
断言必须是 **4xx**,不能只写 `!= 200`:后者会把 500 当成「越权被拒」放行。
|
||||||
|
此类服务端错误曾长期被这两处弱断言掩盖(`cancelAppointment` 抛裸
|
||||||
|
RuntimeException → 兜底 500),只有并发用例的 `< 500` 断言才咬了它一口。
|
||||||
|
"""
|
||||||
owner = new_user()
|
owner = new_user()
|
||||||
stranger = new_user()
|
stranger = new_user()
|
||||||
created = _create(api, owner["token"], station_id)
|
created = _create(api, owner["token"], station_id)
|
||||||
|
|
||||||
detail = api.call("GET", f"/service-appointments/{created['id']}", token=stranger["token"])
|
detail = api.call("GET", f"/service-appointments/{created['id']}", token=stranger["token"])
|
||||||
assert detail.status_code != 200, (
|
assert 400 <= detail.status_code < 500, (
|
||||||
f"他人不得读取我的预约详情(实际 HTTP {detail.status_code})"
|
f"他人读取我的预约详情应被 4xx 拒绝(实际 HTTP {detail.status_code})"
|
||||||
)
|
)
|
||||||
|
|
||||||
cancel = api.call(
|
cancel = api.call(
|
||||||
"POST", f"/service-appointments/{created['id']}/cancel",
|
"POST", f"/service-appointments/{created['id']}/cancel",
|
||||||
token=stranger["token"], json={"reason": "越权取消"},
|
token=stranger["token"], json={"reason": "越权取消"},
|
||||||
)
|
)
|
||||||
assert cancel.status_code != 200, (
|
assert 400 <= cancel.status_code < 500, (
|
||||||
f"他人不得取消我的预约(实际 HTTP {cancel.status_code})"
|
f"他人取消我的预约应被 4xx 拒绝(实际 HTTP {cancel.status_code})"
|
||||||
)
|
)
|
||||||
|
|
||||||
still = _by_id(api, owner["token"], created["id"])
|
still = _by_id(api, owner["token"], created["id"])
|
||||||
@@ -325,6 +330,8 @@ def test_operator_cannot_cancel_completed(api, new_user, operator_token, station
|
|||||||
|
|
||||||
这是 `cancelAppointmentByOperator` 明确实现的规则(拒绝 completed / cancelled),
|
这是 `cancelAppointmentByOperator` 明确实现的规则(拒绝 completed / cancelled),
|
||||||
与用户侧不校验状态形成对照 —— 仅断言**已实现**的那一侧。
|
与用户侧不校验状态形成对照 —— 仅断言**已实现**的那一侧。
|
||||||
|
|
||||||
|
断言 4xx 而非 `!= 200`:状态冲突应当是 409,不是 500。
|
||||||
"""
|
"""
|
||||||
user = new_user()
|
user = new_user()
|
||||||
op_token = operator_token(STATION_OPERATOR)
|
op_token = operator_token(STATION_OPERATOR)
|
||||||
@@ -338,8 +345,8 @@ def test_operator_cannot_cancel_completed(api, new_user, operator_token, station
|
|||||||
"POST", f"/service-appointments/operator/{created['id']}/cancel",
|
"POST", f"/service-appointments/operator/{created['id']}/cancel",
|
||||||
token=op_token, json={"reason": "试图取消已完成预约"},
|
token=op_token, json={"reason": "试图取消已完成预约"},
|
||||||
)
|
)
|
||||||
assert resp.status_code != 200, (
|
assert 400 <= resp.status_code < 500, (
|
||||||
f"已完成预约不得被运营取消(实际 HTTP {resp.status_code}: {resp.text[:200]})"
|
f"已完成预约不得被运营取消,且必须是 4xx(实际 HTTP {resp.status_code}: {resp.text[:200]})"
|
||||||
)
|
)
|
||||||
|
|
||||||
final = _by_id(api, user["token"], created["id"])
|
final = _by_id(api, user["token"], created["id"])
|
||||||
|
|||||||
Reference in New Issue
Block a user