Files
star-chart-search-enhancer/tests/favorites-store.test.ts
T

358 lines
10 KiB
TypeScript

import { describe, expect, test } from "vitest";
import {
FAVORITES_STORAGE_KEY,
createChromeFavoritesStorage,
createFavoritesRepository,
type FavoritesStateV1,
type FavoritesStorage
} from "../src/content/market/favorites-store";
function createMemoryStorage(initial: unknown = undefined): FavoritesStorage {
let value = initial;
return {
async get() {
return value;
},
async set(next) {
value = next;
}
};
}
function createRepository(storage: FavoritesStorage = createMemoryStorage()) {
let id = 0;
return {
repository: createFavoritesRepository({
storage,
now: () => "2026-07-17T00:00:00.000Z",
createId: () => `folder-${++id}`
}),
storage
};
}
describe("favorites-store", () => {
test("returns an empty versioned state when storage is empty", async () => {
const { repository } = createRepository();
await expect(repository.read()).resolves.toEqual({
creators: [],
folders: [],
memberships: [],
version: 1
});
});
test("trims folder names and rejects blank folder names", async () => {
const { repository } = createRepository();
await expect(repository.createFolder(" 工作收藏 ")).resolves.toMatchObject({
id: "folder-1",
name: "工作收藏"
});
await expect(repository.createFolder(" \n ")).rejects.toThrow("请输入收藏夹名称");
await expect(repository.renameFolder("folder-1", "")).rejects.toThrow(
"请输入收藏夹名称"
);
});
test("rejects unknown folders, blank creators, and unknown creator folder ids", async () => {
const { repository } = createRepository();
await expect(repository.renameFolder("missing", "合作")).rejects.toThrow(
"收藏夹不存在"
);
await expect(repository.deleteFolder("missing")).rejects.toThrow("收藏夹不存在");
await expect(
repository.setCreatorFolderIds({ authorId: " ", authorName: "Alice" }, [])
).rejects.toThrow("无法收藏该达人");
await expect(
repository.setCreatorFolderIds(
{ authorId: "author-1", authorName: "Alice" },
["missing"]
)
).rejects.toThrow("收藏夹不存在");
});
test("keeps multi-folder memberships unique and preserves savedAt on repeat saves", async () => {
const { repository } = createRepository();
const firstFolder = await repository.createFolder("合作");
const secondFolder = await repository.createFolder("待联系");
await repository.setCreatorFolderIds(
{ authorId: "author-1", authorName: "Alice" },
[firstFolder.id, firstFolder.id, secondFolder.id]
);
const firstSave = await repository.read();
await repository.setCreatorFolderIds(
{
authorId: "author-1",
authorName: "Alice Updated",
coreUserId: "core-1"
},
[secondFolder.id, firstFolder.id]
);
await expect(repository.read()).resolves.toMatchObject({
creators: [
{
authorId: "author-1",
authorName: "Alice Updated",
coreUserId: "core-1",
savedAt: firstSave.creators[0]?.savedAt
}
],
memberships: [
{ authorId: "author-1", folderId: firstFolder.id },
{ authorId: "author-1", folderId: secondFolder.id }
]
});
});
test("deletes only one folder membership before pruning a final creator orphan", async () => {
const { repository } = createRepository();
const firstFolder = await repository.createFolder("合作");
const secondFolder = await repository.createFolder("待联系");
await repository.setCreatorFolderIds(
{ authorId: "author-1", authorName: "Alice" },
[firstFolder.id, secondFolder.id]
);
await repository.deleteFolder(firstFolder.id);
await expect(repository.read()).resolves.toMatchObject({
creators: [{ authorId: "author-1" }],
memberships: [{ authorId: "author-1", folderId: secondFolder.id }]
});
await repository.deleteFolder(secondFolder.id);
await expect(repository.read()).resolves.toEqual({
creators: [],
folders: [],
memberships: [],
version: 1
});
});
test("rejects removal from an unknown folder without changing state", async () => {
const { repository } = createRepository();
const folder = await repository.createFolder("合作");
await repository.setCreatorFolderIds(
{ authorId: "author-1", authorName: "Alice" },
[folder.id]
);
const beforeRemoval = await repository.read();
await expect(
repository.removeCreatorFromFolder("author-1", "missing")
).rejects.toThrow("收藏夹不存在");
await expect(repository.read()).resolves.toEqual(beforeRemoval);
});
test("recovers from malformed, duplicate, and dangling stored values", async () => {
const { repository } = createRepository(
createMemoryStorage({
version: 1,
folders: [
{
id: "folder-1",
name: " 重点 ",
createdAt: "created",
updatedAt: "updated"
},
{
id: "folder-1",
name: "重复",
createdAt: "created-2",
updatedAt: "updated-2"
},
{ id: "", name: "无效", createdAt: "created", updatedAt: "updated" }
],
creators: [
{ authorId: "author-1", authorName: "Alice", savedAt: "saved" },
{
authorId: "author-1",
authorName: "",
coreUserId: "core-1",
savedAt: "newer"
},
{ authorId: "orphan", authorName: "Orphan", savedAt: "saved" }
],
memberships: [
{ authorId: "author-1", folderId: "folder-1", addedAt: "added" },
{ authorId: "author-1", folderId: "folder-1", addedAt: "duplicate" },
{ authorId: "missing", folderId: "folder-1", addedAt: "dangling" },
{ authorId: "author-1", folderId: "missing", addedAt: "dangling" },
null
]
})
);
await expect(repository.read()).resolves.toEqual({
version: 1,
folders: [
{
id: "folder-1",
name: "重点",
createdAt: "created",
updatedAt: "updated"
}
],
creators: [
{
authorId: "author-1",
authorName: "Alice",
coreUserId: "core-1",
savedAt: "saved"
}
],
memberships: [
{ authorId: "author-1", folderId: "folder-1", addedAt: "added" }
]
});
});
test("treats a future stored version as an empty V1 state", async () => {
const { repository } = createRepository(
createMemoryStorage({
version: 2,
folders: [
{
id: "future-folder",
name: "未来收藏夹",
createdAt: "future",
updatedAt: "future"
}
],
creators: [
{
authorId: "future-author",
authorName: "Future",
savedAt: "future"
}
],
memberships: [
{
folderId: "future-folder",
authorId: "future-author",
addedAt: "future"
}
],
futureOnlyField: "do not retain"
})
);
await expect(repository.read()).resolves.toEqual({
creators: [],
folders: [],
memberships: [],
version: 1
});
});
test("serializes concurrent mutations so neither folder is overwritten", async () => {
const { repository } = createRepository();
await Promise.all([
repository.createFolder("第一个"),
repository.createFolder("第二个")
]);
await expect(repository.read()).resolves.toMatchObject({
folders: [
{ id: "folder-1", name: "第一个" },
{ id: "folder-2", name: "第二个" }
]
});
});
test("recovers the mutation queue after one rejected storage write", async () => {
let writeAttempts = 0;
let storedState: FavoritesStateV1 | undefined;
const storage: FavoritesStorage = {
async get() {
return storedState;
},
async set(value) {
writeAttempts += 1;
if (writeAttempts === 1) {
throw new Error("write failed");
}
storedState = value;
}
};
const { repository } = createRepository(storage);
await expect(repository.createFolder("合作")).rejects.toThrow("write failed");
await expect(repository.createFolder("待联系")).resolves.toMatchObject({
name: "待联系"
});
await expect(repository.read()).resolves.toEqual({
creators: [],
folders: [
{
id: "folder-2",
name: "待联系",
createdAt: "2026-07-17T00:00:00.000Z",
updatedAt: "2026-07-17T00:00:00.000Z"
}
],
memberships: [],
version: 1
});
});
test("uses the exact Chrome storage key and reports unavailable storage", async () => {
const globalWithChrome = globalThis as typeof globalThis & {
chrome?: unknown;
};
const originalChrome = globalWithChrome.chrome;
const storedState: FavoritesStateV1 = {
version: 1,
folders: [],
creators: [],
memberships: []
};
const getCalls: string[] = [];
const setCalls: Record<string, FavoritesStateV1>[] = [];
try {
globalWithChrome.chrome = {
storage: {
local: {
async get(key: string) {
getCalls.push(key);
return { [key]: storedState };
},
async set(items: Record<string, FavoritesStateV1>) {
setCalls.push(items);
}
}
}
};
const storage = createChromeFavoritesStorage();
await expect(storage.get()).resolves.toBe(storedState);
await storage.set(storedState);
expect(getCalls).toEqual([FAVORITES_STORAGE_KEY]);
expect(setCalls).toEqual([{ [FAVORITES_STORAGE_KEY]: storedState }]);
delete globalWithChrome.chrome;
await expect(createChromeFavoritesStorage().get()).rejects.toThrow(
"收藏夹存储不可用"
);
await expect(createChromeFavoritesStorage().set(storedState)).rejects.toThrow(
"收藏夹存储不可用"
);
} finally {
if (originalChrome === undefined) {
delete globalWithChrome.chrome;
} else {
globalWithChrome.chrome = originalChrome;
}
}
});
});