🐛 fix: store listener leak, friends cap, auth resilience

This commit is contained in:
2026-07-03 04:27:20 +07:00
parent f5a7bdc778
commit 35f17c9ac7
33 changed files with 733 additions and 317 deletions
+35
View File
@@ -0,0 +1,35 @@
import { describe, it, expect } from "vitest";
import { fillSlots, normalizeVisibility, orderSlots } from "../src/main/vrchat/favoriteFolders";
const folder = (name: string) => ({ name });
describe("normalizeVisibility", () => {
it("passes through known values and defaults the rest to private", () => {
expect(normalizeVisibility("friends")).toBe("friends");
expect(normalizeVisibility("public")).toBe("public");
expect(normalizeVisibility("private")).toBe("private");
expect(normalizeVisibility("whatever")).toBe("private");
});
});
describe("orderSlots", () => {
it("puts custom-named folders before numbered slots, slots sorted numerically", () => {
const out = orderSlots(
[folder("worlds10"), folder("cool stuff"), folder("worlds2"), folder("worlds1")],
"worlds",
);
expect(out.map((f) => f.name)).toEqual(["cool stuff", "worlds1", "worlds2", "worlds10"]);
});
});
describe("fillSlots", () => {
it("pads with empty numbered slots up to the cap, skipping taken names", () => {
const out = fillSlots([folder("worlds2")], "worlds", 3, folder);
expect(out.map((f) => f.name)).toEqual(["worlds2", "worlds1", "worlds3"]);
});
it("does not pad past the cap when custom folders use up slots", () => {
const out = fillSlots([folder("a"), folder("b")], "worlds", 2, folder);
expect(out.map((f) => f.name)).toEqual(["a", "b"]);
});
});
+116
View File
@@ -0,0 +1,116 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import {
moveOneFavorite,
moveManyFavorites,
type FavoriteMover,
} from "../src/main/vrchat/favoriteMove";
interface Rec {
id: string;
favoriteId: string;
tags: string[];
}
function mover(overrides: Partial<FavoriteMover<Rec>> = {}) {
const m = {
skip: vi.fn((rec: Rec | undefined, folder: string) => rec?.tags.includes(folder) ?? false),
canRefavorite: vi.fn(async () => true),
remove: vi.fn(async () => {}),
add: vi.fn(async () => {}),
restore: vi.fn(async () => {}),
reload: vi.fn(async () => {}),
onMoved: vi.fn(),
...overrides,
};
return m;
}
const rec = (id: string, tags: string[]): Rec => ({ id: `fav-${id}`, favoriteId: id, tags });
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
describe("moveOneFavorite", () => {
it("moves: remove, add, reload, notify", async () => {
const m = mover();
const result = await moveOneFavorite(m, rec("w1", ["old"]), "w1", "new", true);
expect(result).toEqual({ moved: 1, skipped: [] });
expect(m.remove).toHaveBeenCalledOnce();
expect(m.add).toHaveBeenCalledWith("w1", "new");
expect(m.reload).toHaveBeenCalledOnce();
expect(m.onMoved).toHaveBeenCalledWith("w1", "new");
});
it("no-ops when already in the folder", async () => {
const m = mover();
const result = await moveOneFavorite(m, rec("w1", ["new"]), "w1", "new", true);
expect(result).toEqual({ moved: 0, skipped: [] });
expect(m.remove).not.toHaveBeenCalled();
});
it("skips without removing when the item can't be re-favorited", async () => {
const m = mover({ canRefavorite: vi.fn(async () => false) });
const result = await moveOneFavorite(m, rec("w1", ["old"]), "w1", "new", true);
expect(result).toEqual({ moved: 0, skipped: ["w1"] });
expect(m.remove).not.toHaveBeenCalled();
});
it("restores the old favorite when add fails", async () => {
const m = mover({ add: vi.fn(async () => Promise.reject({ status: 400 })) });
const old = rec("w1", ["old"]);
const result = await moveOneFavorite(m, old, "w1", "new", true);
expect(result).toEqual({ moved: 0, skipped: ["w1"] });
expect(m.restore).toHaveBeenCalledWith(old);
expect(m.onMoved).not.toHaveBeenCalled();
});
it("rethrows transient failures after restoring", async () => {
const m = mover({ add: vi.fn(async () => Promise.reject({ status: 429 })) });
await expect(moveOneFavorite(m, rec("w1", ["old"]), "w1", "new", true)).rejects.toMatchObject({
status: 429,
});
expect(m.restore).toHaveBeenCalledOnce();
});
});
describe("moveManyFavorites", () => {
it("moves everything movable and reports the rest as skipped", async () => {
const m = mover({
canRefavorite: vi.fn(async (id: string) => id !== "w3"),
});
const records = new Map([
["w1", rec("w1", ["old"])],
["w2", rec("w2", ["new"])],
["w3", rec("w3", ["old"])],
]);
const done = moveManyFavorites(m, records, ["w1", "w2", "w3", "w4"], "new");
await vi.runAllTimersAsync();
const result = await done;
expect(result).toEqual({ moved: 2, skipped: ["w3"] });
expect(m.add).toHaveBeenCalledWith("w1", "new");
expect(m.add).toHaveBeenCalledWith("w4", "new");
expect(m.reload).toHaveBeenCalledOnce();
});
it("aborts on a transient failure after restoring the current item", async () => {
const m = mover({
add: vi.fn(async (id: string) => {
if (id === "w2") throw { status: 500 };
}),
});
const records = new Map([
["w1", rec("w1", ["old"])],
["w2", rec("w2", ["old"])],
]);
const done = moveManyFavorites(m, records, ["w1", "w2"], "new");
done.catch(() => {});
await vi.runAllTimersAsync();
await expect(done).rejects.toMatchObject({ status: 500 });
expect(m.restore).toHaveBeenCalledWith(records.get("w2"));
expect(m.reload).toHaveBeenCalledOnce();
});
});
+116
View File
@@ -0,0 +1,116 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { Repository } from "../src/main/store/repository/repository";
import type { StorageBackend } from "../src/main/store/repository/backend";
import type { StoredEntity } from "../src/shared/types/repository";
import type { FieldPolicy } from "../src/main/store/repository/fieldPolicy";
interface Thing {
id: string;
name: string;
bio?: string;
occupants?: number;
}
function memoryBackend<T>(): StorageBackend<T> {
const map = new Map<string, StoredEntity<T>>();
return {
load: () => new Map(map),
put: (id, e) => void map.set(id, e),
remove: (id) => void map.delete(id),
flush: () => {},
clear: () => map.clear(),
file: null,
};
}
const policy: FieldPolicy<Thing> = {
classOf: (f) => (f === "occupants" || f === "bio" ? "live" : "identity"),
maxAge: { identity: 60_000, stat: 60_000, live: 1000 },
keepNonEmpty: new Set(["bio"]),
};
function makeRepo() {
return new Repository<Thing>({ name: "things", policy, backend: memoryBackend() });
}
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
describe("Repository.upsert — source priority on live fields", () => {
it("a lower-priority source cannot overwrite a live field set by ws", () => {
const repo = makeRepo();
repo.upsert({ id: "a", name: "A", occupants: 5 }, "ws");
vi.advanceTimersByTime(10);
repo.upsert({ id: "a", occupants: 1 }, "rest:list");
expect(repo.get("a")?.occupants).toBe(5);
});
it("equal-priority live writes apply when newer", () => {
const repo = makeRepo();
repo.upsert({ id: "a", name: "A", occupants: 5 }, "ws");
vi.advanceTimersByTime(10);
repo.upsert({ id: "a", occupants: 9 }, "ws");
expect(repo.get("a")?.occupants).toBe(9);
});
});
describe("Repository.upsert — timestamp ordering", () => {
it("ignores writes older than the stored field", () => {
const repo = makeRepo();
const now = Date.now();
repo.upsert({ id: "a", name: "new" }, "rest:detail", now);
repo.upsert({ id: "a", name: "old" }, "rest:detail", now - 1000);
expect(repo.get("a")?.name).toBe("new");
});
});
describe("Repository.upsert — empty-value guards", () => {
it("an empty identity value never wipes a resolved one", () => {
const repo = makeRepo();
repo.upsert({ id: "a", name: "Resolved" }, "rest:detail");
vi.advanceTimersByTime(10);
repo.upsert({ id: "a", name: "" }, "rest:detail");
expect(repo.get("a")?.name).toBe("Resolved");
});
it("keepNonEmpty fields survive empty writes from lower-priority sources", () => {
const repo = makeRepo();
repo.upsert({ id: "a", name: "A", bio: "hello" }, "rest:detail");
vi.advanceTimersByTime(10);
repo.upsert({ id: "a", bio: "" }, "rest:list");
expect(repo.get("a")?.bio).toBe("hello");
});
it("a rest:detail read may clear a keepNonEmpty field", () => {
const repo = makeRepo();
repo.upsert({ id: "a", name: "A", bio: "hello" }, "rest:detail");
vi.advanceTimersByTime(10);
repo.upsert({ id: "a", bio: "" }, "rest:detail");
expect(repo.get("a")?.bio).toBe("");
});
});
describe("Repository.upsert — change notification", () => {
it("emits on real changes and stays silent on no-op writes", () => {
const repo = makeRepo();
const seen = vi.fn();
repo.onChange(seen);
repo.upsert({ id: "a", name: "A" }, "rest:detail");
expect(seen).toHaveBeenCalledTimes(1);
vi.advanceTimersByTime(10);
repo.upsert({ id: "a", name: "" }, "rest:detail");
expect(seen).toHaveBeenCalledTimes(1);
});
});
describe("Repository.isStale", () => {
it("reports live fields stale after their max age and identity fields fresh", () => {
const repo = makeRepo();
repo.upsert({ id: "a", name: "A", occupants: 3 }, "ws");
vi.advanceTimersByTime(2000);
expect(repo.isStale("a", "occupants")).toBe(true);
expect(repo.isStale("a", "name")).toBe(false);
});
});