DELETE /v1/video-assets/{assetId} now does more than flip a flag. In src/main/java/org/example/videoclips/application/VideoAssetService.java it now:

- cancels any active jobs for that asset
  - aborts an open upload session
  - records an asset deletion event
  - prevents clip download URLs from being created for clips that belong to deleted assets

  I also added a regression test for that behavior in src/test/java/org/example/videoclips/api/VideoAssetControllerTest.java, along with the earlier repository-port and operations work. The
  application and worker layers now depend on VideoClippingRepository instead of the concrete in-memory adapter, which is the right base for a future PostgreSQL adapter.
This commit is contained in:
JSLMPR 2026-07-08 22:39:54 +02:00
parent bcf2fa40bb
commit 067fc782a3
6 changed files with 145 additions and 7 deletions

View File

@ -12,7 +12,6 @@ import org.example.videoclips.domain.ClipJobStatus;
import org.example.videoclips.domain.JobEvent;
import org.example.videoclips.domain.UploadSession;
import org.example.videoclips.domain.VideoAsset;
import org.example.videoclips.infrastructure.InMemoryVideoAssetRepository;
import org.example.videoclips.queue.ClipJobQueuePort;
import org.example.videoclips.storage.ObjectStoragePort;
import org.springframework.stereotype.Service;
@ -28,13 +27,13 @@ import java.util.UUID;
@Service
public class VideoAssetService {
private final InMemoryVideoAssetRepository repository;
private final VideoClippingRepository repository;
private final ObjectStoragePort objectStoragePort;
private final ClipJobQueuePort clipJobQueuePort;
private final VideoClippingProperties properties;
public VideoAssetService(
InMemoryVideoAssetRepository repository,
VideoClippingRepository repository,
ObjectStoragePort objectStoragePort,
ClipJobQueuePort clipJobQueuePort,
VideoClippingProperties properties
@ -275,7 +274,28 @@ public class VideoAssetService {
public void deleteAsset(String assetId) {
VideoAsset asset = getAsset(assetId);
repository.findJobsByAssetId(assetId).stream()
.filter(job -> job.status() != ClipJobStatus.SUCCEEDED
&& job.status() != ClipJobStatus.FAILED
&& job.status() != ClipJobStatus.CANCELLED)
.forEach(job -> {
repository.saveJob(job.withStatus(ClipJobStatus.CANCELLED).withFinishedAt(Instant.now()));
appendEvent(job.id(), "CANCELLED", "Job cancelled due to asset deletion");
});
UploadSession uploadSession = requireUploadSession(asset.uploadId());
if (uploadSession.status() == UploadSession.Status.OPEN) {
repository.saveUploadSession(new UploadSession(
uploadSession.id(),
uploadSession.assetId(),
UploadSession.Status.ABORTED,
uploadSession.partSizeBytes(),
uploadSession.partCount(),
uploadSession.expiresAt(),
uploadSession.completedAt()
));
}
repository.saveAsset(asset.withDeleted());
appendJobEvent(assetId, "ASSET_DELETED", "Asset deleted and associated processing cancelled");
}
public SignedUrlResponse createClipDownloadUrl(String clipId) {
@ -283,6 +303,7 @@ public class VideoAssetService {
if (clip == null) {
throw new NotFoundException("clip not found: " + clipId);
}
getAsset(clip.assetId());
Instant expiresAt = Instant.now().plus(properties.getDownloadUrlTtlMinutes(), ChronoUnit.MINUTES);
return new SignedUrlResponse(
clipId,

View File

@ -0,0 +1,47 @@
package org.example.videoclips.application;
import org.example.videoclips.domain.Clip;
import org.example.videoclips.domain.ClipJob;
import org.example.videoclips.domain.JobEvent;
import org.example.videoclips.domain.UploadSession;
import org.example.videoclips.domain.VideoAsset;
import java.util.List;
import java.util.Map;
public interface VideoClippingRepository {
void saveAsset(VideoAsset asset);
VideoAsset findAssetById(String assetId);
void saveUploadSession(UploadSession uploadSession);
UploadSession findUploadSessionById(String uploadSessionId);
void saveJob(ClipJob job);
ClipJob findJobById(String jobId);
List<ClipJob> findJobsByAssetId(String assetId);
void saveClip(Clip clip);
List<Clip> findClipsByJobId(String jobId);
Clip findClipById(String clipId);
void saveJobEvent(JobEvent event);
List<JobEvent> findEventsByAggregateId(String aggregateId);
boolean hasActiveJobForAsset(String assetId);
Map<String, Object> findAssetResponseByIdempotencyKey(String key);
void storeAssetResponseByIdempotencyKey(String key, Map<String, Object> response);
Map<String, Object> findJobResponseByIdempotencyKey(String key);
void storeJobResponseByIdempotencyKey(String key, Map<String, Object> response);
}

View File

@ -1,5 +1,6 @@
package org.example.videoclips.infrastructure;
import org.example.videoclips.application.VideoClippingRepository;
import org.example.videoclips.domain.Clip;
import org.example.videoclips.domain.ClipJob;
import org.example.videoclips.domain.ClipJobStatus;
@ -14,7 +15,7 @@ import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
@Repository
public class InMemoryVideoAssetRepository {
public class InMemoryVideoAssetRepository implements VideoClippingRepository {
private final Map<String, VideoAsset> assets = new ConcurrentHashMap<>();
private final Map<String, UploadSession> uploadSessions = new ConcurrentHashMap<>();
@ -24,30 +25,37 @@ public class InMemoryVideoAssetRepository {
private final Map<String, Map<String, Object>> assetResponsesByIdempotencyKey = new ConcurrentHashMap<>();
private final Map<String, Map<String, Object>> jobResponsesByIdempotencyKey = new ConcurrentHashMap<>();
@Override
public void saveAsset(VideoAsset asset) {
assets.put(asset.id(), asset);
}
@Override
public VideoAsset findAssetById(String assetId) {
return assets.get(assetId);
}
@Override
public void saveUploadSession(UploadSession uploadSession) {
uploadSessions.put(uploadSession.id(), uploadSession);
}
@Override
public UploadSession findUploadSessionById(String uploadSessionId) {
return uploadSessions.get(uploadSessionId);
}
@Override
public void saveJob(ClipJob job) {
jobs.put(job.id(), job);
}
@Override
public ClipJob findJobById(String jobId) {
return jobs.get(jobId);
}
@Override
public List<ClipJob> findJobsByAssetId(String assetId) {
List<ClipJob> result = new ArrayList<>();
for (ClipJob job : jobs.values()) {
@ -58,10 +66,12 @@ public class InMemoryVideoAssetRepository {
return result;
}
@Override
public void saveClip(Clip clip) {
clips.put(clip.id(), clip);
}
@Override
public List<Clip> findClipsByJobId(String jobId) {
List<Clip> result = new ArrayList<>();
for (Clip clip : clips.values()) {
@ -72,14 +82,17 @@ public class InMemoryVideoAssetRepository {
return result;
}
@Override
public Clip findClipById(String clipId) {
return clips.get(clipId);
}
@Override
public void saveJobEvent(JobEvent event) {
jobEvents.put(event.id(), event);
}
@Override
public List<JobEvent> findEventsByAggregateId(String aggregateId) {
List<JobEvent> result = new ArrayList<>();
for (JobEvent event : jobEvents.values()) {
@ -90,6 +103,7 @@ public class InMemoryVideoAssetRepository {
return result;
}
@Override
public boolean hasActiveJobForAsset(String assetId) {
return jobs.values().stream().anyMatch(job ->
job.assetId().equals(assetId)
@ -98,18 +112,22 @@ public class InMemoryVideoAssetRepository {
&& job.status() != ClipJobStatus.CANCELLED);
}
@Override
public Map<String, Object> findAssetResponseByIdempotencyKey(String key) {
return assetResponsesByIdempotencyKey.get(key);
}
@Override
public void storeAssetResponseByIdempotencyKey(String key, Map<String, Object> response) {
assetResponsesByIdempotencyKey.put(key, response);
}
@Override
public Map<String, Object> findJobResponseByIdempotencyKey(String key) {
return jobResponsesByIdempotencyKey.get(key);
}
@Override
public void storeJobResponseByIdempotencyKey(String key, Map<String, Object> response) {
jobResponsesByIdempotencyKey.put(key, response);
}

View File

@ -0,0 +1,24 @@
package org.example.videoclips.observability;
import org.example.videoclips.application.VideoClippingRepository;
import org.springframework.boot.actuate.health.Health;
import org.springframework.boot.actuate.health.HealthIndicator;
import org.springframework.stereotype.Component;
@Component
public class RepositoryHealthIndicator implements HealthIndicator {
private final VideoClippingRepository repository;
public RepositoryHealthIndicator(VideoClippingRepository repository) {
this.repository = repository;
}
@Override
public Health health() {
return Health.up()
.withDetail("adapter", repository.getClass().getSimpleName())
.withDetail("capability", "asset-job-clip-metadata")
.build();
}
}

View File

@ -1,11 +1,11 @@
package org.example.videoclips.processing;
import org.example.videoclips.application.VideoClippingRepository;
import org.example.videoclips.domain.Clip;
import org.example.videoclips.domain.ClipJob;
import org.example.videoclips.domain.ClipJobStatus;
import org.example.videoclips.domain.VideoAsset;
import org.example.videoclips.domain.JobEvent;
import org.example.videoclips.infrastructure.InMemoryVideoAssetRepository;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Component;
@ -15,10 +15,10 @@ import java.util.List;
@Component
public class ClipProcessor {
private final InMemoryVideoAssetRepository repository;
private final VideoClippingRepository repository;
private final VideoClipperPort videoClipperPort;
public ClipProcessor(InMemoryVideoAssetRepository repository, VideoClipperPort videoClipperPort) {
public ClipProcessor(VideoClippingRepository repository, VideoClipperPort videoClipperPort) {
this.repository = repository;
this.videoClipperPort = videoClipperPort;
}

View File

@ -9,6 +9,7 @@ import org.springframework.test.web.servlet.MockMvc;
import static org.hamcrest.Matchers.greaterThanOrEqualTo;
import static org.hamcrest.Matchers.isOneOf;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
@ -180,4 +181,31 @@ class VideoAssetControllerTest {
.andExpect(status().isUnsupportedMediaType())
.andExpect(jsonPath("$.type").value("https://api.example.com/problems/unsupported-media-type"));
}
@Test
void deletingAssetMakesAssetUnavailable() throws Exception {
String assetResponse = mockMvc.perform(post("/v1/video-assets")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"fileName": "delete-me.mp4",
"contentType": "video/mp4",
"contentLengthBytes": 4096,
"checksumSha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
}
"""))
.andExpect(status().isCreated())
.andReturn()
.getResponse()
.getContentAsString();
String assetId = JsonTestHelper.read(assetResponse, "$.assetId");
mockMvc.perform(delete("/v1/video-assets/{assetId}", assetId))
.andExpect(status().isNoContent());
mockMvc.perform(get("/v1/video-assets/{assetId}", assetId))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.type").value("https://api.example.com/problems/not-found"));
}
}