feat: add local favorites repository
This commit is contained in:
@@ -0,0 +1,411 @@
|
|||||||
|
export const FAVORITES_STORAGE_KEY = "sces:favorites:v1";
|
||||||
|
|
||||||
|
export interface FavoriteFolder {
|
||||||
|
createdAt: string;
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FavoriteCreator {
|
||||||
|
authorId: string;
|
||||||
|
authorName: string;
|
||||||
|
coreUserId?: string;
|
||||||
|
savedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FavoriteMembership {
|
||||||
|
addedAt: string;
|
||||||
|
authorId: string;
|
||||||
|
folderId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FavoritesStateV1 {
|
||||||
|
creators: FavoriteCreator[];
|
||||||
|
folders: FavoriteFolder[];
|
||||||
|
memberships: FavoriteMembership[];
|
||||||
|
version: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FavoriteCreatorInput {
|
||||||
|
authorId: string;
|
||||||
|
authorName: string;
|
||||||
|
coreUserId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FavoritesStorage {
|
||||||
|
get(): Promise<unknown>;
|
||||||
|
set(value: FavoritesStateV1): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FavoritesRepository {
|
||||||
|
createFolder(name: string): Promise<FavoriteFolder>;
|
||||||
|
deleteFolder(folderId: string): Promise<void>;
|
||||||
|
read(): Promise<FavoritesStateV1>;
|
||||||
|
removeCreatorFromFolder(authorId: string, folderId: string): Promise<void>;
|
||||||
|
renameFolder(folderId: string, name: string): Promise<void>;
|
||||||
|
setCreatorFolderIds(
|
||||||
|
creator: FavoriteCreatorInput,
|
||||||
|
folderIds: string[]
|
||||||
|
): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ChromeStorageLocalArea {
|
||||||
|
get(key: string): Promise<Record<string, unknown>>;
|
||||||
|
set(items: Record<string, FavoritesStateV1>): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ChromeApi {
|
||||||
|
storage?: {
|
||||||
|
local?: ChromeStorageLocalArea;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
declare const chrome: ChromeApi;
|
||||||
|
|
||||||
|
export function createFavoritesRepository(options: {
|
||||||
|
storage: FavoritesStorage;
|
||||||
|
now?: () => string;
|
||||||
|
createId?: () => string;
|
||||||
|
}): FavoritesRepository {
|
||||||
|
const now = options.now ?? (() => new Date().toISOString());
|
||||||
|
const createId = options.createId ?? createDefaultId;
|
||||||
|
let mutationTail: Promise<void> = Promise.resolve();
|
||||||
|
|
||||||
|
return {
|
||||||
|
async createFolder(name) {
|
||||||
|
const folderName = normalizeFolderName(name);
|
||||||
|
return mutate((state) => {
|
||||||
|
const timestamp = now();
|
||||||
|
const folder: FavoriteFolder = {
|
||||||
|
createdAt: timestamp,
|
||||||
|
id: createId(),
|
||||||
|
name: folderName,
|
||||||
|
updatedAt: timestamp
|
||||||
|
};
|
||||||
|
state.folders.push(folder);
|
||||||
|
return { ...folder };
|
||||||
|
});
|
||||||
|
},
|
||||||
|
async deleteFolder(folderId) {
|
||||||
|
await mutate((state) => {
|
||||||
|
const folderIndex = state.folders.findIndex((folder) => folder.id === folderId);
|
||||||
|
if (folderIndex === -1) {
|
||||||
|
throw new Error(`收藏夹不存在: ${folderId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
state.folders.splice(folderIndex, 1);
|
||||||
|
state.memberships = state.memberships.filter(
|
||||||
|
(membership) => membership.folderId !== folderId
|
||||||
|
);
|
||||||
|
pruneOrphanCreators(state);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
async read() {
|
||||||
|
await mutationTail;
|
||||||
|
return normalizeState(await options.storage.get(), now);
|
||||||
|
},
|
||||||
|
async removeCreatorFromFolder(authorId, folderId) {
|
||||||
|
await mutate((state) => {
|
||||||
|
state.memberships = state.memberships.filter(
|
||||||
|
(membership) =>
|
||||||
|
membership.authorId !== authorId || membership.folderId !== folderId
|
||||||
|
);
|
||||||
|
pruneOrphanCreators(state);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
async renameFolder(folderId, name) {
|
||||||
|
const folderName = normalizeFolderName(name);
|
||||||
|
await mutate((state) => {
|
||||||
|
const folder = state.folders.find((item) => item.id === folderId);
|
||||||
|
if (!folder) {
|
||||||
|
throw new Error(`收藏夹不存在: ${folderId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
folder.name = folderName;
|
||||||
|
folder.updatedAt = now();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
async setCreatorFolderIds(creator, folderIds) {
|
||||||
|
const authorId = normalizeText(creator.authorId);
|
||||||
|
if (!authorId) {
|
||||||
|
throw new Error("无法收藏该达人");
|
||||||
|
}
|
||||||
|
|
||||||
|
const uniqueFolderIds = Array.from(new Set(folderIds));
|
||||||
|
await mutate((state) => {
|
||||||
|
const knownFolderIds = new Set(state.folders.map((folder) => folder.id));
|
||||||
|
const unknownFolderId = uniqueFolderIds.find(
|
||||||
|
(folderId) => !knownFolderIds.has(folderId)
|
||||||
|
);
|
||||||
|
if (unknownFolderId !== undefined) {
|
||||||
|
throw new Error(`收藏夹不存在: ${unknownFolderId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const timestamp = now();
|
||||||
|
const existingCreator = state.creators.find(
|
||||||
|
(item) => item.authorId === authorId
|
||||||
|
);
|
||||||
|
const currentMemberships = state.memberships.filter(
|
||||||
|
(membership) => membership.authorId === authorId
|
||||||
|
);
|
||||||
|
const existingMemberships = new Map(
|
||||||
|
currentMemberships.map(
|
||||||
|
(membership) => [membership.folderId, membership] as const
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
state.memberships = state.memberships.filter(
|
||||||
|
(membership) => membership.authorId !== authorId
|
||||||
|
);
|
||||||
|
|
||||||
|
if (uniqueFolderIds.length === 0) {
|
||||||
|
pruneOrphanCreators(state);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const authorName = normalizeText(creator.authorName);
|
||||||
|
const coreUserId = normalizeText(creator.coreUserId);
|
||||||
|
if (existingCreator) {
|
||||||
|
if (authorName) {
|
||||||
|
existingCreator.authorName = authorName;
|
||||||
|
}
|
||||||
|
if (coreUserId) {
|
||||||
|
existingCreator.coreUserId = coreUserId;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const nextCreator: FavoriteCreator = {
|
||||||
|
authorId,
|
||||||
|
authorName: authorName ?? authorId,
|
||||||
|
savedAt: timestamp
|
||||||
|
};
|
||||||
|
if (coreUserId) {
|
||||||
|
nextCreator.coreUserId = coreUserId;
|
||||||
|
}
|
||||||
|
state.creators.push(nextCreator);
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestedFolderIds = new Set(uniqueFolderIds);
|
||||||
|
const retainedMemberships = currentMemberships.filter((membership) =>
|
||||||
|
requestedFolderIds.has(membership.folderId)
|
||||||
|
);
|
||||||
|
state.memberships.push(...retainedMemberships);
|
||||||
|
uniqueFolderIds.forEach((folderId) => {
|
||||||
|
if (existingMemberships.has(folderId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.memberships.push({ addedAt: timestamp, authorId, folderId });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function mutate<T>(mutator: (state: FavoritesStateV1) => T): Promise<T> {
|
||||||
|
const operation = mutationTail.then(async () => {
|
||||||
|
const state = normalizeState(await options.storage.get(), now);
|
||||||
|
const result = mutator(state);
|
||||||
|
await options.storage.set(state);
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
mutationTail = operation.then(
|
||||||
|
() => undefined,
|
||||||
|
() => undefined
|
||||||
|
);
|
||||||
|
return operation;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createChromeFavoritesStorage(): FavoritesStorage {
|
||||||
|
return {
|
||||||
|
async get() {
|
||||||
|
const local = getChromeLocalStorage();
|
||||||
|
const result = await local.get(FAVORITES_STORAGE_KEY);
|
||||||
|
return result[FAVORITES_STORAGE_KEY];
|
||||||
|
},
|
||||||
|
async set(value) {
|
||||||
|
const local = getChromeLocalStorage();
|
||||||
|
await local.set({ [FAVORITES_STORAGE_KEY]: value });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getChromeLocalStorage(): ChromeStorageLocalArea {
|
||||||
|
const local =
|
||||||
|
typeof chrome === "undefined" ? undefined : chrome.storage?.local;
|
||||||
|
if (!local) {
|
||||||
|
throw new Error("收藏夹存储不可用");
|
||||||
|
}
|
||||||
|
return local;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeState(value: unknown, now: () => string): FavoritesStateV1 {
|
||||||
|
const source = isRecord(value) ? value : {};
|
||||||
|
const folders = normalizeFolders(source.folders, now);
|
||||||
|
const creators = normalizeCreators(source.creators, now);
|
||||||
|
const knownFolderIds = new Set(folders.map((folder) => folder.id));
|
||||||
|
const knownCreatorIds = new Set(creators.map((creator) => creator.authorId));
|
||||||
|
const memberships = normalizeMemberships(
|
||||||
|
source.memberships,
|
||||||
|
knownFolderIds,
|
||||||
|
knownCreatorIds,
|
||||||
|
now
|
||||||
|
);
|
||||||
|
const memberAuthorIds = new Set(memberships.map((membership) => membership.authorId));
|
||||||
|
|
||||||
|
return {
|
||||||
|
creators: creators.filter((creator) => memberAuthorIds.has(creator.authorId)),
|
||||||
|
folders,
|
||||||
|
memberships,
|
||||||
|
version: 1
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeFolders(value: unknown, now: () => string): FavoriteFolder[] {
|
||||||
|
if (!Array.isArray(value)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const ids = new Set<string>();
|
||||||
|
const folders: FavoriteFolder[] = [];
|
||||||
|
value.forEach((item) => {
|
||||||
|
if (!isRecord(item)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = normalizeText(item.id);
|
||||||
|
const name = normalizeText(item.name);
|
||||||
|
if (!id || !name || ids.has(id)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ids.add(id);
|
||||||
|
folders.push({
|
||||||
|
createdAt: normalizeText(item.createdAt) ?? now(),
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
updatedAt: normalizeText(item.updatedAt) ?? now()
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return folders;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeCreators(value: unknown, now: () => string): FavoriteCreator[] {
|
||||||
|
if (!Array.isArray(value)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const creatorsByAuthorId = new Map<string, FavoriteCreator>();
|
||||||
|
value.forEach((item) => {
|
||||||
|
if (!isRecord(item)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const authorId = normalizeText(item.authorId);
|
||||||
|
if (!authorId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const authorName = normalizeText(item.authorName);
|
||||||
|
const coreUserId = normalizeText(item.coreUserId);
|
||||||
|
const existingCreator = creatorsByAuthorId.get(authorId);
|
||||||
|
if (existingCreator) {
|
||||||
|
if (coreUserId && !existingCreator.coreUserId) {
|
||||||
|
existingCreator.coreUserId = coreUserId;
|
||||||
|
}
|
||||||
|
if (authorName && existingCreator.authorName === authorId) {
|
||||||
|
existingCreator.authorName = authorName;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const creator: FavoriteCreator = {
|
||||||
|
authorId,
|
||||||
|
authorName: authorName ?? authorId,
|
||||||
|
savedAt: normalizeText(item.savedAt) ?? now()
|
||||||
|
};
|
||||||
|
if (coreUserId) {
|
||||||
|
creator.coreUserId = coreUserId;
|
||||||
|
}
|
||||||
|
creatorsByAuthorId.set(authorId, creator);
|
||||||
|
});
|
||||||
|
return Array.from(creatorsByAuthorId.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeMemberships(
|
||||||
|
value: unknown,
|
||||||
|
knownFolderIds: Set<string>,
|
||||||
|
knownCreatorIds: Set<string>,
|
||||||
|
now: () => string
|
||||||
|
): FavoriteMembership[] {
|
||||||
|
if (!Array.isArray(value)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const membershipKeys = new Set<string>();
|
||||||
|
const memberships: FavoriteMembership[] = [];
|
||||||
|
value.forEach((item) => {
|
||||||
|
if (!isRecord(item)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const authorId = normalizeText(item.authorId);
|
||||||
|
const folderId = normalizeText(item.folderId);
|
||||||
|
if (
|
||||||
|
!authorId ||
|
||||||
|
!folderId ||
|
||||||
|
!knownCreatorIds.has(authorId) ||
|
||||||
|
!knownFolderIds.has(folderId)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = `${folderId}\u0000${authorId}`;
|
||||||
|
if (membershipKeys.has(key)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
membershipKeys.add(key);
|
||||||
|
memberships.push({
|
||||||
|
addedAt: normalizeText(item.addedAt) ?? now(),
|
||||||
|
authorId,
|
||||||
|
folderId
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return memberships;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pruneOrphanCreators(state: FavoritesStateV1): void {
|
||||||
|
const memberAuthorIds = new Set(
|
||||||
|
state.memberships.map((membership) => membership.authorId)
|
||||||
|
);
|
||||||
|
state.creators = state.creators.filter((creator) =>
|
||||||
|
memberAuthorIds.has(creator.authorId)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeFolderName(name: string): string {
|
||||||
|
const normalized = name.trim();
|
||||||
|
if (!normalized) {
|
||||||
|
throw new Error("请输入收藏夹名称");
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeText(value: unknown): string | undefined {
|
||||||
|
if (typeof value !== "string") {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const normalized = value.trim();
|
||||||
|
return normalized || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createDefaultId(): string {
|
||||||
|
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
|
||||||
|
return crypto.randomUUID();
|
||||||
|
}
|
||||||
|
return `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,282 @@
|
|||||||
|
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("recovers from malformed, duplicate, and dangling stored values", async () => {
|
||||||
|
const { repository } = createRepository(
|
||||||
|
createMemoryStorage({
|
||||||
|
version: 42,
|
||||||
|
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("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("propagates rejected storage writes", async () => {
|
||||||
|
const storage: FavoritesStorage = {
|
||||||
|
async get() {
|
||||||
|
return undefined;
|
||||||
|
},
|
||||||
|
async set() {
|
||||||
|
throw new Error("write failed");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const { repository } = createRepository(storage);
|
||||||
|
|
||||||
|
await expect(repository.createFolder("合作")).rejects.toThrow("write failed");
|
||||||
|
});
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user