This commit is contained in:
2026-08-14 16:06:27 +08:00
parent 87eb1aa72f
commit 87e919f3c4
54 changed files with 984 additions and 175 deletions
@@ -31,7 +31,7 @@ class PlatformTenantControllerContractTest {
assertNotNull(annotation(method, 0, Min.class));
assertNotNull(annotation(method, 1, Min.class));
assertEquals(200, annotation(method, 1, Max.class).value());
assertEquals("PROFILE_REQUIRED|PENDING|ACTIVE|REJECTED|DISABLED",
assertEquals("PROFILE_REQUIRED|PENDING|ACTIVE|REJECTED|DISABLED|ARCHIVED",
annotation(method, 3, Pattern.class).regexp());
Set<String> statuses = Arrays.stream(method.getAnnotation(ApiResponses.class).value())
@@ -0,0 +1,126 @@
package com.baiyue.iam.notification;
import com.baiyue.iam.notification.entity.NotificationRecipient;
import com.baiyue.iam.notification.entity.NotificationTask;
import com.baiyue.iam.notification.repository.NotificationRepository;
import com.baiyue.iam.notification.service.NotificationDispatchTransactionService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
class NotificationDispatchTransactionServiceTest {
private final NotificationRepository repository = mock(NotificationRepository.class);
private final NotificationDispatchTransactionService service = new NotificationDispatchTransactionService();
@BeforeEach void setup() {
ReflectionTestUtils.setField(service, "repository", repository);
}
@Test void claimDeliveryAndFailureEvidenceUseIndependentTransactions() throws Exception {
for (var method : List.of(
NotificationDispatchTransactionService.class.getMethod("claimScheduled", Long.class),
NotificationDispatchTransactionService.class.getMethod("claimRetrying", Long.class),
NotificationDispatchTransactionService.class.getMethod("deliverClaimed", Long.class),
NotificationDispatchTransactionService.class.getMethod("recordFailure", Long.class, Throwable.class))) {
assertThat(method.getAnnotation(Transactional.class)).isNotNull()
.extracting(Transactional::propagation)
.isEqualTo(Propagation.REQUIRES_NEW);
}
}
@Test void claimStartsOneOfThreeAllowedAttempts() {
when(repository.claimScheduled(eq(41L), eq(3), any())).thenReturn(true);
assertThat(service.claimScheduled(41L)).isTrue();
verify(repository).claimScheduled(eq(41L), eq(3), any());
}
@Test void retryClaimEnforcesDelayAndSameAttemptLimit() {
when(repository.claimRetrying(eq(41L), eq(3), any(), any())).thenReturn(true);
assertThat(service.claimRetrying(41L)).isTrue();
ArgumentCaptor<LocalDateTime> dueBefore = ArgumentCaptor.forClass(LocalDateTime.class);
ArgumentCaptor<LocalDateTime> now = ArgumentCaptor.forClass(LocalDateTime.class);
verify(repository).claimRetrying(eq(41L), eq(3), dueBefore.capture(), now.capture());
assertThat(dueBefore.getValue()).isEqualTo(now.getValue().minusSeconds(30));
}
@Test void successfulDeliveryWritesInboxThenCompletesTaskAndClearsRecipientFailure() {
NotificationTask task = task("DISPATCHING");
NotificationRecipient recipient = recipient("FAILED");
when(repository.task(41L)).thenReturn(task);
when(repository.recipients(41L)).thenReturn(List.of(recipient));
when(repository.markRecipientDelivered(eq(51L), any())).thenReturn(true);
when(repository.completeDispatch(eq(41L), any())).thenReturn(true);
service.deliverClaimed(41L);
verify(repository).insertInbox(argThat(item -> item.getTaskId().equals(41L)
&& item.getRecipientId().equals(51L) && "UNREAD".equals(item.getStatus())));
var ordered = inOrder(repository);
ordered.verify(repository).markRecipientDelivered(eq(51L), any());
ordered.verify(repository).completeDispatch(eq(41L), any());
}
@Test void alreadyDeliveredRecipientIsNotInsertedAgain() {
NotificationTask task = task("DISPATCHING");
NotificationRecipient recipient = recipient("DELIVERED");
when(repository.task(41L)).thenReturn(task);
when(repository.recipients(41L)).thenReturn(List.of(recipient));
when(repository.completeDispatch(eq(41L), any())).thenReturn(true);
service.deliverClaimed(41L);
verify(repository, never()).insertInbox(any());
verify(repository, never()).markRecipientDelivered(anyLong(), any());
}
@Test void failurePersistsBoundedEvidenceAndUsesAttemptLimit() {
when(repository.failDispatch(eq(41L), eq(3), anyString(), any())).thenReturn(true);
service.recordFailure(41L, new IllegalStateException("x".repeat(1200)));
ArgumentCaptor<String> error = ArgumentCaptor.forClass(String.class);
verify(repository).markUndeliveredRecipientsFailed(eq(41L), error.capture(), any());
assertThat(error.getValue()).hasSize(1000);
verify(repository).failDispatch(eq(41L), eq(3), eq(error.getValue()), any());
}
@Test void rejectsDeliveryWithoutCurrentClaim() {
when(repository.task(41L)).thenReturn(task("RETRYING"));
assertThatThrownBy(() -> service.deliverClaimed(41L))
.isInstanceOf(IllegalStateException.class)
.hasMessage("NOTIFICATION_DISPATCH_CLAIM_LOST");
verify(repository, never()).insertInbox(any());
}
private NotificationTask task(String status) {
NotificationTask task = new NotificationTask();
task.setId(41L); task.setStatus(status); task.setCategory("ORDER"); task.setTitle("状态变化");
task.setBody("正文"); task.setSourceEventId("event-1"); task.setSourceEventType("order.status.changed");
return task;
}
private NotificationRecipient recipient(String status) {
NotificationRecipient recipient = new NotificationRecipient();
recipient.setId(51L); recipient.setTaskId(41L); recipient.setTenantId(9L);
recipient.setMembershipId(11L); recipient.setAppCode("MERCHANT"); recipient.setStatus(status);
recipient.setFailureReason("old error"); recipient.setUpdatedAt(LocalDateTime.now());
return recipient;
}
}
@@ -1,6 +1,7 @@
package com.baiyue.iam.notification;
import com.baiyue.iam.notification.controller.NotificationInboxController;
import com.baiyue.iam.notification.controller.PlatformNotificationController;
import org.junit.jupiter.api.Test;
import org.springframework.security.access.prepost.PreAuthorize;
@@ -18,6 +19,15 @@ class NotificationInboxPermissionContractTest {
assertPermission("readAll", "hasAuthority(#app + ':notification:read-all')");
}
@Test
void taskDetailUsesTheTaskListPermission() {
var method = Arrays.stream(PlatformNotificationController.class.getDeclaredMethods())
.filter(candidate -> candidate.getName().equals("taskDetail"))
.findFirst().orElseThrow();
assertThat(method.getAnnotation(PreAuthorize.class).value())
.isEqualTo("hasAuthority('platform:notification:task:list')");
}
private static void assertPermission(String methodName, String expected) {
var method = Arrays.stream(NotificationInboxController.class.getDeclaredMethods())
.filter(candidate -> candidate.getName().equals(methodName))
@@ -28,6 +28,7 @@ class NotificationOpenApiContractTest {
"/{app}/v1/notifications/read-all", "/platform/v1/notifications",
"/platform/v1/notification-templates", "/platform/v1/notification-templates/{id}",
"/platform/v1/notification-templates/{id}/status", "/platform/v1/notification-tasks",
"/platform/v1/notification-tasks/{id}",
"/platform/v1/notification-tasks/{id}/cancel", "/platform/v1/notification-tasks/{id}/retry");
assertThat(paths).doesNotContainKey("/merchant/v1/notifications:create");
@@ -37,6 +38,7 @@ class NotificationOpenApiContractTest {
assertRequestBody(paths, "/platform/v1/notification-templates/{id}/status", "put");
for (String path : List.of("/platform/v1/notification-templates/{id}",
"/platform/v1/notification-templates/{id}/status",
"/platform/v1/notification-tasks/{id}",
"/platform/v1/notification-tasks/{id}/cancel",
"/platform/v1/notification-tasks/{id}/retry")) {
Map<String, Object> operation = map(map(paths.get(path)).values().iterator().next());
@@ -50,6 +52,7 @@ class NotificationOpenApiContractTest {
"UpdateTemplateRequest", "ChangeTemplateStatusRequest", "InboxPageResponse",
"InboxDetailResponse", "UnreadCountResponse", "ReadAllResponse",
"TemplateItem", "TemplateDetailResponse", "TaskItem", "TaskDetailResponse");
assertThat(schemas).containsKeys("TaskItemResponse", "TaskRecipientItem", "TaskDetail");
for (String schema : List.of("CreateNotificationRequest", "CreateTemplateRequest",
"UpdateTemplateRequest", "ChangeTemplateStatusRequest", "InboxItem", "TemplateItem",
"TaskItem", "InboxPageResponse", "TemplateDetailResponse", "TaskDetailResponse")) {
@@ -59,6 +62,13 @@ class NotificationOpenApiContractTest {
.contains("DISABLED cannot return to ACTIVE")
.contains("does not support deleting or clearing read messages")
.contains("name: taskNo").contains("name: templateCode").contains("name: title");
Map<String, Object> inboxItem = map(schemas.get("InboxItem"));
assertThat(list(inboxItem.get("required"))).contains("summary");
assertThat(map(map(inboxItem.get("properties")).get("summary"))).containsEntry("maxLength", 120);
Map<String, Object> taskDetail = map(schemas.get("TaskDetail"));
assertThat(map(map(taskDetail.get("properties")).get("attemptCount")))
.containsEntry("minimum", 0).containsEntry("maximum", 3);
assertThat(document).contains("scheduler delivery starts after the retry delay");
}
private void assertRequestBody(Map<String, Object> paths, String path, String method) {
@@ -13,15 +13,34 @@ import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.*;
class NotificationSchedulerRecoveryTest {
@Test void claimSqlAtomicallyIncrementsAttemptsAndEnforcesDueTimeAndLimit() throws Exception {
Update scheduled = NotificationTaskMapper.class
.getMethod("claimScheduled", Long.class, int.class, java.time.LocalDateTime.class)
.getAnnotation(Update.class);
String scheduledSql = String.join(" ", scheduled.value()).replaceAll("\\s+", " ").toUpperCase();
assertThat(scheduledSql).contains("STATUS = 'DISPATCHING'", "ATTEMPT_COUNT = ATTEMPT_COUNT + 1",
"STATUS = 'SCHEDULED'", "SCHEDULED_AT <= #{NOW}", "ATTEMPT_COUNT < #{MAXATTEMPTS}");
Update retrying = NotificationTaskMapper.class
.getMethod("claimRetrying", Long.class, int.class, java.time.LocalDateTime.class,
java.time.LocalDateTime.class)
.getAnnotation(Update.class);
String retryingSql = String.join(" ", retrying.value()).replaceAll("\\s+", " ").toUpperCase();
assertThat(retryingSql).contains("STATUS = 'DISPATCHING'", "ATTEMPT_COUNT = ATTEMPT_COUNT + 1",
"STATUS = 'RETRYING'", "UPDATED_AT <= #{DUEBEFORE}", "ATTEMPT_COUNT < #{MAXATTEMPTS}");
}
@Test void recoverySqlRequiresTimeoutAndNoSuccessfulDeliveryEvidence() throws Exception {
Update retry = NotificationTaskMapper.class
.getMethod("retryTimedOutDispatchingWithoutDeliveryEvidence", java.time.LocalDateTime.class, java.time.LocalDateTime.class)
.getMethod("retryTimedOutDispatchingWithoutDeliveryEvidence", java.time.LocalDateTime.class,
java.time.LocalDateTime.class, int.class)
.getAnnotation(Update.class);
String retrySql = String.join(" ", retry.value()).replaceAll("\\s+", " ").toUpperCase();
assertThat(retrySql).contains("TASK.STATUS = 'DISPATCHING'", "TASK.UPDATED_AT <= #{STALEBEFORE}",
"TASK.SENT_AT IS NULL", "RECIPIENT.STATUS = 'DELIVERED'", "IAM_NOTIFICATION_INBOX")
.contains("SET TASK.STATUS = 'RETRYING'");
.contains("SET TASK.STATUS = 'RETRYING'", "TASK.ATTEMPT_COUNT < #{MAXATTEMPTS}")
.doesNotContain("ATTEMPT_COUNT = TASK.ATTEMPT_COUNT + 1");
Update dead = NotificationTaskMapper.class
.getMethod("deadTimedOutDispatchingWithDeliveryEvidence", java.time.LocalDateTime.class, java.time.LocalDateTime.class)
@@ -30,6 +49,21 @@ class NotificationSchedulerRecoveryTest {
assertThat(deadSql).contains("SET TASK.STATUS = 'DEAD'", "TASK.STATUS = 'DISPATCHING'",
"TASK.UPDATED_AT <= #{STALEBEFORE}", "RECIPIENT.STATUS = 'DELIVERED'", "IAM_NOTIFICATION_INBOX",
"DISPATCH_TIMEOUT_WITH_DELIVERY_EVIDENCE");
Update attemptLimit = NotificationTaskMapper.class
.getMethod("deadTimedOutDispatchingAtAttemptLimit", java.time.LocalDateTime.class,
java.time.LocalDateTime.class, int.class)
.getAnnotation(Update.class);
String attemptLimitSql = String.join(" ", attemptLimit.value()).replaceAll("\\s+", " ").toUpperCase();
assertThat(attemptLimitSql).contains("SET TASK.STATUS = 'DEAD'",
"TASK.ATTEMPT_COUNT >= #{MAXATTEMPTS}", "DISPATCH_TIMEOUT_ATTEMPT_LIMIT");
Update failed = NotificationTaskMapper.class
.getMethod("failDispatch", Long.class, int.class, String.class, java.time.LocalDateTime.class)
.getAnnotation(Update.class);
String failedSql = String.join(" ", failed.value()).replaceAll("\\s+", " ").toUpperCase();
assertThat(failedSql).contains("STATUS = CASE WHEN ATTEMPT_COUNT >= #{MAXATTEMPTS} THEN 'DEAD' ELSE 'RETRYING' END",
"LAST_ERROR = #{ERROR}", "STATUS = 'DISPATCHING'");
}
@Test void schedulerRecoversBeforeClaimingScheduledAndRetryingTasks() {
@@ -4,10 +4,12 @@ import com.baiyue.common.core.exception.BizException;
import com.baiyue.iam.notification.dto.NotificationDtos.*;
import com.baiyue.iam.notification.entity.NotificationRecipient;
import com.baiyue.iam.notification.entity.NotificationEventInbox;
import com.baiyue.iam.notification.entity.NotificationInbox;
import com.baiyue.iam.notification.entity.NotificationTask;
import com.baiyue.iam.notification.entity.NotificationTemplate;
import com.baiyue.iam.notification.repository.NotificationRepository;
import com.baiyue.iam.notification.service.NotificationRecipientResolver;
import com.baiyue.iam.notification.service.NotificationDispatchTransactionService;
import com.baiyue.iam.notification.service.NotificationService;
import com.baiyue.iam.notification.service.NotificationTemplateRenderer;
import com.fasterxml.jackson.databind.ObjectMapper;
@@ -27,6 +29,8 @@ import static org.mockito.Mockito.*;
class NotificationServiceTest {
private final NotificationRepository repository = mock(NotificationRepository.class);
private final NotificationRecipientResolver resolver = mock(NotificationRecipientResolver.class);
private final NotificationDispatchTransactionService dispatchTransactions =
mock(NotificationDispatchTransactionService.class);
private final NotificationService service = new NotificationService();
@BeforeEach void setup() {
@@ -34,6 +38,7 @@ class NotificationServiceTest {
ReflectionTestUtils.setField(service, "renderer", new NotificationTemplateRenderer());
ReflectionTestUtils.setField(service, "resolver", resolver);
ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper());
ReflectionTestUtils.setField(service, "dispatchTransactions", dispatchTransactions);
}
@Test void createsTaskAndImmutableRecipientSnapshot() {
@@ -63,6 +68,39 @@ class NotificationServiceTest {
assertThat(stored.get(0).getStatus()).isEqualTo("DELIVERED");
}
@Test void returnsWhitespaceCompactedUnicodeBoundedInboxSummaryWithoutFullBody() {
NotificationInbox item = new NotificationInbox();
item.setId(8L); item.setCategory("ORDER"); item.setTitle("状态变化"); item.setStatus("UNREAD");
item.setSentAt(LocalDateTime.now()); item.setBody(" 第一行\n" + "测".repeat(140) + " ");
when(repository.pageInbox(9L, 11L, "MERCHANT", 1, 20, null, null))
.thenReturn(new com.baiyue.common.core.api.PageResult<>(List.of(item), 1, 20, 1, 1));
NotificationSummary summary = service.inbox(9L, 11L, "MERCHANT", 1, 20, null, null).records().get(0);
assertThat(summary.summary()).startsWith("第一行 ");
assertThat(summary.summary().codePointCount(0, summary.summary().length())).isEqualTo(120);
assertThat(summary.summary()).isNotEqualTo(item.getBody());
}
@Test void returnsTaskAndRecipientFailureEvidence() {
NotificationTask task = task(41L, "DEAD");
task.setTaskNo("NT41"); task.setLastError("gateway timeout"); task.setAttemptCount(3);
NotificationRecipient recipient = new NotificationRecipient();
recipient.setId(51L); recipient.setDisplayName("客户成员"); recipient.setAppCode("MERCHANT");
recipient.setStatus("FAILED"); recipient.setFailureReason("gateway timeout");
when(repository.task(41L)).thenReturn(task);
when(repository.recipients(41L)).thenReturn(List.of(recipient));
TaskDetail detail = service.taskDetail(41L);
assertThat(detail.attemptCount()).isEqualTo(3);
assertThat(detail.lastError()).isEqualTo("gateway timeout");
assertThat(detail.recipients()).singleElement().satisfies(value -> {
assertThat(value.status()).isEqualTo("FAILED");
assertThat(value.failureReason()).isEqualTo("gateway timeout");
});
}
@Test void returnsOriginalTaskForRepeatedIdempotencyKey() {
NotificationTask existing = new NotificationTask(); existing.setId(5L); existing.setTaskNo("NT5");
existing.setCategory("ORDER"); existing.setTitle("A"); existing.setStatus("SENT"); existing.setRecipientCount(1);
@@ -151,31 +189,28 @@ class NotificationServiceTest {
}
@Test void recoversOnlyTasksOlderThanConfiguredDispatchTimeout() {
when(repository.recoverTimedOutDispatching(any(), any())).thenReturn(2);
when(repository.recoverTimedOutDispatching(any(), any(), eq(3))).thenReturn(2);
assertThat(service.recoverTimedOutDispatching(300)).isEqualTo(2);
ArgumentCaptor<LocalDateTime> staleBefore = ArgumentCaptor.forClass(LocalDateTime.class);
ArgumentCaptor<LocalDateTime> now = ArgumentCaptor.forClass(LocalDateTime.class);
verify(repository).recoverTimedOutDispatching(staleBefore.capture(), now.capture());
verify(repository).recoverTimedOutDispatching(staleBefore.capture(), now.capture(), eq(3));
assertThat(staleBefore.getValue()).isEqualTo(now.getValue().minusSeconds(300));
}
@Test void onlyOneInstanceCanClaimRecoveredTask() {
when(repository.claimRetrying(41L)).thenReturn(true, false);
NotificationTask task = task(41L, "RETRYING");
when(repository.task(41L)).thenReturn(task);
when(repository.recipients(41L)).thenReturn(List.of());
when(dispatchTransactions.claimRetrying(41L)).thenReturn(true, false);
assertThat(service.dispatchRetrying(41L)).isTrue();
assertThat(service.dispatchRetrying(41L)).isFalse();
verify(repository, times(2)).claimRetrying(41L);
verify(repository, times(1)).saveTask(task);
verify(dispatchTransactions, times(2)).claimRetrying(41L);
verify(dispatchTransactions, times(1)).deliverClaimed(41L);
}
@Test void sentTaskCannotBeClaimedForRecoveryDelivery() {
when(repository.claimRetrying(42L)).thenReturn(false);
when(dispatchTransactions.claimRetrying(42L)).thenReturn(false);
assertThat(service.dispatchRetrying(42L)).isFalse();
@@ -183,6 +218,30 @@ class NotificationServiceTest {
verify(repository, never()).recipients(42L);
}
@Test void persistsScheduledDeliveryFailureAfterCommittedClaim() {
IllegalStateException failure = new IllegalStateException("inbox unavailable");
when(dispatchTransactions.claimScheduled(43L)).thenReturn(true);
doThrow(failure).when(dispatchTransactions).deliverClaimed(43L);
assertThatThrownBy(() -> service.dispatchScheduled(43L)).isSameAs(failure);
verify(dispatchTransactions).recordFailure(43L, failure);
}
@Test void manualRetryResetsAttemptBudgetAndWaitsForScheduler() {
NotificationTask task = task(44L, "DEAD");
task.setAttemptCount(3); task.setLastError("timeout");
when(repository.task(44L)).thenReturn(task);
when(repository.requeueForManualRetry(eq(44L), any())).thenReturn(true);
TaskListItem result = service.retry(44L);
assertThat(result.status()).isEqualTo("RETRYING");
assertThat(task.getAttemptCount()).isZero();
assertThat(task.getLastError()).isNull();
verify(dispatchTransactions, never()).deliverClaimed(anyLong());
}
private NotificationTask task(long id, String status) {
NotificationTask value = new NotificationTask();
value.setId(id); value.setStatus(status); value.setCategory("ORDER"); value.setTitle("A");
@@ -179,11 +179,11 @@ class TenantServiceTest {
}
@Test
void archiveIsIdempotentForDisabledCustomerWithoutCallingAuth() {
IamTenant tenant = merchant(10001L, "DISABLED");
void archiveIsIdempotentForArchivedCustomerWithoutCallingAuth() {
IamTenant tenant = merchant(10001L, "ARCHIVED");
when(repository.findTenantForUpdate(10001L)).thenReturn(tenant);
assertEquals("DISABLED", service.archiveMerchant(10001L).status());
assertEquals("ARCHIVED", service.archiveMerchant(10001L).status());
verify(authClient, never()).archiveTenant(any(), any());
verify(repository, never()).saveTenant(any());
}
@@ -222,6 +222,34 @@ class TenantServiceTest {
assertEquals(ResultCode.CONFLICT, exception.getResultCode());
}
@Test
void archivedCustomerCannotBeReactivated() {
when(repository.findTenant(10001L)).thenReturn(merchant(10001L, "ARCHIVED"));
BizException exception = assertThrows(BizException.class,
() -> service.changeMerchantStatus(10001L, new ChangeTenantStatusRequest("ACTIVE")));
assertEquals(ResultCode.CONFLICT, exception.getResultCode());
verify(repository, never()).saveTenant(any());
}
@Test
void archivedCustomerIsReadOnlyForPlatformAndMerchantProfileUpdates() {
IamTenant archived = merchant(10001L, "ARCHIVED");
when(repository.findTenant(10001L)).thenReturn(archived);
BizException platformFailure = assertThrows(BizException.class, () -> service.updateMerchant(10001L,
new UpdateTenantRequest("New name", "New legal", "Bob", "Alice", "13800138000", null,
"基础版", null, null, 30, null, null, null, "CNY", null, null)));
BizException merchantFailure = assertThrows(BizException.class, () -> service.update(10001L,
new UpdateProfileRequest("New name", "New legal", "Bob", "Alice",
"13800138000", "alice@example.com")));
assertEquals(ResultCode.CONFLICT, platformFailure.getResultCode());
assertEquals(ResultCode.CONFLICT, merchantFailure.getResultCode());
verify(repository, never()).saveTenant(any());
}
@Test
void merchantProfileCannotReadOrUpdatePlatformTenant() {
IamTenant platform = new IamTenant();
@@ -300,7 +328,7 @@ class TenantServiceTest {
var archived = service.archiveMerchant(10001L);
assertEquals("DISABLED", archived.status());
assertEquals("ARCHIVED", archived.status());
verify(authClient).archiveTenant("internal-token", 10001L);
verify(repository).saveTenant(tenant);
}