I completed the next plan step, Tenant quotas, and updated the plan to mark it done in docs/video-clipping-service-implementation-plan.md:701.
The implementation adds tenant-aware assets/jobs plus a configurable per-tenant active-job quota. The main enforcement is in src/main/java/org/example/videoclips/application/VideoAssetService.java:165, backed by tenant fields added to the domain/persistence model and migration src/main/resources/db/migration/V6__tenant_quota_support.sql:1. Requests can set X-Tenant-Id on asset creation, quota config lives in src/main/java/org/example/videoclips/ config/VideoClippingProperties.java:39, and quota violations now return 429 via src/main/java/org/example/videoclips/api/ApiExceptionHandler.java:44.
This commit is contained in:
parent
8548542248
commit
1bd879f1e2
|
|
@ -703,7 +703,7 @@ Keep the domain independent from Spring framework details. Adapters implement st
|
|||
1. [x] `EXACT` clipping mode.
|
||||
2. [x] Idempotency keys.
|
||||
3. [~] Retry and DLQ handling.
|
||||
4. [ ] Tenant quotas.
|
||||
4. [x] Tenant quotas.
|
||||
5. [~] Full `application/problem+json` error model.
|
||||
6. [x] Cleanup lifecycle jobs.
|
||||
7. [~] Security review.
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import jakarta.servlet.http.HttpServletRequest;
|
|||
import jakarta.validation.ConstraintViolationException;
|
||||
import org.example.videoclips.application.NotFoundException;
|
||||
import org.example.videoclips.application.PayloadTooLargeException;
|
||||
import org.example.videoclips.application.QuotaExceededException;
|
||||
import org.example.videoclips.application.StateConflictException;
|
||||
import org.example.videoclips.application.UnsupportedMediaTypeException;
|
||||
import org.springframework.http.HttpStatus;
|
||||
|
|
@ -45,6 +46,11 @@ public class ApiExceptionHandler extends ResponseEntityExceptionHandler {
|
|||
return problem(HttpStatus.UNSUPPORTED_MEDIA_TYPE, "unsupported-media-type", ex.getMessage(), request.getRequestURI(), null);
|
||||
}
|
||||
|
||||
@ExceptionHandler(QuotaExceededException.class)
|
||||
ProblemDetail handleQuotaExceeded(QuotaExceededException ex, HttpServletRequest request) {
|
||||
return problem(HttpStatus.TOO_MANY_REQUESTS, "quota-exceeded", ex.getMessage(), request.getRequestURI(), null);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected ResponseEntity<Object> handleMethodArgumentNotValid(
|
||||
MethodArgumentNotValidException ex,
|
||||
|
|
|
|||
|
|
@ -30,9 +30,10 @@ public class VideoAssetController {
|
|||
@ResponseStatus(HttpStatus.CREATED)
|
||||
public Object createAsset(
|
||||
@Valid @RequestBody CreateVideoAssetRequest request,
|
||||
@RequestHeader(value = "Idempotency-Key", required = false) String idempotencyKey
|
||||
@RequestHeader(value = "Idempotency-Key", required = false) String idempotencyKey,
|
||||
@RequestHeader(value = "X-Tenant-Id", required = false) String tenantId
|
||||
) {
|
||||
return videoAssetService.createAsset(request, idempotencyKey);
|
||||
return videoAssetService.createAsset(request, idempotencyKey, tenantId);
|
||||
}
|
||||
|
||||
@PostMapping("/{assetId}/uploads:complete")
|
||||
|
|
@ -65,9 +66,10 @@ public class VideoAssetController {
|
|||
public Object createClipJob(
|
||||
@PathVariable String assetId,
|
||||
@Valid @RequestBody ClipJobRequest request,
|
||||
@RequestHeader(value = "Idempotency-Key", required = false) String idempotencyKey
|
||||
@RequestHeader(value = "Idempotency-Key", required = false) String idempotencyKey,
|
||||
@RequestHeader(value = "X-Tenant-Id", required = false) String tenantId
|
||||
) {
|
||||
return videoAssetService.createClipJob(assetId, request, idempotencyKey);
|
||||
return videoAssetService.createClipJob(assetId, request, idempotencyKey, tenantId);
|
||||
}
|
||||
|
||||
@DeleteMapping("/{assetId}")
|
||||
|
|
|
|||
|
|
@ -0,0 +1,8 @@
|
|||
package org.example.videoclips.application;
|
||||
|
||||
public class QuotaExceededException extends RuntimeException {
|
||||
|
||||
public QuotaExceededException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
|
@ -26,6 +26,7 @@ import java.util.UUID;
|
|||
|
||||
@Service
|
||||
public class VideoAssetService {
|
||||
private static final String DEFAULT_TENANT_ID = "default";
|
||||
|
||||
private final VideoClippingRepository repository;
|
||||
private final ObjectStoragePort objectStoragePort;
|
||||
|
|
@ -44,7 +45,7 @@ public class VideoAssetService {
|
|||
this.properties = properties;
|
||||
}
|
||||
|
||||
public Map<String, Object> createAsset(CreateVideoAssetRequest request, String idempotencyKey) {
|
||||
public Map<String, Object> createAsset(CreateVideoAssetRequest request, String idempotencyKey, String tenantId) {
|
||||
if (request.contentLengthBytes() > properties.getMaxFileSizeBytes()) {
|
||||
throw new PayloadTooLargeException("contentLengthBytes exceeds configured max file size");
|
||||
}
|
||||
|
|
@ -52,7 +53,8 @@ public class VideoAssetService {
|
|||
throw new UnsupportedMediaTypeException("contentType is not allowed");
|
||||
}
|
||||
|
||||
String dedupeKey = normalizeKey(idempotencyKey, "asset:" + request.fileName() + ":" + request.contentLengthBytes());
|
||||
String normalizedTenantId = normalizeTenantId(tenantId);
|
||||
String dedupeKey = normalizeKey(idempotencyKey, normalizedTenantId + ":asset:" + request.fileName() + ":" + request.contentLengthBytes());
|
||||
Map<String, Object> existing = repository.findAssetResponseByIdempotencyKey(dedupeKey);
|
||||
if (existing != null) {
|
||||
return existing;
|
||||
|
|
@ -65,6 +67,7 @@ public class VideoAssetService {
|
|||
|
||||
VideoAsset asset = new VideoAsset(
|
||||
assetId,
|
||||
normalizedTenantId,
|
||||
request.fileName(),
|
||||
request.contentType(),
|
||||
request.contentLengthBytes(),
|
||||
|
|
@ -159,7 +162,7 @@ public class VideoAssetService {
|
|||
);
|
||||
}
|
||||
|
||||
public Map<String, Object> createClipJob(String assetId, ClipJobRequest request, String idempotencyKey) {
|
||||
public Map<String, Object> createClipJob(String assetId, ClipJobRequest request, String idempotencyKey, String tenantId) {
|
||||
VideoAsset asset = getAsset(assetId);
|
||||
if (asset.status() != VideoAsset.Status.UPLOADED && asset.status() != VideoAsset.Status.READY) {
|
||||
throw new StateConflictException("asset must be uploaded before a clip job can be created");
|
||||
|
|
@ -168,13 +171,18 @@ public class VideoAssetService {
|
|||
throw new StateConflictException("only mp4 output is supported in this implementation");
|
||||
}
|
||||
|
||||
String dedupeKey = normalizeKey(idempotencyKey, "job:" + assetId + ":" + request.segmentDurationSeconds() + ":" + request.accuracyMode());
|
||||
String effectiveTenantId = asset.tenantId();
|
||||
String dedupeKey = normalizeKey(idempotencyKey, effectiveTenantId + ":job:" + assetId + ":" + request.segmentDurationSeconds() + ":" + request.accuracyMode());
|
||||
Map<String, Object> existing = repository.findJobResponseByIdempotencyKey(dedupeKey);
|
||||
if (existing != null) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
boolean overwriteExisting = Boolean.TRUE.equals(request.overwriteExisting());
|
||||
int maxActiveJobsPerTenant = properties.getQuotas().getMaxActiveJobsPerTenant();
|
||||
if (repository.countActiveJobsForTenant(effectiveTenantId) >= maxActiveJobsPerTenant) {
|
||||
throw new QuotaExceededException("tenant active job quota exceeded");
|
||||
}
|
||||
if (!overwriteExisting && repository.hasActiveJobForAsset(assetId)) {
|
||||
throw new StateConflictException("an active clip job already exists for this asset");
|
||||
}
|
||||
|
|
@ -182,6 +190,7 @@ public class VideoAssetService {
|
|||
ClipJob job = new ClipJob(
|
||||
nextId("job"),
|
||||
assetId,
|
||||
effectiveTenantId,
|
||||
ClipJobStatus.QUEUED,
|
||||
request.accuracyMode() == null ? AccuracyMode.FAST : request.accuracyMode(),
|
||||
request.segmentDurationSeconds(),
|
||||
|
|
@ -433,6 +442,10 @@ public class VideoAssetService {
|
|||
return providedKey == null || providedKey.isBlank() ? fallback : providedKey;
|
||||
}
|
||||
|
||||
private String normalizeTenantId(String tenantId) {
|
||||
return tenantId == null || tenantId.isBlank() ? DEFAULT_TENANT_ID : tenantId;
|
||||
}
|
||||
|
||||
private void appendJobEvent(String aggregateId, String eventType, String message) {
|
||||
repository.saveJobEvent(new JobEvent(nextId("evt"), aggregateId, eventType, message, Instant.now()));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -44,6 +44,8 @@ public interface VideoClippingRepository {
|
|||
|
||||
boolean hasActiveJobForAsset(String assetId);
|
||||
|
||||
int countActiveJobsForTenant(String tenantId);
|
||||
|
||||
Map<String, Object> findAssetResponseByIdempotencyKey(String key);
|
||||
|
||||
void storeAssetResponseByIdempotencyKey(String key, Map<String, Object> response);
|
||||
|
|
|
|||
|
|
@ -38,6 +38,8 @@ public class VideoClippingProperties {
|
|||
|
||||
private final Cleanup cleanup = new Cleanup();
|
||||
|
||||
private final Quotas quotas = new Quotas();
|
||||
|
||||
public long getMaxFileSizeBytes() {
|
||||
return maxFileSizeBytes;
|
||||
}
|
||||
|
|
@ -118,6 +120,10 @@ public class VideoClippingProperties {
|
|||
return cleanup;
|
||||
}
|
||||
|
||||
public Quotas getQuotas() {
|
||||
return quotas;
|
||||
}
|
||||
|
||||
public static class S3 {
|
||||
private String region = "us-east-1";
|
||||
private String bucket = "video-clipping-dev";
|
||||
|
|
@ -308,4 +314,17 @@ public class VideoClippingProperties {
|
|||
this.clipRetentionHours = clipRetentionHours;
|
||||
}
|
||||
}
|
||||
|
||||
public static class Quotas {
|
||||
@Min(0)
|
||||
private int maxActiveJobsPerTenant = 5;
|
||||
|
||||
public int getMaxActiveJobsPerTenant() {
|
||||
return maxActiveJobsPerTenant;
|
||||
}
|
||||
|
||||
public void setMaxActiveJobsPerTenant(int maxActiveJobsPerTenant) {
|
||||
this.maxActiveJobsPerTenant = maxActiveJobsPerTenant;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import java.time.Instant;
|
|||
public record ClipJob(
|
||||
String id,
|
||||
String assetId,
|
||||
String tenantId,
|
||||
ClipJobStatus status,
|
||||
AccuracyMode accuracyMode,
|
||||
int segmentDurationSeconds,
|
||||
|
|
@ -16,27 +17,27 @@ public record ClipJob(
|
|||
String errorMessage
|
||||
) {
|
||||
public ClipJob withStatus(ClipJobStatus newStatus) {
|
||||
return new ClipJob(id, assetId, newStatus, accuracyMode, segmentDurationSeconds, progressPercent, attemptCount,
|
||||
return new ClipJob(id, assetId, tenantId, newStatus, accuracyMode, segmentDurationSeconds, progressPercent, attemptCount,
|
||||
createdAt, startedAt, finishedAt, errorMessage);
|
||||
}
|
||||
|
||||
public ClipJob withProgress(int progress) {
|
||||
return new ClipJob(id, assetId, status, accuracyMode, segmentDurationSeconds, progress, attemptCount,
|
||||
return new ClipJob(id, assetId, tenantId, status, accuracyMode, segmentDurationSeconds, progress, attemptCount,
|
||||
createdAt, startedAt, finishedAt, errorMessage);
|
||||
}
|
||||
|
||||
public ClipJob withStartedAt(Instant startedAtValue) {
|
||||
return new ClipJob(id, assetId, status, accuracyMode, segmentDurationSeconds, progressPercent, attemptCount,
|
||||
return new ClipJob(id, assetId, tenantId, status, accuracyMode, segmentDurationSeconds, progressPercent, attemptCount,
|
||||
createdAt, startedAtValue, finishedAt, errorMessage);
|
||||
}
|
||||
|
||||
public ClipJob withFinishedAt(Instant finishedAtValue) {
|
||||
return new ClipJob(id, assetId, status, accuracyMode, segmentDurationSeconds, progressPercent, attemptCount,
|
||||
return new ClipJob(id, assetId, tenantId, status, accuracyMode, segmentDurationSeconds, progressPercent, attemptCount,
|
||||
createdAt, startedAt, finishedAtValue, errorMessage);
|
||||
}
|
||||
|
||||
public ClipJob withErrorMessage(String message) {
|
||||
return new ClipJob(id, assetId, status, accuracyMode, segmentDurationSeconds, progressPercent, attemptCount,
|
||||
return new ClipJob(id, assetId, tenantId, status, accuracyMode, segmentDurationSeconds, progressPercent, attemptCount,
|
||||
createdAt, startedAt, finishedAt, message);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import java.time.Instant;
|
|||
|
||||
public record VideoAsset(
|
||||
String id,
|
||||
String tenantId,
|
||||
String fileName,
|
||||
String contentType,
|
||||
long contentLengthBytes,
|
||||
|
|
@ -26,12 +27,12 @@ public record VideoAsset(
|
|||
}
|
||||
|
||||
public VideoAsset withUploadCompleted(long durationSeconds, Instant uploadedAt) {
|
||||
return new VideoAsset(id, fileName, contentType, contentLengthBytes, checksumSha256, clipProfile, sourceObjectKey,
|
||||
return new VideoAsset(id, tenantId, fileName, contentType, contentLengthBytes, checksumSha256, clipProfile, sourceObjectKey,
|
||||
Status.UPLOADED, uploadId, durationSeconds, createdAt, uploadedAt, expiresAt);
|
||||
}
|
||||
|
||||
public VideoAsset withStatus(Status status) {
|
||||
return new VideoAsset(id, fileName, contentType, contentLengthBytes, checksumSha256, clipProfile, sourceObjectKey,
|
||||
return new VideoAsset(id, tenantId, fileName, contentType, contentLengthBytes, checksumSha256, clipProfile, sourceObjectKey,
|
||||
status, uploadId, sourceDurationSeconds, createdAt, uploadedAt, expiresAt);
|
||||
}
|
||||
|
||||
|
|
@ -40,7 +41,7 @@ public record VideoAsset(
|
|||
}
|
||||
|
||||
public VideoAsset withExpiresAt(Instant expiration) {
|
||||
return new VideoAsset(id, fileName, contentType, contentLengthBytes, checksumSha256, clipProfile, sourceObjectKey,
|
||||
return new VideoAsset(id, tenantId, fileName, contentType, contentLengthBytes, checksumSha256, clipProfile, sourceObjectKey,
|
||||
status, uploadId, sourceDurationSeconds, createdAt, uploadedAt, expiration);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -142,6 +142,16 @@ public class InMemoryVideoAssetRepository implements VideoClippingRepository {
|
|||
&& job.status() != ClipJobStatus.CANCELLED);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int countActiveJobsForTenant(String tenantId) {
|
||||
return (int) jobs.values().stream()
|
||||
.filter(job -> job.tenantId().equals(tenantId))
|
||||
.filter(job -> job.status() != ClipJobStatus.SUCCEEDED
|
||||
&& job.status() != ClipJobStatus.FAILED
|
||||
&& job.status() != ClipJobStatus.CANCELLED)
|
||||
.count();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, Object> findAssetResponseByIdempotencyKey(String key) {
|
||||
return assetResponsesByIdempotencyKey.get(key);
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ public class JpaVideoClippingMapper {
|
|||
public VideoAssetEntity toEntity(VideoAsset asset) {
|
||||
VideoAssetEntity entity = new VideoAssetEntity();
|
||||
entity.setId(asset.id());
|
||||
entity.setTenantId(asset.tenantId());
|
||||
entity.setFileName(asset.fileName());
|
||||
entity.setContentType(asset.contentType());
|
||||
entity.setContentLengthBytes(asset.contentLengthBytes());
|
||||
|
|
@ -38,6 +39,7 @@ public class JpaVideoClippingMapper {
|
|||
public VideoAsset toDomain(VideoAssetEntity entity) {
|
||||
return new VideoAsset(
|
||||
entity.getId(),
|
||||
entity.getTenantId(),
|
||||
entity.getFileName(),
|
||||
entity.getContentType(),
|
||||
entity.getContentLengthBytes(),
|
||||
|
|
@ -83,6 +85,7 @@ public class JpaVideoClippingMapper {
|
|||
ClipJobEntity entity = new ClipJobEntity();
|
||||
entity.setId(job.id());
|
||||
entity.setAssetId(job.assetId());
|
||||
entity.setTenantId(job.tenantId());
|
||||
entity.setStatus(job.status().name());
|
||||
entity.setAccuracyMode(job.accuracyMode().name());
|
||||
entity.setSegmentDurationSeconds(job.segmentDurationSeconds());
|
||||
|
|
@ -99,6 +102,7 @@ public class JpaVideoClippingMapper {
|
|||
return new ClipJob(
|
||||
entity.getId(),
|
||||
entity.getAssetId(),
|
||||
entity.getTenantId(),
|
||||
ClipJobStatus.valueOf(entity.getStatus()),
|
||||
AccuracyMode.valueOf(entity.getAccuracyMode()),
|
||||
entity.getSegmentDurationSeconds(),
|
||||
|
|
|
|||
|
|
@ -152,6 +152,11 @@ public class JpaVideoClippingRepository implements VideoClippingRepository {
|
|||
&& job.status() != ClipJobStatus.CANCELLED);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int countActiveJobsForTenant(String tenantId) {
|
||||
return Math.toIntExact(clipJobJpaRepository.countActiveByTenantId(tenantId));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, Object> findAssetResponseByIdempotencyKey(String key) {
|
||||
return assetIdempotencyJpaRepository.findById(key)
|
||||
|
|
|
|||
|
|
@ -17,6 +17,9 @@ public class ClipJobEntity {
|
|||
@Column(nullable = false, length = 64)
|
||||
private String assetId;
|
||||
|
||||
@Column(nullable = false, length = 64)
|
||||
private String tenantId;
|
||||
|
||||
@Column(nullable = false, length = 32)
|
||||
private String status;
|
||||
|
||||
|
|
@ -62,6 +65,14 @@ public class ClipJobEntity {
|
|||
return status;
|
||||
}
|
||||
|
||||
public String getTenantId() {
|
||||
return tenantId;
|
||||
}
|
||||
|
||||
public void setTenantId(String tenantId) {
|
||||
this.tenantId = tenantId;
|
||||
}
|
||||
|
||||
public void setStatus(String status) {
|
||||
this.status = status;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,9 @@ public class VideoAssetEntity {
|
|||
@Id
|
||||
private String id;
|
||||
|
||||
@Column(nullable = false, length = 64)
|
||||
private String tenantId;
|
||||
|
||||
@Column(nullable = false, length = 255)
|
||||
private String fileName;
|
||||
|
||||
|
|
@ -59,6 +62,14 @@ public class VideoAssetEntity {
|
|||
return fileName;
|
||||
}
|
||||
|
||||
public String getTenantId() {
|
||||
return tenantId;
|
||||
}
|
||||
|
||||
public void setTenantId(String tenantId) {
|
||||
this.tenantId = tenantId;
|
||||
}
|
||||
|
||||
public void setFileName(String fileName) {
|
||||
this.fileName = fileName;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,10 +2,19 @@ package org.example.videoclips.persistence.spring;
|
|||
|
||||
import org.example.videoclips.persistence.entity.ClipJobEntity;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public interface ClipJobJpaRepository extends JpaRepository<ClipJobEntity, String> {
|
||||
|
||||
List<ClipJobEntity> findByAssetId(String assetId);
|
||||
|
||||
@Query("""
|
||||
select count(j)
|
||||
from ClipJobEntity j
|
||||
where j.tenantId = :tenantId
|
||||
and j.status not in ('SUCCEEDED', 'FAILED', 'CANCELLED')
|
||||
""")
|
||||
long countActiveByTenantId(String tenantId);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,10 +18,13 @@ video-clipping.s3.bucket=video-clipping-dev
|
|||
video-clipping.s3.path-style=true
|
||||
video-clipping.database-queue.poll-interval-ms=1000
|
||||
video-clipping.database-queue.batch-size=5
|
||||
video-clipping.database-queue.max-attempts=3
|
||||
video-clipping.database-queue.retry-backoff-ms=5000
|
||||
video-clipping.ffmpeg.ffmpeg-binary=ffmpeg
|
||||
video-clipping.ffmpeg.input-directory=./tmp/ffmpeg-input
|
||||
video-clipping.ffmpeg.output-directory=./tmp/ffmpeg-output
|
||||
video-clipping.ffmpeg.cleanup-local-files=true
|
||||
video-clipping.quotas.max-active-jobs-per-tenant=5
|
||||
video-clipping.cleanup.enabled=true
|
||||
video-clipping.cleanup.local-artifact-poll-interval-ms=300000
|
||||
video-clipping.cleanup.local-artifact-retention-hours=24
|
||||
|
|
|
|||
|
|
@ -0,0 +1,8 @@
|
|||
alter table video_assets
|
||||
add column if not exists tenant_id varchar(64) not null default 'default';
|
||||
|
||||
alter table clip_jobs
|
||||
add column if not exists tenant_id varchar(64) not null default 'default';
|
||||
|
||||
create index if not exists idx_video_assets_tenant_id on video_assets(tenant_id);
|
||||
create index if not exists idx_clip_jobs_tenant_id_status on clip_jobs(tenant_id, status);
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
package org.example.videoclips.api;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
@SpringBootTest(properties = "video-clipping.quotas.max-active-jobs-per-tenant=0")
|
||||
@AutoConfigureMockMvc
|
||||
class TenantQuotaControllerTest {
|
||||
|
||||
@Autowired
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@Test
|
||||
void rejectsClipJobCreationWhenTenantActiveJobQuotaIsExceeded() throws Exception {
|
||||
String assetResponse = mockMvc.perform(post("/v1/video-assets")
|
||||
.header("X-Tenant-Id", "tenant-a")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{
|
||||
"fileName": "quota.mp4",
|
||||
"contentType": "video/mp4",
|
||||
"contentLengthBytes": 4096,
|
||||
"checksumSha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||
}
|
||||
"""))
|
||||
.andExpect(status().isCreated())
|
||||
.andReturn()
|
||||
.getResponse()
|
||||
.getContentAsString();
|
||||
|
||||
String assetId = JsonTestHelper.read(assetResponse, "$.assetId");
|
||||
String uploadId = JsonTestHelper.read(assetResponse, "$.upload.uploadId");
|
||||
|
||||
mockMvc.perform(post("/v1/video-assets/{assetId}/uploads:complete", assetId)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{
|
||||
"uploadId": "%s",
|
||||
"sourceDurationSeconds": 17,
|
||||
"parts": [
|
||||
{
|
||||
"partNumber": 1,
|
||||
"etag": "\\"etag-1\\""
|
||||
}
|
||||
]
|
||||
}
|
||||
""".formatted(uploadId)))
|
||||
.andExpect(status().isOk());
|
||||
|
||||
mockMvc.perform(post("/v1/video-assets/{assetId}/clip-jobs", assetId)
|
||||
.header("X-Tenant-Id", "tenant-a")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{
|
||||
"segmentDurationSeconds": 8,
|
||||
"accuracyMode": "FAST",
|
||||
"outputContainer": "mp4",
|
||||
"overwriteExisting": false
|
||||
}
|
||||
"""))
|
||||
.andExpect(status().isTooManyRequests())
|
||||
.andExpect(jsonPath("$.type").value("https://api.example.com/problems/quota-exceeded"));
|
||||
}
|
||||
}
|
||||
|
|
@ -41,6 +41,7 @@ class RetentionCleanupJobTest {
|
|||
);
|
||||
VideoAsset asset = new VideoAsset(
|
||||
"asset_1",
|
||||
"default",
|
||||
"source.mp4",
|
||||
"video/mp4",
|
||||
4096,
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
stub-clip-content-clip_3db7ae28dd6e
|
||||
|
|
@ -0,0 +1 @@
|
|||
stub-clip-content-clip_a87a659135b7
|
||||
|
|
@ -0,0 +1 @@
|
|||
stub-clip-content-clip_f6d9f025b976
|
||||
|
|
@ -0,0 +1 @@
|
|||
stub-clip-content-clip_10c8dc32bebf
|
||||
|
|
@ -0,0 +1 @@
|
|||
stub-clip-content-clip_04750c742dae
|
||||
|
|
@ -0,0 +1 @@
|
|||
stub-clip-content-clip_9c0062d6e046
|
||||
|
|
@ -0,0 +1 @@
|
|||
stub-clip-content-clip_6610d92757c1
|
||||
|
|
@ -0,0 +1 @@
|
|||
stub-clip-content-clip_f6612e1119d5
|
||||
|
|
@ -0,0 +1 @@
|
|||
stub-clip-content-clip_b68e31fe0da6
|
||||
|
|
@ -0,0 +1 @@
|
|||
stub-clip-content-clip_5806cd2fc91e
|
||||
|
|
@ -0,0 +1 @@
|
|||
stub-clip-content-clip_297c6a9ab956
|
||||
|
|
@ -0,0 +1 @@
|
|||
stub-clip-content-clip_8a000ebfe018
|
||||
Loading…
Reference in New Issue