mateclaw/mateclaw-server/src/test/java/vip/mate/wiki/service/WikiRawMaterialDeleteCascadeTest.java
matevip 0ffc224623 chore(test): backfill mateclaw-server/src/test/ that earlier PRIVATE_ITEMS pattern accidentally excluded
The PRIVATE_ITEMS list contained the bare 'test' entry, which rsync
interprets as 'any directory named test at any depth' — so it caught
the root-level /test/ scratch directory (intended) AND every src/test/
under each module (not intended).

Pattern is already anchored to /test (root-only). This commit rsyncs
the accumulated src/test/ tree forward so opensource has the unit tests
that have been written / updated against existing src/main/ code since
the pattern regression. Going forward each per-commit sync will carry
src/test/ files along with the main change.
2026-05-12 17:38:08 +08:00

248 lines
9.7 KiB
Java

package vip.mate.wiki.service;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.springframework.context.ApplicationEventPublisher;
import vip.mate.system.featureflag.FeatureFlagService;
import vip.mate.tool.builtin.DocumentExtractTool;
import vip.mate.tool.image.vision.ImageVisionService;
import vip.mate.wiki.WikiProperties;
import vip.mate.wiki.model.WikiRawMaterialEntity;
import vip.mate.wiki.repository.WikiRawMaterialMapper;
import java.lang.reflect.Field;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.atLeastOnce;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* Pins the cascade behavior for raw-material deletion.
*
* <p>Before this fix, {@code delete(rawId)} only removed the raw row +
* chunks. Pages generated from that raw lived on as orphans — they kept
* showing up in search, the page list was polluted, citations dangled.
* The reprocess path already cleaned them via
* {@link WikiPageService#deleteExclusiveBySourceRawId}; the delete path
* has to do the same to be symmetric.
*/
class WikiRawMaterialDeleteCascadeTest {
private WikiRawMaterialMapper rawMapper;
private WikiKnowledgeBaseService kbService;
private WikiChunkService chunkService;
private WikiPageService pageService;
private WikiRawMaterialService service;
private WikiProperties props;
private static final Long KB_ID = 100L;
private static final Long RAW_ID = 42L;
@BeforeEach
void setUp() throws Exception {
rawMapper = mock(WikiRawMaterialMapper.class);
kbService = mock(WikiKnowledgeBaseService.class);
chunkService = mock(WikiChunkService.class);
pageService = mock(WikiPageService.class);
props = new WikiProperties();
props.setAutoProcessOnUpload(false);
service = new WikiRawMaterialService(
rawMapper, kbService, props,
mock(ApplicationEventPublisher.class),
mock(DocumentExtractTool.class),
chunkService,
mock(ImageVisionService.class),
mock(PdfImageExtractor.class),
mock(FeatureFlagService.class));
// pageService is wired via @Autowired(required=false); inject manually.
Field f = WikiRawMaterialService.class.getDeclaredField("pageService");
f.setAccessible(true);
f.set(service, pageService);
}
private static WikiRawMaterialEntity raw(Long id, Long kbId) {
WikiRawMaterialEntity r = new WikiRawMaterialEntity();
r.setId(id);
r.setKbId(kbId);
return r;
}
private static WikiRawMaterialEntity rawWithPath(Long id, Long kbId, String path) {
WikiRawMaterialEntity r = raw(id, kbId);
r.setSourcePath(path);
return r;
}
@Test
@DisplayName("delete cascades to exclusive pages, raw row, and chunks — in that order")
void cascadesToPagesAndChunks() {
when(rawMapper.selectById(RAW_ID)).thenReturn(raw(RAW_ID, KB_ID));
when(pageService.deleteExclusiveBySourceRawId(KB_ID, RAW_ID)).thenReturn(3);
service.delete(RAW_ID);
// Pages first (so we can use kb_id off the still-present raw row).
verify(pageService).deleteExclusiveBySourceRawId(KB_ID, RAW_ID);
verify(rawMapper).deleteById(RAW_ID);
verify(chunkService).deleteByRawId(RAW_ID);
}
@Test
@DisplayName("delete still works when pageService bean is absent (defensive null check)")
void noPageService_stillDeletesRawAndChunks() throws Exception {
// Strip pageService back out — simulates a deployment without that bean.
Field f = WikiRawMaterialService.class.getDeclaredField("pageService");
f.setAccessible(true);
f.set(service, null);
when(rawMapper.selectById(RAW_ID)).thenReturn(raw(RAW_ID, KB_ID));
service.delete(RAW_ID);
verify(rawMapper).deleteById(RAW_ID);
verify(chunkService).deleteByRawId(RAW_ID);
// pageService is null — no method to verify, but the call must not throw.
}
@Test
@DisplayName("delete is idempotent when raw row is already gone (selectById returns null)")
void rawAlreadyDeleted_skipsPageCascade() {
when(rawMapper.selectById(RAW_ID)).thenReturn(null);
service.delete(RAW_ID);
// No raw, no kb_id, so we skip the page cascade — but still try the
// raw delete + chunk cleanup since they're keyed on rawId alone.
verify(pageService, never()).deleteExclusiveBySourceRawId(eq(KB_ID), eq(RAW_ID));
verify(rawMapper).deleteById(RAW_ID);
verify(chunkService).deleteByRawId(RAW_ID);
}
@Test
@DisplayName("page cascade failure does not block the raw + chunk delete")
void pageCascadeFails_softLogged() {
when(rawMapper.selectById(RAW_ID)).thenReturn(raw(RAW_ID, KB_ID));
when(pageService.deleteExclusiveBySourceRawId(KB_ID, RAW_ID))
.thenThrow(new RuntimeException("page mapper down"));
service.delete(RAW_ID);
// The thrown exception is caught + logged; raw + chunks still get
// cleaned. Operator sees a warn line but the delete itself succeeds.
verify(rawMapper, atLeastOnce()).deleteById(RAW_ID);
verify(chunkService, atLeastOnce()).deleteByRawId(RAW_ID);
}
@Test
@DisplayName("raw with null kbId is treated as not-cascaded (defensive)")
void rawWithNullKbId_skipsPageCascade() {
when(rawMapper.selectById(RAW_ID)).thenReturn(raw(RAW_ID, null));
service.delete(RAW_ID);
verify(pageService, never()).deleteExclusiveBySourceRawId(eq(KB_ID), eq(RAW_ID));
verify(rawMapper).deleteById(RAW_ID);
}
@Test
@DisplayName("zero pages cascaded → still proceeds (raw with 0 generated pages, e.g. failed ingest)")
void zeroPagesCascaded() {
when(rawMapper.selectById(RAW_ID)).thenReturn(raw(RAW_ID, KB_ID));
when(pageService.deleteExclusiveBySourceRawId(KB_ID, RAW_ID)).thenReturn(0);
service.delete(RAW_ID);
verify(pageService, times(1)).deleteExclusiveBySourceRawId(KB_ID, RAW_ID);
verify(rawMapper).deleteById(RAW_ID);
verify(chunkService).deleteByRawId(RAW_ID);
}
@Test
@DisplayName("delete also removes the upload file from disk (sourcePath inside uploadDir)")
void cleansSourceFile(@TempDir Path tmp) throws Exception {
// Pretend tmp is the upload directory so the source file is sandbox-eligible.
props.setUploadDir(tmp.toString());
Path file = Files.createFile(tmp.resolve("uploaded.pdf"));
Files.writeString(file, "fake pdf bytes");
Assertions.assertThat(Files.exists(file)).isTrue();
when(rawMapper.selectById(RAW_ID)).thenReturn(rawWithPath(RAW_ID, KB_ID, file.toString()));
service.delete(RAW_ID);
Assertions.assertThat(Files.exists(file))
.as("source file should be removed alongside the raw row")
.isFalse();
}
@Test
@DisplayName("delete with a missing source file is a no-op for the file (idempotent)")
void cleansSourceFile_alreadyMissing(@TempDir Path tmp) {
props.setUploadDir(tmp.toString());
// Path that never existed — cleanupFile must not throw.
String ghostPath = tmp.resolve("never-existed.pdf").toString();
when(rawMapper.selectById(RAW_ID)).thenReturn(rawWithPath(RAW_ID, KB_ID, ghostPath));
service.delete(RAW_ID);
// Reaches DB cascades + does not throw.
verify(rawMapper).deleteById(RAW_ID);
}
@Test
@DisplayName("delete must NOT touch a directory-scanned file (sourcePath outside uploadDir)")
void preservesUserOwnedScannedFile(@TempDir Path tmp) throws Exception {
// uploadDir lives under tmp/uploads — the scanned file lives elsewhere
// under tmp/user-docs, simulating a user-chosen directory imported in
// place by WikiDirectoryScanService (no copy made into the upload tree).
Path uploadDir = Files.createDirectory(tmp.resolve("uploads"));
Path userDocs = Files.createDirectory(tmp.resolve("user-docs"));
props.setUploadDir(uploadDir.toString());
Path scannedFile = Files.createFile(userDocs.resolve("important.pdf"));
Files.writeString(scannedFile, "user's original document");
when(rawMapper.selectById(RAW_ID))
.thenReturn(rawWithPath(RAW_ID, KB_ID, scannedFile.toString()));
service.delete(RAW_ID);
// The DB-side cascade still runs (raw row + chunks + pages).
verify(rawMapper).deleteById(RAW_ID);
verify(chunkService).deleteByRawId(RAW_ID);
verify(pageService).deleteExclusiveBySourceRawId(KB_ID, RAW_ID);
// But the user's local file MUST survive — it was referenced in place,
// never copied, and isn't ours to delete.
Assertions.assertThat(Files.exists(scannedFile))
.as("scanned file outside uploadDir must be preserved on raw delete")
.isTrue();
Assertions.assertThat(Files.readString(scannedFile)).isEqualTo("user's original document");
}
@Test
@DisplayName("delete with null source path skips the file cleanup branch (text-only raw)")
void cleansSourceFile_nullPath() {
when(rawMapper.selectById(RAW_ID)).thenReturn(raw(RAW_ID, KB_ID)); // no sourcePath set
service.delete(RAW_ID);
verify(rawMapper).deleteById(RAW_ID);
verify(chunkService).deleteByRawId(RAW_ID);
}
}