Files
curriculum-project-hub/hub/test/unit/filelib-version-store.test.ts
T
82241afb56 feat(filelib)!: VersionStore 改为真 git,一项目一仓库
内存 store 换成 gitVersionStore:init 建目录并 git init,VersionId 是
commit hash,某文件的版本取 `git log -1 -- <path>`(D16 文件级版本不因
别的文件提交而失效)。删除也是一个 commit,旧版本仍可读。决策见 ADR-0030。

git 用 execFile 调系统二进制,不引依赖。每次调用钉死 --git-dir/--work-tree
并禁 hooks、隔离全局 gitconfig:项目仓库是老师上传的数据,而 storage root
默认就在本 repo 内,不钉死会让命令落到外层仓库上。

同时:
- 单文件上限改为 HUB_FILELIB_MAX_FILE_BYTES(缺省 10MiB),前端从
  /database/config 读,不再两处硬编码
- commit 身份 name=displayName、email=<userId>@filelib.paradigm-edu.net;
  message 缺省为「【用户名】修改了【路径】」,调用方显式传则优先
- 上传改走弹窗,路径与 commit 信息可手填(原先 prompt 只能填路径)

BREAKING CHANGE: VersionId 由计数器(v1/v2)变为 commit hash;
CommitRequest.author 由字符串变为 { userId, displayName? }。
旧 .version-store.json 不迁移,此前建的项目报 repo_not_found。
2026-07-27 15:55:01 +08:00

125 lines
5.9 KiB
TypeScript

