mateclaw/mateclaw-server/src/test/java/vip/mate/wiki/service/WikiSourceWatcherServiceE2ETest.java
倪程伟 18daad79b2
feat(wiki): unify raw materials & source watcher into a Sources tab with per-KB auto-sync (#316)
* 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.
2026-06-11 09:29:26 +08:00

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");
}
}