mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 19:23:42 +08:00
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.
248 lines
9.7 KiB
Java
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);
|
|
}
|
|
}
|