/**
* InMemory VersionStore 语义单测(契约 C1:S1/S2/S7 + 计划 D16/S4)。
* mock 必须如实表达:冲突走返回值、baseVersion=null 表新建、init 幂等、
* 文件级版本互不影响、同仓库写操作串行。
*/
import { describe, expect, it } from "vitest";
import { createInMemoryVersionStore } from "../../src/database/filelib/versionStore.js";
import { FileLibError } from "../../src/database/filelib/model.js";
const DIR = "/repos/p1";
describe("InMemoryVersionStore · C1 语义", () => {
it("init 幂等(S7);未 init 的仓库操作报 repo_not_found", async () => {
const store = createInMemoryVersionStore();
await expect(store.head(DIR, "a.md")).rejects.toThrowError(FileLibError);
await store.init(DIR);
await store.init(DIR); // 幂等,不报错、不重建
await store.commit(DIR, "a.md", { baseVersion: null, content: "hello" });
await store.init(DIR); // 已有内容后再 init 也不清空
expect(await store.head(DIR, "a.md")).toBe("v1");
});
it("新建(baseVersion null)→ ok;head/read 命中", async () => {
const store = createInMemoryVersionStore();
await store.init(DIR);
const r = await store.commit(DIR, "a.md", {
baseVersion: null,
content: "v1 内容",
author: { userId: "u1" },
});
expect(r).toEqual({ status: "ok", version: "v1" });
expect((await store.read(DIR, "a.md")).toString()).toBe("v1 内容");
});
it("对已存在文件再次『新建』 → conflict(S2)", async () => {
const store = createInMemoryVersionStore();
await store.init(DIR);
await store.commit(DIR, "a.md", { baseVersion: null, content: "1" });
const r = await store.commit(DIR, "a.md", { baseVersion: null, content: "2" });
expect(r).toEqual({ status: "conflict", currentVersion: "v1" });
});
it("baseVersion 落后于当前 → conflict 并带 currentVersion(S1)", async () => {
const store = createInMemoryVersionStore();
await store.init(DIR);
await store.commit(DIR, "a.md", { baseVersion: null, content: "1" });
const ok = await store.commit(DIR, "a.md", { baseVersion: "v1", content: "2" });
expect(ok.status).toBe("ok");
const stale = await store.commit(DIR, "a.md", { baseVersion: "v1", content: "3" });
expect(stale).toEqual({ status: "conflict", currentVersion: "v2" });
});
it("正确 base 连续提交;旧版本仍可读", async () => {
const store = createInMemoryVersionStore();
await store.init(DIR);
await store.commit(DIR, "a.md", { baseVersion: null, content: "一" });
await store.commit(DIR, "a.md", { baseVersion: "v1", content: "二" });
expect((await store.read(DIR, "a.md", "v1")).toString()).toBe("一");
expect((await store.read(DIR, "a.md")).toString()).toBe("二");
});
it("D16 文件级版本:文件 A 的提交不影响文件 B 的 base", async () => {
const store = createInMemoryVersionStore();
await store.init(DIR);
await store.commit(DIR, "a.md", { baseVersion: null, content: "A1" });
const b1 = await store.commit(DIR, "b.md", { baseVersion: null, content: "B1" });
await store.commit(DIR, "a.md", { baseVersion: "v1", content: "A2" }); // A 前进到 v3
// B 仍以自己的 head 为 base,不受 A 推进影响
const b2 = await store.commit(DIR, "b.md", {
baseVersion: b1.status === "ok" ? b1.version : "",
content: "B2",
});
expect(b2.status).toBe("ok");
});
it("remove:正确 base → ok;head 随后 404;stale base → conflict", async () => {
const store = createInMemoryVersionStore();
await store.init(DIR);
await store.commit(DIR, "a.md", { baseVersion: null, content: "1" });
await store.commit(DIR, "a.md", { baseVersion: "v1", content: "2" });
expect(await store.remove(DIR, "a.md", "v1")).toEqual({ status: "conflict", currentVersion: "v2" });
expect((await store.remove(DIR, "a.md", "v2")).status).toBe("ok");
await expect(store.head(DIR, "a.md")).rejects.toThrowError(FileLibError);
await expect(store.remove(DIR, "a.md", "v3")).rejects.toThrowError(FileLibError);
});
it("history 新→旧,支持 limit;list 排除已删并按前缀过滤", async () => {
const store = createInMemoryVersionStore();
await store.init(DIR);
await store.commit(DIR, "docs/a.md", { baseVersion: null, content: "1", message: "初版" });
await store.commit(DIR, "docs/a.md", { baseVersion: "v1", content: "2", message: "二版" });
await store.commit(DIR, "other/b.md", { baseVersion: null, content: "x" });
const h = await store.history(DIR, "docs/a.md");
expect(h.map((v) => v.message)).toEqual(["二版", "初版"]);
expect((await store.history(DIR, "docs/a.md", 1)).map((v) => v.message)).toEqual(["二版"]);
expect((await store.list(DIR)).map((f) => f.path)).toEqual(["docs/a.md", "other/b.md"]);
expect((await store.list(DIR, "docs/")).map((f) => f.path)).toEqual(["docs/a.md"]);
await store.remove(DIR, "other/b.md", "v3");
expect((await store.list(DIR)).map((f) => f.path)).toEqual(["docs/a.md"]);
});
it("S4 同仓库写串行:并发同 base 提交,恰好一成一冲突", async () => {
const store = createInMemoryVersionStore();
await store.init(DIR);
await store.commit(DIR, "a.md", { baseVersion: null, content: "1" });
const [r1, r2] = await Promise.all([
store.commit(DIR, "a.md", { baseVersion: "v1", content: "x" }),
store.commit(DIR, "a.md", { baseVersion: "v1", content: "y" }),
]);
const statuses = [r1.status, r2.status].sort();
expect(statuses).toEqual(["conflict", "ok"]);
});
it("diff 输出含增删行", async () => {
const store = createInMemoryVersionStore();
await store.init(DIR);
await store.commit(DIR, "a.md", { baseVersion: null, content: "keep\nold" });
await store.commit(DIR, "a.md", { baseVersion: "v1", content: "keep\nnew" });
const d = await store.diff(DIR, "a.md", "v1", "v2");
expect(d).toContain("-old");
expect(d).toContain("+new");
expect(d).toContain(" keep");
});
});