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:
parent
bcf2fa40bb
commit
067fc782a3
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue