168 changed files with 41383 additions and 14334 deletions
@ -0,0 +1,64 @@ |
|||||
|
/** |
||||
|
* Copyright © 2016-2026 The Thingsboard Authors |
||||
|
* |
||||
|
* Licensed under the Apache License, Version 2.0 (the "License"); |
||||
|
* you may not use this file except in compliance with the License. |
||||
|
* You may obtain a copy of the License at |
||||
|
* |
||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
* |
||||
|
* Unless required by applicable law or agreed to in writing, software |
||||
|
* distributed under the License is distributed on an "AS IS" BASIS, |
||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||
|
* See the License for the specific language governing permissions and |
||||
|
* limitations under the License. |
||||
|
*/ |
||||
|
package org.thingsboard.server.config; |
||||
|
|
||||
|
import lombok.RequiredArgsConstructor; |
||||
|
import lombok.extern.slf4j.Slf4j; |
||||
|
import org.springframework.security.config.annotation.web.configurers.HeadersConfigurer; |
||||
|
import org.springframework.security.web.header.writers.StaticHeadersWriter; |
||||
|
import org.springframework.stereotype.Component; |
||||
|
import org.springframework.util.StringUtils; |
||||
|
|
||||
|
@Slf4j |
||||
|
@Component |
||||
|
@RequiredArgsConstructor |
||||
|
public class HttpSecurityHeadersCustomizer { |
||||
|
|
||||
|
private final HttpSecurityHeadersProperties properties; |
||||
|
|
||||
|
public void customize(HeadersConfigurer<?> headers) { |
||||
|
if (properties.getXContentTypeOptions().isEnabled()) { |
||||
|
headers.contentTypeOptions(config -> {}); |
||||
|
} |
||||
|
|
||||
|
if (properties.getReferrerPolicy().isEnabled()) { |
||||
|
headers.addHeaderWriter(new StaticHeadersWriter("Referrer-Policy", properties.getReferrerPolicy().getValue())); |
||||
|
} |
||||
|
|
||||
|
if (properties.getXFrameOptions().isEnabled()) { |
||||
|
String value = properties.getXFrameOptions().getValue(); |
||||
|
if ("DENY".equalsIgnoreCase(value)) { |
||||
|
headers.frameOptions(HeadersConfigurer.FrameOptionsConfig::deny); |
||||
|
} else { |
||||
|
if (!"SAMEORIGIN".equalsIgnoreCase(value)) { |
||||
|
log.warn("Unrecognized X-Frame-Options value '{}', falling back to SAMEORIGIN. Valid values: DENY, SAMEORIGIN", value); |
||||
|
} |
||||
|
headers.frameOptions(HeadersConfigurer.FrameOptionsConfig::sameOrigin); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
if (properties.getContentSecurityPolicy().isEnabled() && StringUtils.hasText(properties.getContentSecurityPolicy().getValue())) { |
||||
|
headers.contentSecurityPolicy(csp -> { |
||||
|
csp.policyDirectives(properties.getContentSecurityPolicy().getValue()); |
||||
|
if (properties.getContentSecurityPolicy().isReportOnly()) { |
||||
|
csp.reportOnly(); |
||||
|
} |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
} |
||||
|
|
||||
|
} |
||||
@ -0,0 +1,56 @@ |
|||||
|
/** |
||||
|
* Copyright © 2016-2026 The Thingsboard Authors |
||||
|
* |
||||
|
* Licensed under the Apache License, Version 2.0 (the "License"); |
||||
|
* you may not use this file except in compliance with the License. |
||||
|
* You may obtain a copy of the License at |
||||
|
* |
||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
* |
||||
|
* Unless required by applicable law or agreed to in writing, software |
||||
|
* distributed under the License is distributed on an "AS IS" BASIS, |
||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||
|
* See the License for the specific language governing permissions and |
||||
|
* limitations under the License. |
||||
|
*/ |
||||
|
package org.thingsboard.server.config; |
||||
|
|
||||
|
import lombok.Data; |
||||
|
import org.springframework.boot.context.properties.ConfigurationProperties; |
||||
|
import org.springframework.stereotype.Component; |
||||
|
|
||||
|
@Component |
||||
|
@ConfigurationProperties(prefix = "security.headers") |
||||
|
@Data |
||||
|
public class HttpSecurityHeadersProperties { |
||||
|
|
||||
|
private XContentTypeOptions xContentTypeOptions = new XContentTypeOptions(); |
||||
|
private ReferrerPolicy referrerPolicy = new ReferrerPolicy(); |
||||
|
private XFrameOptions xFrameOptions = new XFrameOptions(); |
||||
|
private ContentSecurityPolicy contentSecurityPolicy = new ContentSecurityPolicy(); |
||||
|
|
||||
|
@Data |
||||
|
public static class XContentTypeOptions { |
||||
|
private boolean enabled = true; |
||||
|
} |
||||
|
|
||||
|
@Data |
||||
|
public static class ReferrerPolicy { |
||||
|
private boolean enabled = true; |
||||
|
private String value = "strict-origin-when-cross-origin"; |
||||
|
} |
||||
|
|
||||
|
@Data |
||||
|
public static class XFrameOptions { |
||||
|
private boolean enabled = false; |
||||
|
private String value = "SAMEORIGIN"; |
||||
|
} |
||||
|
|
||||
|
@Data |
||||
|
public static class ContentSecurityPolicy { |
||||
|
private boolean enabled = false; |
||||
|
private String value = ""; |
||||
|
private boolean reportOnly = false; |
||||
|
} |
||||
|
|
||||
|
} |
||||
@ -0,0 +1,272 @@ |
|||||
|
/** |
||||
|
* Copyright © 2016-2026 The Thingsboard Authors |
||||
|
* |
||||
|
* Licensed under the Apache License, Version 2.0 (the "License"); |
||||
|
* you may not use this file except in compliance with the License. |
||||
|
* You may obtain a copy of the License at |
||||
|
* |
||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
* |
||||
|
* Unless required by applicable law or agreed to in writing, software |
||||
|
* distributed under the License is distributed on an "AS IS" BASIS, |
||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||
|
* See the License for the specific language governing permissions and |
||||
|
* limitations under the License. |
||||
|
*/ |
||||
|
package org.thingsboard.server.service.edge.rpc; |
||||
|
|
||||
|
import io.grpc.ManagedChannel; |
||||
|
import io.grpc.Server; |
||||
|
import io.grpc.netty.shaded.io.grpc.netty.GrpcSslContexts; |
||||
|
import io.grpc.netty.shaded.io.grpc.netty.NettyChannelBuilder; |
||||
|
import io.grpc.netty.shaded.io.grpc.netty.NettyServerBuilder; |
||||
|
import org.bouncycastle.asn1.x500.X500Name; |
||||
|
import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter; |
||||
|
import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder; |
||||
|
import org.bouncycastle.jce.provider.BouncyCastleProvider; |
||||
|
import org.bouncycastle.openssl.jcajce.JcaPEMWriter; |
||||
|
import org.bouncycastle.openssl.jcajce.JcePEMEncryptorBuilder; |
||||
|
import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; |
||||
|
import org.bouncycastle.util.io.pem.PemObject; |
||||
|
import org.junit.jupiter.api.AfterEach; |
||||
|
import org.junit.jupiter.params.ParameterizedTest; |
||||
|
import org.junit.jupiter.params.provider.EnumSource; |
||||
|
import org.springframework.test.util.ReflectionTestUtils; |
||||
|
import org.thingsboard.server.controller.AbstractWebTest; |
||||
|
import org.thingsboard.server.gen.edge.v1.EdgeRpcServiceGrpc; |
||||
|
|
||||
|
import java.io.ByteArrayInputStream; |
||||
|
import java.math.BigInteger; |
||||
|
import java.nio.charset.StandardCharsets; |
||||
|
import java.nio.file.Files; |
||||
|
import java.nio.file.Path; |
||||
|
import java.security.KeyPair; |
||||
|
import java.security.KeyPairGenerator; |
||||
|
import java.security.PrivateKey; |
||||
|
import java.security.Security; |
||||
|
import java.security.cert.X509Certificate; |
||||
|
import java.security.spec.ECGenParameterSpec; |
||||
|
import java.util.ArrayList; |
||||
|
import java.util.Date; |
||||
|
import java.util.List; |
||||
|
import java.util.concurrent.TimeUnit; |
||||
|
|
||||
|
import static org.assertj.core.api.Assertions.assertThat; |
||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy; |
||||
|
import static org.awaitility.Awaitility.await; |
||||
|
|
||||
|
/** |
||||
|
* Tests for Edge gRPC SSL setup using the production {@link EdgeGrpcService#setupSsl} method. |
||||
|
* <p> |
||||
|
* Covers: |
||||
|
* 1. Separate cert and key PEM inputs |
||||
|
* 2. Combined PEM (cert + key in one file) |
||||
|
* 3. Encrypted private key with password |
||||
|
* 4. Missing key in combined PEM → error |
||||
|
* <p> |
||||
|
* Each scenario is parameterized across key types: RSA-2048, RSA-4096, EC P-256, EC P-384. |
||||
|
*/ |
||||
|
class EdgeGrpcSslTest { |
||||
|
|
||||
|
static { |
||||
|
if (Security.getProvider(BouncyCastleProvider.PROVIDER_NAME) == null) { |
||||
|
Security.addProvider(new BouncyCastleProvider()); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
enum KeyType { |
||||
|
RSA_2048("RSA", 2048, null, "SHA256withRSA"), |
||||
|
RSA_4096("RSA", 4096, null, "SHA256withRSA"), |
||||
|
EC_P256("EC", 256, "secp256r1", "SHA256withECDSA"), |
||||
|
EC_P384("EC", 384, "secp384r1", "SHA384withECDSA"); |
||||
|
|
||||
|
final String algorithm; |
||||
|
final int size; |
||||
|
final String curve; |
||||
|
final String sigAlg; |
||||
|
|
||||
|
KeyType(String algorithm, int size, String curve, String sigAlg) { |
||||
|
this.algorithm = algorithm; |
||||
|
this.size = size; |
||||
|
this.curve = curve; |
||||
|
this.sigAlg = sigAlg; |
||||
|
} |
||||
|
|
||||
|
KeyPair generateKeyPair() throws Exception { |
||||
|
KeyPairGenerator kpg = KeyPairGenerator.getInstance(algorithm); |
||||
|
if (curve != null) { |
||||
|
kpg.initialize(new ECGenParameterSpec(curve)); |
||||
|
} else { |
||||
|
kpg.initialize(size); |
||||
|
} |
||||
|
return kpg.generateKeyPair(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private final List<Path> tempFiles = new ArrayList<>(); |
||||
|
private Server server; |
||||
|
private ManagedChannel channel; |
||||
|
|
||||
|
@AfterEach |
||||
|
void cleanup() throws Exception { |
||||
|
if (channel != null) { |
||||
|
channel.shutdownNow().awaitTermination(2, TimeUnit.SECONDS); |
||||
|
} |
||||
|
if (server != null) { |
||||
|
server.shutdownNow().awaitTermination(2, TimeUnit.SECONDS); |
||||
|
} |
||||
|
for (Path p : tempFiles) { |
||||
|
Files.deleteIfExists(p); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@ParameterizedTest(name = "separateCertAndKey_{0}") |
||||
|
@EnumSource(KeyType.class) |
||||
|
void separateCertAndKey(KeyType keyType) throws Exception { |
||||
|
KeyPair kp = keyType.generateKeyPair(); |
||||
|
X509Certificate cert = generateSelfSignedCert(kp, keyType.sigAlg); |
||||
|
|
||||
|
Path certFile = writeTempPem("cert", cert); |
||||
|
Path keyFile = writeTempPem("key", kp.getPrivate()); |
||||
|
|
||||
|
server = startServer(certFile.toString(), keyFile.toString(), null); |
||||
|
assertTlsConnectivity(cert); |
||||
|
} |
||||
|
|
||||
|
@ParameterizedTest(name = "combinedPemWithCertAndKey_{0}") |
||||
|
@EnumSource(KeyType.class) |
||||
|
void combinedPemWithCertAndKey(KeyType keyType) throws Exception { |
||||
|
KeyPair kp = keyType.generateKeyPair(); |
||||
|
X509Certificate cert = generateSelfSignedCert(kp, keyType.sigAlg); |
||||
|
|
||||
|
Path combinedFile = writeTempPem("combined", cert, kp.getPrivate()); |
||||
|
|
||||
|
server = startServer(combinedFile.toString(), "", null); |
||||
|
assertTlsConnectivity(cert); |
||||
|
} |
||||
|
|
||||
|
// RSA-only: BouncyCastle writes encrypted EC keys in traditional PEM format (BEGIN EC PRIVATE KEY),
|
||||
|
// which after decryption produces a PEMKeyPair without public key info — causing PemSslCredentials
|
||||
|
// to fail with "Cannot invoke SubjectPublicKeyInfo.getEncoded() because getPublicKeyInfo() is null".
|
||||
|
@ParameterizedTest(name = "encryptedPrivateKey_{0}") |
||||
|
@EnumSource(value = KeyType.class, names = {"RSA_2048", "RSA_4096"}) |
||||
|
void encryptedPrivateKey(KeyType keyType) throws Exception { |
||||
|
KeyPair kp = keyType.generateKeyPair(); |
||||
|
X509Certificate cert = generateSelfSignedCert(kp, keyType.sigAlg); |
||||
|
String password = "test-password"; |
||||
|
|
||||
|
Path combinedFile = writeTempPemEncrypted("enc-combined", password, cert, kp.getPrivate()); |
||||
|
|
||||
|
server = startServer(combinedFile.toString(), "", password); |
||||
|
assertTlsConnectivity(cert); |
||||
|
} |
||||
|
|
||||
|
@ParameterizedTest(name = "combinedPemWithCertOnly_throwsException_{0}") |
||||
|
@EnumSource(KeyType.class) |
||||
|
void combinedPemWithCertOnly_throwsException(KeyType keyType) throws Exception { |
||||
|
KeyPair kp = keyType.generateKeyPair(); |
||||
|
X509Certificate cert = generateSelfSignedCert(kp, keyType.sigAlg); |
||||
|
|
||||
|
Path certOnlyFile = writeTempPem("cert-only", cert); |
||||
|
|
||||
|
assertThatThrownBy(() -> startServer(certOnlyFile.toString(), "", null)) |
||||
|
.isInstanceOf(IllegalArgumentException.class); |
||||
|
} |
||||
|
|
||||
|
// --- Server startup using production EdgeGrpcService.setupSsl() ---
|
||||
|
|
||||
|
private Server startServer(String certFileResource, String privateKeyResource, String keyPassword) throws Exception { |
||||
|
EdgeGrpcService edgeGrpcService = new EdgeGrpcService(); |
||||
|
ReflectionTestUtils.setField(edgeGrpcService, "certFileResource", certFileResource); |
||||
|
ReflectionTestUtils.setField(edgeGrpcService, "privateKeyResource", privateKeyResource); |
||||
|
ReflectionTestUtils.setField(edgeGrpcService, "keyPassword", keyPassword != null ? keyPassword : ""); |
||||
|
|
||||
|
NettyServerBuilder builder = NettyServerBuilder.forPort(0) |
||||
|
.addService(new EdgeRpcServiceGrpc.EdgeRpcServiceImplBase() {}); |
||||
|
|
||||
|
edgeGrpcService.setupSsl(builder); |
||||
|
|
||||
|
return builder.build().start(); |
||||
|
} |
||||
|
|
||||
|
private void assertTlsConnectivity(X509Certificate trustedCert) throws Exception { |
||||
|
String certPem = toPem(trustedCert); |
||||
|
var clientSsl = GrpcSslContexts.forClient() |
||||
|
.trustManager(new ByteArrayInputStream(certPem.getBytes(StandardCharsets.UTF_8))) |
||||
|
.build(); |
||||
|
|
||||
|
channel = NettyChannelBuilder.forAddress("localhost", server.getPort()) |
||||
|
.sslContext(clientSsl) |
||||
|
.build(); |
||||
|
|
||||
|
channel.getState(true); // trigger connection attempt
|
||||
|
await().atMost(AbstractWebTest.TIMEOUT, TimeUnit.SECONDS) |
||||
|
.pollInterval(50, TimeUnit.MILLISECONDS) |
||||
|
.untilAsserted(() -> { |
||||
|
var state = channel.getState(false); |
||||
|
if (state == io.grpc.ConnectivityState.TRANSIENT_FAILURE) { |
||||
|
throw new AssertionError("TLS handshake failed: channel in TRANSIENT_FAILURE"); |
||||
|
} |
||||
|
assertThat(state).isEqualTo(io.grpc.ConnectivityState.READY); |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
// --- Cert/key generation ---
|
||||
|
|
||||
|
private X509Certificate generateSelfSignedCert(KeyPair kp, String sigAlg) throws Exception { |
||||
|
X500Name subject = new X500Name("CN=localhost"); |
||||
|
Date now = new Date(); |
||||
|
return new JcaX509CertificateConverter().getCertificate( |
||||
|
new JcaX509v3CertificateBuilder( |
||||
|
subject, BigInteger.ONE, now, |
||||
|
new Date(now.getTime() + TimeUnit.DAYS.toMillis(1)), |
||||
|
subject, kp.getPublic()) |
||||
|
.build(new JcaContentSignerBuilder(sigAlg).build(kp.getPrivate()))); |
||||
|
} |
||||
|
|
||||
|
// --- PEM file helpers ---
|
||||
|
|
||||
|
private String toPem(Object obj) throws Exception { |
||||
|
java.io.StringWriter sw = new java.io.StringWriter(); |
||||
|
try (JcaPEMWriter w = new JcaPEMWriter(sw)) { |
||||
|
w.writeObject(obj); |
||||
|
} |
||||
|
return sw.toString(); |
||||
|
} |
||||
|
|
||||
|
private Path writeTempPem(String prefix, Object... objects) throws Exception { |
||||
|
Path p = Files.createTempFile(prefix + "-", ".pem"); |
||||
|
tempFiles.add(p); |
||||
|
try (JcaPEMWriter w = new JcaPEMWriter(Files.newBufferedWriter(p))) { |
||||
|
for (Object o : objects) { |
||||
|
w.writeObject(toPkcs8IfKey(o)); |
||||
|
} |
||||
|
} |
||||
|
return p; |
||||
|
} |
||||
|
|
||||
|
private Path writeTempPemEncrypted(String prefix, String password, Object... objects) throws Exception { |
||||
|
Path p = Files.createTempFile(prefix + "-", ".pem"); |
||||
|
tempFiles.add(p); |
||||
|
var encryptor = new JcePEMEncryptorBuilder("AES-256-CBC") |
||||
|
.setProvider(BouncyCastleProvider.PROVIDER_NAME) |
||||
|
.build(password.toCharArray()); |
||||
|
try (JcaPEMWriter w = new JcaPEMWriter(Files.newBufferedWriter(p))) { |
||||
|
for (Object o : objects) { |
||||
|
if (o instanceof PrivateKey) { |
||||
|
w.writeObject(o, encryptor); |
||||
|
} else { |
||||
|
w.writeObject(o); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
return p; |
||||
|
} |
||||
|
|
||||
|
private Object toPkcs8IfKey(Object o) { |
||||
|
if (o instanceof PrivateKey pk) { |
||||
|
return new PemObject("PRIVATE KEY", pk.getEncoded()); |
||||
|
} |
||||
|
return o; |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,144 @@ |
|||||
|
/** |
||||
|
* Copyright © 2016-2026 The Thingsboard Authors |
||||
|
* |
||||
|
* Licensed under the Apache License, Version 2.0 (the "License"); |
||||
|
* you may not use this file except in compliance with the License. |
||||
|
* You may obtain a copy of the License at |
||||
|
* |
||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
* |
||||
|
* Unless required by applicable law or agreed to in writing, software |
||||
|
* distributed under the License is distributed on an "AS IS" BASIS, |
||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||
|
* See the License for the specific language governing permissions and |
||||
|
* limitations under the License. |
||||
|
*/ |
||||
|
package org.thingsboard.server.service.ttl; |
||||
|
|
||||
|
import org.junit.jupiter.api.BeforeEach; |
||||
|
import org.junit.jupiter.api.Test; |
||||
|
import org.junit.jupiter.api.extension.ExtendWith; |
||||
|
import org.mockito.Mock; |
||||
|
import org.mockito.junit.jupiter.MockitoExtension; |
||||
|
import org.springframework.test.util.ReflectionTestUtils; |
||||
|
import org.thingsboard.server.common.data.id.TenantId; |
||||
|
import org.thingsboard.server.common.data.page.PageData; |
||||
|
import org.thingsboard.server.common.msg.queue.TopicPartitionInfo; |
||||
|
import org.thingsboard.server.dao.notification.NotificationRequestDao; |
||||
|
import org.thingsboard.server.dao.sqlts.insert.sql.SqlPartitioningRepository; |
||||
|
import org.thingsboard.server.dao.tenant.TenantService; |
||||
|
import org.thingsboard.server.queue.discovery.PartitionService; |
||||
|
|
||||
|
import java.util.List; |
||||
|
import java.util.UUID; |
||||
|
|
||||
|
import static org.mockito.ArgumentMatchers.any; |
||||
|
import static org.mockito.ArgumentMatchers.anyInt; |
||||
|
import static org.mockito.ArgumentMatchers.anyLong; |
||||
|
import static org.mockito.ArgumentMatchers.anyString; |
||||
|
import static org.mockito.ArgumentMatchers.eq; |
||||
|
import static org.mockito.Mockito.never; |
||||
|
import static org.mockito.Mockito.times; |
||||
|
import static org.mockito.Mockito.verify; |
||||
|
import static org.mockito.Mockito.when; |
||||
|
|
||||
|
@ExtendWith(MockitoExtension.class) |
||||
|
public class NotificationsCleanUpServiceTest { |
||||
|
|
||||
|
@Mock |
||||
|
private PartitionService partitionService; |
||||
|
@Mock |
||||
|
private SqlPartitioningRepository partitioningRepository; |
||||
|
@Mock |
||||
|
private NotificationRequestDao notificationRequestDao; |
||||
|
@Mock |
||||
|
private TenantService tenantService; |
||||
|
|
||||
|
private NotificationsCleanUpService cleanUpService; |
||||
|
|
||||
|
private static final int BATCH_SIZE = 3; |
||||
|
|
||||
|
@BeforeEach |
||||
|
public void setUp() { |
||||
|
cleanUpService = new NotificationsCleanUpService(partitionService, partitioningRepository, notificationRequestDao, tenantService); |
||||
|
ReflectionTestUtils.setField(cleanUpService, "ttlInSec", 2592000L); |
||||
|
ReflectionTestUtils.setField(cleanUpService, "partitionSizeInHours", 168); |
||||
|
ReflectionTestUtils.setField(cleanUpService, "removalBatchSize", BATCH_SIZE); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
public void testBatchLoopCallsDaoMultipleTimes() { |
||||
|
TopicPartitionInfo myPartition = TopicPartitionInfo.builder().topic("tb_core").myPartition(true).build(); |
||||
|
when(partitionService.resolve(any(), any(), any())).thenReturn(myPartition); |
||||
|
when(partitioningRepository.dropPartitionsBefore(anyString(), anyLong(), anyLong())) |
||||
|
.thenReturn(System.currentTimeMillis()); |
||||
|
|
||||
|
TenantId tenantId = TenantId.fromUUID(UUID.randomUUID()); |
||||
|
when(tenantService.findTenantsIds(any())) |
||||
|
.thenReturn(new PageData<>(List.of(tenantId), 1, 1, false)); |
||||
|
|
||||
|
// Sysadmin: returns 3 (full batch), then 1 (partial) -> 2 calls
|
||||
|
when(notificationRequestDao.removeByTenantIdAndCreatedTimeBeforeBatch(eq(TenantId.SYS_TENANT_ID), anyLong(), eq(BATCH_SIZE))) |
||||
|
.thenReturn(BATCH_SIZE) |
||||
|
.thenReturn(1); |
||||
|
// Tenant: returns 3, 3, 0 -> 3 calls
|
||||
|
when(notificationRequestDao.removeByTenantIdAndCreatedTimeBeforeBatch(eq(tenantId), anyLong(), eq(BATCH_SIZE))) |
||||
|
.thenReturn(BATCH_SIZE) |
||||
|
.thenReturn(BATCH_SIZE) |
||||
|
.thenReturn(0); |
||||
|
|
||||
|
cleanUpService.cleanUp(); |
||||
|
|
||||
|
verify(notificationRequestDao, times(2)) |
||||
|
.removeByTenantIdAndCreatedTimeBeforeBatch(eq(TenantId.SYS_TENANT_ID), anyLong(), eq(BATCH_SIZE)); |
||||
|
verify(notificationRequestDao, times(3)) |
||||
|
.removeByTenantIdAndCreatedTimeBeforeBatch(eq(tenantId), anyLong(), eq(BATCH_SIZE)); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
public void testSkipsTenantNotOnMyPartition() { |
||||
|
TopicPartitionInfo myPartition = TopicPartitionInfo.builder().topic("tb_core").myPartition(true).build(); |
||||
|
TopicPartitionInfo notMyPartition = TopicPartitionInfo.builder().topic("tb_core").myPartition(false).build(); |
||||
|
when(partitionService.resolve(any(), eq(TenantId.SYS_TENANT_ID), eq(TenantId.SYS_TENANT_ID))) |
||||
|
.thenReturn(myPartition); |
||||
|
when(partitioningRepository.dropPartitionsBefore(anyString(), anyLong(), anyLong())) |
||||
|
.thenReturn(System.currentTimeMillis()); |
||||
|
|
||||
|
// Sysadmin: no records
|
||||
|
when(notificationRequestDao.removeByTenantIdAndCreatedTimeBeforeBatch(eq(TenantId.SYS_TENANT_ID), anyLong(), eq(BATCH_SIZE))) |
||||
|
.thenReturn(0); |
||||
|
|
||||
|
TenantId myTenant = TenantId.fromUUID(UUID.randomUUID()); |
||||
|
TenantId otherTenant = TenantId.fromUUID(UUID.randomUUID()); |
||||
|
when(tenantService.findTenantsIds(any())) |
||||
|
.thenReturn(new PageData<>(List.of(myTenant, otherTenant), 2, 1, false)); |
||||
|
when(partitionService.resolve(any(), eq(myTenant), eq(myTenant))).thenReturn(myPartition); |
||||
|
when(partitionService.resolve(any(), eq(otherTenant), eq(otherTenant))).thenReturn(notMyPartition); |
||||
|
|
||||
|
when(notificationRequestDao.removeByTenantIdAndCreatedTimeBeforeBatch(eq(myTenant), anyLong(), eq(BATCH_SIZE))) |
||||
|
.thenReturn(0); |
||||
|
|
||||
|
cleanUpService.cleanUp(); |
||||
|
|
||||
|
verify(notificationRequestDao).removeByTenantIdAndCreatedTimeBeforeBatch(eq(myTenant), anyLong(), eq(BATCH_SIZE)); |
||||
|
verify(notificationRequestDao, never()).removeByTenantIdAndCreatedTimeBeforeBatch(eq(otherTenant), anyLong(), anyInt()); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
public void testNoPartitionsDropped_stillCleansUpRequests() { |
||||
|
TopicPartitionInfo myPartition = TopicPartitionInfo.builder().topic("tb_core").myPartition(true).build(); |
||||
|
when(partitionService.resolve(any(), any(), any())).thenReturn(myPartition); |
||||
|
when(partitioningRepository.dropPartitionsBefore(anyString(), anyLong(), anyLong())) |
||||
|
.thenReturn(0L); |
||||
|
|
||||
|
when(notificationRequestDao.removeByTenantIdAndCreatedTimeBeforeBatch(eq(TenantId.SYS_TENANT_ID), anyLong(), eq(BATCH_SIZE))) |
||||
|
.thenReturn(0); |
||||
|
when(tenantService.findTenantsIds(any())) |
||||
|
.thenReturn(new PageData<>(List.of(), 0, 0, false)); |
||||
|
|
||||
|
cleanUpService.cleanUp(); |
||||
|
|
||||
|
verify(notificationRequestDao).removeByTenantIdAndCreatedTimeBeforeBatch(eq(TenantId.SYS_TENANT_ID), anyLong(), eq(BATCH_SIZE)); |
||||
|
} |
||||
|
|
||||
|
} |
||||
@ -0,0 +1,136 @@ |
|||||
|
/** |
||||
|
* Copyright © 2016-2026 The Thingsboard Authors |
||||
|
* |
||||
|
* Licensed under the Apache License, Version 2.0 (the "License"); |
||||
|
* you may not use this file except in compliance with the License. |
||||
|
* You may obtain a copy of the License at |
||||
|
* |
||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
* |
||||
|
* Unless required by applicable law or agreed to in writing, software |
||||
|
* distributed under the License is distributed on an "AS IS" BASIS, |
||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||
|
* See the License for the specific language governing permissions and |
||||
|
* limitations under the License. |
||||
|
*/ |
||||
|
package org.thingsboard.server.service.ttl.rpc; |
||||
|
|
||||
|
import org.junit.jupiter.api.BeforeEach; |
||||
|
import org.junit.jupiter.api.Test; |
||||
|
import org.junit.jupiter.api.extension.ExtendWith; |
||||
|
import org.mockito.Mock; |
||||
|
import org.mockito.junit.jupiter.MockitoExtension; |
||||
|
import org.springframework.test.util.ReflectionTestUtils; |
||||
|
import org.thingsboard.server.common.data.TenantProfile; |
||||
|
import org.thingsboard.server.common.data.id.TenantId; |
||||
|
import org.thingsboard.server.common.data.page.PageData; |
||||
|
import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileConfiguration; |
||||
|
import org.thingsboard.server.common.data.tenant.profile.TenantProfileData; |
||||
|
import org.thingsboard.server.common.msg.queue.TopicPartitionInfo; |
||||
|
import org.thingsboard.server.dao.rpc.RpcDao; |
||||
|
import org.thingsboard.server.dao.tenant.TbTenantProfileCache; |
||||
|
import org.thingsboard.server.dao.tenant.TenantService; |
||||
|
import org.thingsboard.server.queue.discovery.PartitionService; |
||||
|
|
||||
|
import java.util.List; |
||||
|
import java.util.UUID; |
||||
|
|
||||
|
import static org.mockito.ArgumentMatchers.any; |
||||
|
import static org.mockito.ArgumentMatchers.anyInt; |
||||
|
import static org.mockito.ArgumentMatchers.anyLong; |
||||
|
import static org.mockito.ArgumentMatchers.eq; |
||||
|
import static org.mockito.Mockito.never; |
||||
|
import static org.mockito.Mockito.times; |
||||
|
import static org.mockito.Mockito.verify; |
||||
|
import static org.mockito.Mockito.when; |
||||
|
|
||||
|
@ExtendWith(MockitoExtension.class) |
||||
|
public class RpcCleanUpServiceTest { |
||||
|
|
||||
|
@Mock |
||||
|
private PartitionService partitionService; |
||||
|
@Mock |
||||
|
private RpcDao rpcDao; |
||||
|
@Mock |
||||
|
private TenantService tenantService; |
||||
|
@Mock |
||||
|
private TbTenantProfileCache tenantProfileCache; |
||||
|
|
||||
|
private RpcCleanUpService cleanUpService; |
||||
|
|
||||
|
private static final int BATCH_SIZE = 3; |
||||
|
|
||||
|
@BeforeEach |
||||
|
public void setUp() { |
||||
|
cleanUpService = new RpcCleanUpService(tenantService, partitionService, tenantProfileCache, rpcDao); |
||||
|
ReflectionTestUtils.setField(cleanUpService, "removalBatchSize", BATCH_SIZE); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
public void testBatchLoopCallsDaoMultipleTimes() { |
||||
|
TenantId tenantId = TenantId.fromUUID(UUID.randomUUID()); |
||||
|
setupTenant(tenantId, 7); |
||||
|
|
||||
|
// Returns 3 (full batch), 3 (full batch), 1 (partial) -> 3 calls
|
||||
|
when(rpcDao.deleteOutdatedRpcByTenantIdBatch(eq(tenantId), anyLong(), eq(BATCH_SIZE))) |
||||
|
.thenReturn(BATCH_SIZE) |
||||
|
.thenReturn(BATCH_SIZE) |
||||
|
.thenReturn(1); |
||||
|
|
||||
|
cleanUpService.cleanUp(); |
||||
|
|
||||
|
verify(rpcDao, times(3)).deleteOutdatedRpcByTenantIdBatch(eq(tenantId), anyLong(), eq(BATCH_SIZE)); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
public void testSkipsTenantNotOnMyPartition() { |
||||
|
TenantId myTenant = TenantId.fromUUID(UUID.randomUUID()); |
||||
|
TenantId otherTenant = TenantId.fromUUID(UUID.randomUUID()); |
||||
|
|
||||
|
TopicPartitionInfo myPartition = TopicPartitionInfo.builder().topic("tb_core").myPartition(true).build(); |
||||
|
TopicPartitionInfo notMyPartition = TopicPartitionInfo.builder().topic("tb_core").myPartition(false).build(); |
||||
|
|
||||
|
when(tenantService.findTenantsIds(any())) |
||||
|
.thenReturn(new PageData<>(List.of(myTenant, otherTenant), 2, 1, false)); |
||||
|
when(partitionService.resolve(any(), eq(myTenant), eq(myTenant))).thenReturn(myPartition); |
||||
|
when(partitionService.resolve(any(), eq(otherTenant), eq(otherTenant))).thenReturn(notMyPartition); |
||||
|
|
||||
|
setupTenantProfile(myTenant, 7); |
||||
|
when(rpcDao.deleteOutdatedRpcByTenantIdBatch(eq(myTenant), anyLong(), eq(BATCH_SIZE))) |
||||
|
.thenReturn(0); |
||||
|
|
||||
|
cleanUpService.cleanUp(); |
||||
|
|
||||
|
verify(rpcDao).deleteOutdatedRpcByTenantIdBatch(eq(myTenant), anyLong(), eq(BATCH_SIZE)); |
||||
|
verify(rpcDao, never()).deleteOutdatedRpcByTenantIdBatch(eq(otherTenant), anyLong(), anyInt()); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
public void testSkipsTenantWithZeroTtl() { |
||||
|
TenantId tenantId = TenantId.fromUUID(UUID.randomUUID()); |
||||
|
setupTenant(tenantId, 0); |
||||
|
|
||||
|
cleanUpService.cleanUp(); |
||||
|
|
||||
|
verify(rpcDao, never()).deleteOutdatedRpcByTenantIdBatch(any(), anyLong(), anyInt()); |
||||
|
} |
||||
|
|
||||
|
private void setupTenant(TenantId tenantId, int rpcTtlDays) { |
||||
|
TopicPartitionInfo myPartition = TopicPartitionInfo.builder().topic("tb_core").myPartition(true).build(); |
||||
|
when(partitionService.resolve(any(), eq(tenantId), eq(tenantId))).thenReturn(myPartition); |
||||
|
when(tenantService.findTenantsIds(any())) |
||||
|
.thenReturn(new PageData<>(List.of(tenantId), 1, 1, false)); |
||||
|
setupTenantProfile(tenantId, rpcTtlDays); |
||||
|
} |
||||
|
|
||||
|
private void setupTenantProfile(TenantId tenantId, int rpcTtlDays) { |
||||
|
TenantProfile profile = new TenantProfile(); |
||||
|
TenantProfileData profileData = new TenantProfileData(); |
||||
|
DefaultTenantProfileConfiguration config = new DefaultTenantProfileConfiguration(); |
||||
|
config.setRpcTtlDays(rpcTtlDays); |
||||
|
profileData.setConfiguration(config); |
||||
|
profile.setProfileData(profileData); |
||||
|
when(tenantProfileCache.get(tenantId)).thenReturn(profile); |
||||
|
} |
||||
|
|
||||
|
} |
||||
@ -0,0 +1,137 @@ |
|||||
|
/** |
||||
|
* Copyright © 2016-2026 The Thingsboard Authors |
||||
|
* |
||||
|
* Licensed under the Apache License, Version 2.0 (the "License"); |
||||
|
* you may not use this file except in compliance with the License. |
||||
|
* You may obtain a copy of the License at |
||||
|
* |
||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
* |
||||
|
* Unless required by applicable law or agreed to in writing, software |
||||
|
* distributed under the License is distributed on an "AS IS" BASIS, |
||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||
|
* See the License for the specific language governing permissions and |
||||
|
* limitations under the License. |
||||
|
*/ |
||||
|
package org.thingsboard.server.transport.lwm2m.server.store; |
||||
|
|
||||
|
import org.junit.jupiter.api.BeforeEach; |
||||
|
import org.junit.jupiter.api.Test; |
||||
|
import org.junit.jupiter.api.extension.ExtendWith; |
||||
|
import org.mockito.Mock; |
||||
|
import org.mockito.junit.jupiter.MockitoExtension; |
||||
|
import org.springframework.data.redis.connection.RedisConnection; |
||||
|
import org.springframework.data.redis.connection.RedisConnectionFactory; |
||||
|
import org.springframework.data.redis.core.Cursor; |
||||
|
import org.springframework.data.redis.core.ScanOptions; |
||||
|
import org.thingsboard.server.transport.lwm2m.server.client.LwM2MClientState; |
||||
|
import org.thingsboard.server.transport.lwm2m.server.client.LwM2mClient; |
||||
|
|
||||
|
import java.util.List; |
||||
|
import java.util.Set; |
||||
|
import java.util.function.Consumer; |
||||
|
|
||||
|
import static org.assertj.core.api.Assertions.assertThat; |
||||
|
import static org.mockito.ArgumentMatchers.any; |
||||
|
import static org.mockito.Mockito.doAnswer; |
||||
|
import static org.mockito.Mockito.mock; |
||||
|
import static org.mockito.Mockito.never; |
||||
|
import static org.mockito.Mockito.verify; |
||||
|
import static org.mockito.Mockito.when; |
||||
|
import static org.thingsboard.server.transport.lwm2m.server.store.util.LwM2MClientSerDes.serialize; |
||||
|
|
||||
|
/** |
||||
|
* Verifies that {@link TbRedisLwM2MClientStore#getAll()} uses separate connections for |
||||
|
* SCAN and GET operations to prevent Jedis 5.x response-ordering corruption that occurs |
||||
|
* when both commands share the same connection. |
||||
|
*/ |
||||
|
@ExtendWith(MockitoExtension.class) |
||||
|
class TbRedisLwM2MClientStoreTest { |
||||
|
|
||||
|
@Mock |
||||
|
RedisConnectionFactory connectionFactory; |
||||
|
|
||||
|
@Mock |
||||
|
RedisConnection scanConnection; |
||||
|
|
||||
|
@Mock |
||||
|
RedisConnection getConnection; |
||||
|
|
||||
|
TbRedisLwM2MClientStore store; |
||||
|
|
||||
|
@BeforeEach |
||||
|
void setUp() { |
||||
|
// First getConnection() call → scanConnection, second → getConnection
|
||||
|
when(connectionFactory.getConnection()) |
||||
|
.thenReturn(scanConnection) |
||||
|
.thenReturn(getConnection); |
||||
|
store = new TbRedisLwM2MClientStore(connectionFactory); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void getAll_returnsSingleClient() { |
||||
|
LwM2mClient client = new LwM2mClient("nodeId", "testEndpoint"); |
||||
|
client.setState(LwM2MClientState.REGISTERED); |
||||
|
byte[] key = "CLIENT#EP#testEndpoint".getBytes(); |
||||
|
byte[] value = serialize(client); |
||||
|
|
||||
|
// Cursor created before thenReturn to avoid Mockito unfinished-stubbing error
|
||||
|
Cursor<byte[]> cursor = cursorOf(key); |
||||
|
when(scanConnection.scan(any(ScanOptions.class))).thenReturn(cursor); |
||||
|
when(getConnection.get(key)).thenReturn(value); |
||||
|
|
||||
|
Set<LwM2mClient> result = store.getAll(); |
||||
|
|
||||
|
assertThat(result).hasSize(1); |
||||
|
assertThat(result.iterator().next().getEndpoint()).isEqualTo("testEndpoint"); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void getAll_getIsNeverCalledOnScanConnection() { |
||||
|
Cursor<byte[]> cursor = cursorOf(); |
||||
|
when(scanConnection.scan(any(ScanOptions.class))).thenReturn(cursor); |
||||
|
|
||||
|
store.getAll(); |
||||
|
|
||||
|
verify(scanConnection, never()).get(any(byte[].class)); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void getAll_scanIsNeverCalledOnGetConnection() { |
||||
|
Cursor<byte[]> cursor = cursorOf(); |
||||
|
when(scanConnection.scan(any(ScanOptions.class))).thenReturn(cursor); |
||||
|
|
||||
|
store.getAll(); |
||||
|
|
||||
|
verify(getConnection, never()).scan(any(ScanOptions.class)); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void getAll_skipsKeyWhenValueIsNull() { |
||||
|
byte[] key = "CLIENT#EP#gone".getBytes(); |
||||
|
Cursor<byte[]> cursor = cursorOf(key); |
||||
|
when(scanConnection.scan(any(ScanOptions.class))).thenReturn(cursor); |
||||
|
// getConnection.get(key) returns null by default — no stubbing needed
|
||||
|
|
||||
|
Set<LwM2mClient> result = store.getAll(); |
||||
|
|
||||
|
assertThat(result).isEmpty(); |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Creates a mock {@link Cursor} that iterates over the given keys via {@code forEachRemaining}. |
||||
|
* The cursor is created separately (not inside a {@code thenReturn()} argument) to avoid |
||||
|
* Mockito's "unfinished stubbing" error caused by nested {@code when()} calls. |
||||
|
*/ |
||||
|
@SuppressWarnings("unchecked") |
||||
|
private static Cursor<byte[]> cursorOf(byte[]... keys) { |
||||
|
Cursor<byte[]> cursor = mock(Cursor.class); |
||||
|
List<byte[]> keyList = List.of(keys); |
||||
|
doAnswer(inv -> { |
||||
|
Consumer<byte[]> action = inv.getArgument(0); |
||||
|
keyList.forEach(action); |
||||
|
return null; |
||||
|
}).when(cursor).forEachRemaining(any(Consumer.class)); |
||||
|
return cursor; |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,133 @@ |
|||||
|
/** |
||||
|
* Copyright © 2016-2026 The Thingsboard Authors |
||||
|
* |
||||
|
* Licensed under the Apache License, Version 2.0 (the "License"); |
||||
|
* you may not use this file except in compliance with the License. |
||||
|
* You may obtain a copy of the License at |
||||
|
* |
||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
* |
||||
|
* Unless required by applicable law or agreed to in writing, software |
||||
|
* distributed under the License is distributed on an "AS IS" BASIS, |
||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||
|
* See the License for the specific language governing permissions and |
||||
|
* limitations under the License. |
||||
|
*/ |
||||
|
package org.thingsboard.server.dao.sql.notification; |
||||
|
|
||||
|
import org.junit.After; |
||||
|
import org.junit.Test; |
||||
|
import org.springframework.beans.factory.annotation.Autowired; |
||||
|
import org.thingsboard.server.common.data.id.NotificationRequestId; |
||||
|
import org.thingsboard.server.common.data.id.TenantId; |
||||
|
import org.thingsboard.server.common.data.notification.NotificationRequest; |
||||
|
import org.thingsboard.server.common.data.notification.NotificationRequestStatus; |
||||
|
import org.thingsboard.server.dao.AbstractJpaDaoTest; |
||||
|
|
||||
|
import java.util.ArrayList; |
||||
|
import java.util.List; |
||||
|
import java.util.UUID; |
||||
|
import java.util.concurrent.TimeUnit; |
||||
|
|
||||
|
import static org.assertj.core.api.Assertions.assertThat; |
||||
|
|
||||
|
public class JpaNotificationRequestDaoTest extends AbstractJpaDaoTest { |
||||
|
|
||||
|
@Autowired |
||||
|
JpaNotificationRequestDao notificationRequestDao; |
||||
|
|
||||
|
private final List<NotificationRequest> createdRequests = new ArrayList<>(); |
||||
|
|
||||
|
@After |
||||
|
public void tearDown() { |
||||
|
for (NotificationRequest request : createdRequests) { |
||||
|
notificationRequestDao.removeById(request.getTenantId(), request.getId().getId()); |
||||
|
} |
||||
|
createdRequests.clear(); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
public void testBatchDeletion() { |
||||
|
TenantId sysTenantId = TenantId.SYS_TENANT_ID; |
||||
|
long now = System.currentTimeMillis(); |
||||
|
long oldTimestamp = now - TimeUnit.DAYS.toMillis(30); |
||||
|
|
||||
|
NotificationRequest oldRequest1 = createNotificationRequest(sysTenantId, oldTimestamp); |
||||
|
notificationRequestDao.save(sysTenantId, oldRequest1); |
||||
|
|
||||
|
NotificationRequest oldRequest2 = createNotificationRequest(sysTenantId, oldTimestamp); |
||||
|
notificationRequestDao.save(sysTenantId, oldRequest2); |
||||
|
|
||||
|
NotificationRequest freshRequest = createNotificationRequest(sysTenantId, now); |
||||
|
notificationRequestDao.save(sysTenantId, freshRequest); |
||||
|
|
||||
|
TenantId tenant2Id = TenantId.fromUUID(UUID.fromString("3d193a7a-774b-4c05-84d5-f7fdcf7a37cf")); |
||||
|
NotificationRequest tenant2Request = createNotificationRequest(tenant2Id, oldTimestamp); |
||||
|
notificationRequestDao.save(tenant2Id, tenant2Request); |
||||
|
|
||||
|
int batchSize = 10_000; |
||||
|
|
||||
|
assertThat(notificationRequestDao.removeByTenantIdAndCreatedTimeBeforeBatch(sysTenantId, oldTimestamp - 1, batchSize)).isEqualTo(0); |
||||
|
|
||||
|
long expirationTime = now - TimeUnit.DAYS.toMillis(15); |
||||
|
assertThat(notificationRequestDao.removeByTenantIdAndCreatedTimeBeforeBatch(sysTenantId, expirationTime, batchSize)).isEqualTo(2); |
||||
|
|
||||
|
assertThat(notificationRequestDao.findById(sysTenantId, freshRequest.getId().getId())).isNotNull(); |
||||
|
assertThat(notificationRequestDao.removeByTenantIdAndCreatedTimeBeforeBatch(tenant2Id, now + 1, batchSize)).isEqualTo(1); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
public void testBatchDeletionWithSmallBatchSize() { |
||||
|
TenantId tenantId = TenantId.SYS_TENANT_ID; |
||||
|
long oldTimestamp = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(30); |
||||
|
|
||||
|
for (int i = 0; i < 10; i++) { |
||||
|
NotificationRequest request = createNotificationRequest(tenantId, oldTimestamp); |
||||
|
notificationRequestDao.save(tenantId, request); |
||||
|
} |
||||
|
|
||||
|
int batchSize = 3; |
||||
|
long expirationTime = System.currentTimeMillis(); |
||||
|
|
||||
|
assertThat(notificationRequestDao.removeByTenantIdAndCreatedTimeBeforeBatch(tenantId, expirationTime, batchSize)).isEqualTo(3); |
||||
|
assertThat(notificationRequestDao.removeByTenantIdAndCreatedTimeBeforeBatch(tenantId, expirationTime, batchSize)).isEqualTo(3); |
||||
|
assertThat(notificationRequestDao.removeByTenantIdAndCreatedTimeBeforeBatch(tenantId, expirationTime, batchSize)).isEqualTo(3); |
||||
|
assertThat(notificationRequestDao.removeByTenantIdAndCreatedTimeBeforeBatch(tenantId, expirationTime, batchSize)).isEqualTo(1); |
||||
|
assertThat(notificationRequestDao.removeByTenantIdAndCreatedTimeBeforeBatch(tenantId, expirationTime, batchSize)).isEqualTo(0); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
public void testBatchDeletionIsolationBetweenTenants() { |
||||
|
TenantId tenant1 = TenantId.SYS_TENANT_ID; |
||||
|
TenantId tenant2 = TenantId.fromUUID(UUID.fromString("3d193a7a-774b-4c05-84d5-f7fdcf7a37cf")); |
||||
|
long oldTimestamp = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(30); |
||||
|
|
||||
|
for (int i = 0; i < 5; i++) { |
||||
|
NotificationRequest request = createNotificationRequest(tenant1, oldTimestamp); |
||||
|
notificationRequestDao.save(tenant1, request); |
||||
|
} |
||||
|
|
||||
|
for (int i = 0; i < 3; i++) { |
||||
|
NotificationRequest request = createNotificationRequest(tenant2, oldTimestamp); |
||||
|
notificationRequestDao.save(tenant2, request); |
||||
|
} |
||||
|
|
||||
|
int batchSize = 10_000; |
||||
|
long expirationTime = System.currentTimeMillis(); |
||||
|
|
||||
|
assertThat(notificationRequestDao.removeByTenantIdAndCreatedTimeBeforeBatch(tenant1, expirationTime, batchSize)).isEqualTo(5); |
||||
|
assertThat(notificationRequestDao.removeByTenantIdAndCreatedTimeBeforeBatch(tenant2, expirationTime, batchSize)).isEqualTo(3); |
||||
|
} |
||||
|
|
||||
|
private NotificationRequest createNotificationRequest(TenantId tenantId, long createdTime) { |
||||
|
NotificationRequest request = new NotificationRequest(); |
||||
|
request.setId(new NotificationRequestId(UUID.randomUUID())); |
||||
|
request.setTenantId(tenantId); |
||||
|
request.setCreatedTime(createdTime); |
||||
|
request.setTargets(List.of(UUID.randomUUID())); |
||||
|
request.setStatus(NotificationRequestStatus.SENT); |
||||
|
createdRequests.add(request); |
||||
|
return request; |
||||
|
} |
||||
|
|
||||
|
} |
||||
Some files were not shown because too many files changed in this diff
Loading…
Reference in new issue