mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 11:13:43 +08:00
* feat(wiki): unify raw materials & source watcher into a Sources tab with per-KB auto-sync The raw-material directory scan and the Advanced "source watcher" sub-tab were the same engine (same kb.sourceDirectory, same WikiDirectoryScanService) split across two surfaces with two editable directory inputs. Merge them into one "Sources" tab (upload / paste / directory manual scan + auto-sync toggle + the raw-material list) and drop the watcher sub-tab from Advanced. Auto-sync is now per-KB opt-in: a new watcher_enabled column (V146) gates the periodic scan per knowledge base. The server-global mate.wiki.watcher-enabled stays as an ops master switch — a KB is auto-scanned only when both are on (AND). Manual scans are unaffected. Scan interval stays global for now (tracked separately). Closes matevip/mateclaw#314 * docs(wiki): document source-watcher global switch env vars Expose MATE_WIKI_WATCHER_ENABLED / MATE_WIKI_WATCHER_INTERVAL_MS as explicit placeholders in application-mysql.yml, .env.example and docker-compose.yml, mirroring MATE_WIKI_ALLOWED_SOURCE_ROOTS. Notes the AND semantics (global ops gate + per-KB toggle) so operators know the global switch alone is not sufficient.
136 lines
6.1 KiB
Java
136 lines
6.1 KiB
Java
package vip.mate.wiki.service;
|
|
|
|
import org.junit.jupiter.api.Test;
|
|
import org.junit.jupiter.api.io.TempDir;
|
|
import org.springframework.beans.factory.annotation.Autowired;
|
|
import org.springframework.boot.test.context.SpringBootTest;
|
|
import vip.mate.wiki.model.WikiKnowledgeBaseEntity;
|
|
|
|
import java.io.IOException;
|
|
import java.nio.file.Files;
|
|
import java.nio.file.Path;
|
|
|
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
|
|
|
/**
|
|
* End-to-end test of the source watcher's scan cycle against H2: new files in a
|
|
* KB's source directory are auto-ingested, and a re-scan is idempotent (dedup
|
|
* by source path). Auto-processing is disabled so the test stays model-free.
|
|
*/
|
|
@SpringBootTest(
|
|
webEnvironment = SpringBootTest.WebEnvironment.NONE,
|
|
properties = {
|
|
"spring.flyway.enabled=true",
|
|
"spring.flyway.locations=classpath:db/migration/h2",
|
|
"mateclaw.feature-flag.refresh-ms=999999",
|
|
"mate.wiki.auto-process-on-upload=false"
|
|
}
|
|
)
|
|
class WikiSourceWatcherServiceE2ETest {
|
|
|
|
@Autowired
|
|
private WikiSourceWatcherService watcherService;
|
|
@Autowired
|
|
private WikiKnowledgeBaseService kbService;
|
|
@Autowired
|
|
private WikiDirectoryScanService scanService;
|
|
@Autowired
|
|
private WikiRawMaterialService rawMaterialService;
|
|
|
|
private static final java.util.concurrent.atomic.AtomicLong SEQ =
|
|
new java.util.concurrent.atomic.AtomicLong(System.nanoTime());
|
|
|
|
@Test
|
|
void scanCycleIngestsNewFiles_thenDedups(@TempDir Path sourceDir) throws IOException {
|
|
Files.writeString(sourceDir.resolve("note-a.md"), "# Note A\n\ncontent a");
|
|
Files.writeString(sourceDir.resolve("note-b.md"), "# Note B\n\ncontent b");
|
|
|
|
WikiKnowledgeBaseEntity kb = kbService.create(
|
|
"watcher-" + SEQ.incrementAndGet(), "test", null);
|
|
kbService.updateSourceDirectory(kb.getId(), sourceDir.toString());
|
|
// Auto-sync is per-KB opt-in; enable it so the cycle scans this KB.
|
|
kbService.updateWatcherEnabled(kb.getId(), true);
|
|
|
|
// First cycle ingests both new files.
|
|
int firstAdded = watcherService.runScanCycle();
|
|
assertTrue(firstAdded >= 2, "expected >= 2 new files, got " + firstAdded);
|
|
|
|
// A new file appears; the next cycle ingests only it (existing files dedup).
|
|
Files.writeString(sourceDir.resolve("note-c.md"), "# Note C\n\ncontent c");
|
|
int secondAdded = watcherService.runScanCycle();
|
|
assertEquals(1, secondAdded, "only the newly added file should ingest");
|
|
|
|
// Re-scanning with no changes ingests nothing.
|
|
assertEquals(0, watcherService.runScanCycle(), "unchanged files must not re-ingest");
|
|
|
|
// Modifying an existing file's content re-ingests it (content hash changed).
|
|
Files.writeString(sourceDir.resolve("note-a.md"), "# Note A\n\nEDITED content a");
|
|
int afterEdit = watcherService.runScanCycle();
|
|
assertEquals(1, afterEdit, "a modified file must be re-ingested");
|
|
}
|
|
|
|
@Test
|
|
void symlinkFileEscapingScanRoot_isNotIngested(@TempDir Path sourceDir, @TempDir Path outside)
|
|
throws java.io.IOException {
|
|
Files.writeString(sourceDir.resolve("real.md"), "# Real\n\nlocal content");
|
|
Path secret = Files.writeString(outside.resolve("secret.md"), "TOP SECRET OUTSIDE");
|
|
Path link = sourceDir.resolve("leak.md");
|
|
try {
|
|
Files.createSymbolicLink(link, secret);
|
|
} catch (UnsupportedOperationException | java.io.IOException e) {
|
|
return; // filesystem without symlink support — skip
|
|
}
|
|
|
|
long kb = SEQ.incrementAndGet();
|
|
WikiDirectoryScanService.ScanResult result = scanService.scanDirectory(kb, sourceDir.toString());
|
|
|
|
// Only the real file is ingested; the symlink escaping the root is skipped.
|
|
assertEquals(1, result.added(), "symlinked file pointing outside the root must not be ingested");
|
|
assertTrue(result.skipped() >= 1 || !result.errors().isEmpty(),
|
|
"the escaping symlink should be reported as skipped");
|
|
}
|
|
|
|
@Test
|
|
void modifiedBinaryFile_isReingested(@TempDir Path sourceDir) throws java.io.IOException {
|
|
Path pdf = sourceDir.resolve("doc.pdf");
|
|
Files.write(pdf, "PDF-VERSION-ONE-bytes".getBytes());
|
|
long kb = SEQ.incrementAndGet();
|
|
|
|
assertEquals(1, scanService.scanDirectory(kb, sourceDir.toString()).added());
|
|
// Unchanged binary re-scan ingests nothing.
|
|
assertEquals(0, scanService.scanDirectory(kb, sourceDir.toString()).added());
|
|
// Changed bytes -> different content hash -> re-ingested.
|
|
Files.write(pdf, "PDF-VERSION-TWO-different-bytes".getBytes());
|
|
assertEquals(1, scanService.scanDirectory(kb, sourceDir.toString()).added(),
|
|
"a modified binary file must be re-ingested");
|
|
}
|
|
|
|
@Test
|
|
void kbsWithoutSourceDirectory_areSkipped() {
|
|
// A KB with no source directory must not cause errors in the cycle.
|
|
kbService.create("nodir-" + SEQ.incrementAndGet(), "test", null);
|
|
// Should complete without throwing (count is non-negative).
|
|
assertTrue(watcherService.runScanCycle() >= 0);
|
|
}
|
|
|
|
@Test
|
|
void disabledKb_isNotAutoScanned_untilEnabled(@TempDir Path sourceDir) throws IOException {
|
|
Files.writeString(sourceDir.resolve("note.md"), "# Note\n\ncontent");
|
|
WikiKnowledgeBaseEntity kb = kbService.create(
|
|
"watcher-off-" + SEQ.incrementAndGet(), "test", null);
|
|
kbService.updateSourceDirectory(kb.getId(), sourceDir.toString());
|
|
// watcher_enabled defaults to 0 → the auto cycle must skip this KB.
|
|
|
|
watcherService.runScanCycle();
|
|
assertEquals(0, rawMaterialService.listByKbId(kb.getId()).size(),
|
|
"a KB with auto-sync disabled must not be auto-scanned");
|
|
|
|
// Once enabled, the same cycle ingests its file.
|
|
kbService.updateWatcherEnabled(kb.getId(), true);
|
|
watcherService.runScanCycle();
|
|
assertTrue(rawMaterialService.listByKbId(kb.getId()).size() >= 1,
|
|
"after enabling auto-sync the KB's file should be ingested");
|
|
}
|
|
}
|