mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-14 00:47:57 +00:00
2766 lines
94 KiB
TypeScript
2766 lines
94 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
|
|
vi.mock("@convex-dev/auth/server", () => ({
|
|
getAuthUserId: vi.fn(),
|
|
authTables: {},
|
|
}));
|
|
|
|
import { getAuthUserId } from "@convex-dev/auth/server";
|
|
import {
|
|
changeOwner,
|
|
getBySlug,
|
|
getSkillForPublishPreflightInternal,
|
|
getSkillBySlugInternal,
|
|
mergeOwnedSkillIntoCanonicalInternal,
|
|
renameOwnedSkillInternal,
|
|
resolveVersionByHash,
|
|
setDuplicate,
|
|
transferSkillOwnerForUserInternal,
|
|
} from "./skills";
|
|
|
|
type WrappedHandler<TArgs, TResult = unknown> = {
|
|
_handler: (ctx: unknown, args: TArgs) => Promise<TResult>;
|
|
};
|
|
|
|
const getSkillBySlugInternalHandler = (
|
|
getSkillBySlugInternal as unknown as WrappedHandler<{ slug: string }>
|
|
)._handler;
|
|
const getBySlugHandler = (
|
|
getBySlug as unknown as WrappedHandler<{ slug: string; ownerHandle?: string }>
|
|
)._handler;
|
|
const getSkillForPublishPreflightInternalHandler = (
|
|
getSkillForPublishPreflightInternal as unknown as WrappedHandler<{
|
|
userId: string;
|
|
slug: string;
|
|
ownerPublisherId?: string;
|
|
sourceOwnerPublisherId?: string;
|
|
migrateOwner?: boolean;
|
|
}>
|
|
)._handler;
|
|
const resolveVersionByHashHandler = (
|
|
resolveVersionByHash as unknown as WrappedHandler<{
|
|
slug: string;
|
|
hash: string;
|
|
ownerHandle?: string;
|
|
}>
|
|
)._handler;
|
|
const mergeOwnedSkillIntoCanonicalInternalHandler = (
|
|
mergeOwnedSkillIntoCanonicalInternal as unknown as WrappedHandler<{
|
|
actorUserId: string;
|
|
sourceSlug: string;
|
|
targetSlug: string;
|
|
sourceOwnerHandle?: string;
|
|
targetOwnerHandle?: string;
|
|
}>
|
|
)._handler;
|
|
const renameOwnedSkillInternalHandler = (
|
|
renameOwnedSkillInternal as unknown as WrappedHandler<{
|
|
actorUserId: string;
|
|
slug: string;
|
|
newSlug: string;
|
|
}>
|
|
)._handler;
|
|
const transferSkillOwnerForUserInternalHandler = (
|
|
transferSkillOwnerForUserInternal as unknown as WrappedHandler<{
|
|
actorUserId: string;
|
|
slug: string;
|
|
toOwner: string;
|
|
reason?: string;
|
|
}>
|
|
)._handler;
|
|
const changeOwnerHandler = (
|
|
changeOwner as unknown as WrappedHandler<{
|
|
skillId: string;
|
|
ownerUserId: string;
|
|
}>
|
|
)._handler;
|
|
const setDuplicateHandler = (
|
|
setDuplicate as unknown as WrappedHandler<{
|
|
skillId: string;
|
|
canonicalSlug?: string;
|
|
canonicalSkillId?: string;
|
|
}>
|
|
)._handler;
|
|
|
|
afterEach(() => {
|
|
vi.mocked(getAuthUserId).mockReset();
|
|
});
|
|
|
|
function chainEq(constraints: Record<string, unknown>) {
|
|
return {
|
|
eq(field: string, value: unknown) {
|
|
constraints[field] = value;
|
|
return chainEq(constraints);
|
|
},
|
|
};
|
|
}
|
|
|
|
const defaultSkillStats = {
|
|
downloads: 0,
|
|
stars: 0,
|
|
installsCurrent: 0,
|
|
installsAllTime: 0,
|
|
versions: 1,
|
|
comments: 0,
|
|
};
|
|
|
|
describe("skills ownership", () => {
|
|
it("resolves publish preflight by owner namespace instead of global slug", async () => {
|
|
const constraintsByIndex: Array<{
|
|
table: string;
|
|
index: string;
|
|
constraints: Record<string, unknown>;
|
|
}> = [];
|
|
|
|
const result = await getSkillForPublishPreflightInternalHandler(
|
|
{
|
|
db: {
|
|
get: vi.fn(async (id: string) => {
|
|
if (id === "users:caller") {
|
|
return {
|
|
_id: "users:caller",
|
|
handle: "caller",
|
|
personalPublisherId: "publishers:personal",
|
|
deletedAt: undefined,
|
|
deactivatedAt: undefined,
|
|
};
|
|
}
|
|
if (id === "publishers:personal") {
|
|
return {
|
|
_id: "publishers:personal",
|
|
kind: "user",
|
|
handle: "caller",
|
|
linkedUserId: "users:caller",
|
|
deletedAt: undefined,
|
|
deactivatedAt: undefined,
|
|
};
|
|
}
|
|
if (id === "publishers:org") {
|
|
return {
|
|
_id: "publishers:org",
|
|
kind: "org",
|
|
handle: "team",
|
|
deletedAt: undefined,
|
|
deactivatedAt: undefined,
|
|
};
|
|
}
|
|
return null;
|
|
}),
|
|
query: vi.fn((table: string) => {
|
|
if (table !== "skills") throw new Error(`unexpected table ${table}`);
|
|
return {
|
|
withIndex: (
|
|
index: string,
|
|
build: (q: { eq: (field: string, value: unknown) => unknown }) => unknown,
|
|
) => {
|
|
if (index === "by_slug") {
|
|
throw new Error("publish preflight must not use global by_slug");
|
|
}
|
|
const constraints: Record<string, unknown> = {};
|
|
const q = chainEq(constraints);
|
|
build(q);
|
|
constraintsByIndex.push({ table, index, constraints });
|
|
return {
|
|
unique: async () =>
|
|
index === "by_owner_publisher_slug" &&
|
|
constraints.ownerPublisherId === "publishers:org" &&
|
|
constraints.slug === "publish"
|
|
? {
|
|
_id: "skills:orgPublish",
|
|
slug: "publish",
|
|
ownerUserId: "users:caller",
|
|
ownerPublisherId: "publishers:org",
|
|
summary: "Grandfathered reserved slug",
|
|
}
|
|
: null,
|
|
};
|
|
},
|
|
};
|
|
}),
|
|
},
|
|
} as never,
|
|
{
|
|
userId: "users:caller",
|
|
slug: "publish",
|
|
ownerPublisherId: "publishers:org",
|
|
} as never,
|
|
);
|
|
|
|
expect(result).toMatchObject({
|
|
_id: "skills:orgPublish",
|
|
ownerPublisherId: "publishers:org",
|
|
});
|
|
expect(constraintsByIndex).toEqual([
|
|
{
|
|
table: "skills",
|
|
index: "by_owner_publisher_slug",
|
|
constraints: {
|
|
ownerPublisherId: "publishers:org",
|
|
slug: "publish",
|
|
},
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("resolves alias slugs to the live target skill", async () => {
|
|
const result = await getSkillBySlugInternalHandler(
|
|
{
|
|
db: {
|
|
normalizeId: vi.fn(() => null),
|
|
system: {},
|
|
get: vi.fn(async (id: string) => {
|
|
if (id === "skills:target") {
|
|
return {
|
|
_id: "skills:target",
|
|
slug: "demo",
|
|
ownerUserId: "users:1",
|
|
};
|
|
}
|
|
return null;
|
|
}),
|
|
query: vi.fn((table: string) => {
|
|
if (table === "skills") {
|
|
return {
|
|
withIndex: (name: string) => {
|
|
if (name !== "by_slug") throw new Error(`unexpected skills index ${name}`);
|
|
return {
|
|
take: async () => [],
|
|
unique: async () => null,
|
|
};
|
|
},
|
|
};
|
|
}
|
|
if (table === "skillSlugAliases") {
|
|
return {
|
|
withIndex: (name: string) => {
|
|
if (name !== "by_slug") throw new Error(`unexpected alias index ${name}`);
|
|
return {
|
|
take: async () => [
|
|
{
|
|
_id: "skillSlugAliases:1",
|
|
slug: "demo-old",
|
|
skillId: "skills:target",
|
|
},
|
|
],
|
|
unique: async () => ({
|
|
_id: "skillSlugAliases:1",
|
|
slug: "demo-old",
|
|
skillId: "skills:target",
|
|
}),
|
|
};
|
|
},
|
|
};
|
|
}
|
|
if (table === "skillSlugAliases") {
|
|
return {
|
|
withIndex: (name: string) => {
|
|
if (name !== "by_slug") {
|
|
throw new Error(`unexpected skillSlugAliases index ${name}`);
|
|
}
|
|
return { take: async () => [] };
|
|
},
|
|
};
|
|
}
|
|
if (table === "skillSlugAliases") {
|
|
return {
|
|
withIndex: (name: string) => {
|
|
if (name !== "by_slug") {
|
|
throw new Error(`unexpected skillSlugAliases index ${name}`);
|
|
}
|
|
return { take: async () => [] };
|
|
},
|
|
};
|
|
}
|
|
if (table === "skillSlugAliases") {
|
|
return {
|
|
withIndex: (name: string) => {
|
|
if (name !== "by_slug") {
|
|
throw new Error(`unexpected skillSlugAliases index ${name}`);
|
|
}
|
|
return { take: async () => [] };
|
|
},
|
|
};
|
|
}
|
|
if (table === "skillSlugAliases") {
|
|
return {
|
|
withIndex: (name: string) => {
|
|
if (name !== "by_slug") {
|
|
throw new Error(`unexpected skillSlugAliases index ${name}`);
|
|
}
|
|
return { take: async () => [] };
|
|
},
|
|
};
|
|
}
|
|
if (table === "skillSlugAliases") {
|
|
return {
|
|
withIndex: (name: string) => {
|
|
if (name !== "by_slug") {
|
|
throw new Error(`unexpected skillSlugAliases index ${name}`);
|
|
}
|
|
return { take: async () => [] };
|
|
},
|
|
};
|
|
}
|
|
throw new Error(`unexpected table ${table}`);
|
|
}),
|
|
},
|
|
} as never,
|
|
{ slug: "demo-old" } as never,
|
|
);
|
|
|
|
expect(result).toEqual(
|
|
expect.objectContaining({
|
|
_id: "skills:target",
|
|
slug: "demo",
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("prefers openclaw for legacy slug-only reads when duplicate visible skills exist", async () => {
|
|
const skills = [
|
|
{
|
|
_id: "skills:community",
|
|
slug: "demo",
|
|
ownerUserId: "users:community",
|
|
ownerPublisherId: "publishers:community",
|
|
softDeletedAt: undefined,
|
|
},
|
|
{
|
|
_id: "skills:openclaw",
|
|
slug: "demo",
|
|
ownerUserId: "users:openclaw",
|
|
ownerPublisherId: "publishers:openclaw",
|
|
softDeletedAt: undefined,
|
|
},
|
|
];
|
|
|
|
const result = await getSkillBySlugInternalHandler(
|
|
{
|
|
db: {
|
|
normalizeId: vi.fn(() => null),
|
|
system: {},
|
|
get: vi.fn(async (id: string) => {
|
|
if (id === "publishers:community") {
|
|
return { _id: id, kind: "org", handle: "community" };
|
|
}
|
|
if (id === "publishers:openclaw") {
|
|
return { _id: id, kind: "org", handle: "openclaw" };
|
|
}
|
|
return skills.find((entry) => entry._id === id) ?? null;
|
|
}),
|
|
query: vi.fn((table: string) => {
|
|
if (table === "skills") {
|
|
return {
|
|
withIndex: (name: string) => {
|
|
if (name !== "by_slug") throw new Error(`unexpected skills index ${name}`);
|
|
return {
|
|
take: async () => skills,
|
|
unique: async () => {
|
|
throw new Error("unique should not be used for legacy duplicate reads");
|
|
},
|
|
};
|
|
},
|
|
};
|
|
}
|
|
if (table === "skillSlugAliases") {
|
|
return {
|
|
withIndex: (name: string) => {
|
|
if (name !== "by_slug") {
|
|
throw new Error(`unexpected skillSlugAliases index ${name}`);
|
|
}
|
|
return { take: async () => [] };
|
|
},
|
|
};
|
|
}
|
|
throw new Error(`unexpected table ${table}`);
|
|
}),
|
|
},
|
|
} as never,
|
|
{ slug: "demo" } as never,
|
|
);
|
|
|
|
expect(result).toEqual(expect.objectContaining({ _id: "skills:openclaw" }));
|
|
});
|
|
|
|
it("ignores soft-deleted duplicates before treating legacy slug-only reads as ambiguous", async () => {
|
|
const skills = [
|
|
{
|
|
_id: "skills:visible",
|
|
slug: "demo",
|
|
ownerUserId: "users:visible",
|
|
ownerPublisherId: "publishers:visible",
|
|
softDeletedAt: undefined,
|
|
},
|
|
{
|
|
_id: "skills:deleted",
|
|
slug: "demo",
|
|
ownerUserId: "users:deleted",
|
|
ownerPublisherId: "publishers:deleted",
|
|
softDeletedAt: 123,
|
|
},
|
|
];
|
|
|
|
const result = await getSkillBySlugInternalHandler(
|
|
{
|
|
db: {
|
|
normalizeId: vi.fn(() => null),
|
|
system: {},
|
|
get: vi.fn(async (id: string) => skills.find((entry) => entry._id === id) ?? null),
|
|
query: vi.fn((table: string) => {
|
|
if (table === "skills") {
|
|
return {
|
|
withIndex: (name: string) => {
|
|
if (name !== "by_slug") throw new Error(`unexpected skills index ${name}`);
|
|
return {
|
|
take: async () => skills,
|
|
unique: async () => {
|
|
throw new Error("unique should not be used for legacy duplicate reads");
|
|
},
|
|
};
|
|
},
|
|
};
|
|
}
|
|
if (table === "skillSlugAliases") {
|
|
return {
|
|
withIndex: (name: string) => {
|
|
if (name !== "by_slug") {
|
|
throw new Error(`unexpected skillSlugAliases index ${name}`);
|
|
}
|
|
return { take: async () => [] };
|
|
},
|
|
};
|
|
}
|
|
throw new Error(`unexpected table ${table}`);
|
|
}),
|
|
},
|
|
} as never,
|
|
{ slug: "demo" } as never,
|
|
);
|
|
|
|
expect(result).toEqual(expect.objectContaining({ _id: "skills:visible" }));
|
|
});
|
|
|
|
it("returns an ambiguous result for legacy slug-only version resolution", async () => {
|
|
const skills = [
|
|
{
|
|
_id: "skills:one",
|
|
slug: "demo",
|
|
ownerUserId: "users:one",
|
|
ownerPublisherId: "publishers:one",
|
|
latestVersionId: "skillVersions:one",
|
|
softDeletedAt: undefined,
|
|
},
|
|
{
|
|
_id: "skills:two",
|
|
slug: "demo",
|
|
ownerUserId: "users:two",
|
|
ownerPublisherId: "publishers:two",
|
|
latestVersionId: "skillVersions:two",
|
|
softDeletedAt: undefined,
|
|
},
|
|
];
|
|
|
|
const result = await resolveVersionByHashHandler(
|
|
{
|
|
db: {
|
|
get: vi.fn(async (id: string) => {
|
|
if (id === "publishers:one") {
|
|
return { _id: id, kind: "org", handle: "one" };
|
|
}
|
|
if (id === "publishers:two") {
|
|
return { _id: id, kind: "org", handle: "two" };
|
|
}
|
|
return skills.find((entry) => entry._id === id) ?? null;
|
|
}),
|
|
query: vi.fn((table: string) => {
|
|
return {
|
|
withIndex: (name: string) => {
|
|
if (table === "skills" && name !== "by_slug") {
|
|
throw new Error(`unexpected skills index ${name}`);
|
|
}
|
|
if (table === "skillSlugAliases" && name !== "by_slug") {
|
|
throw new Error(`unexpected skillSlugAliases index ${name}`);
|
|
}
|
|
if (table !== "skills" && table !== "skillSlugAliases") {
|
|
throw new Error(`unexpected table ${table}`);
|
|
}
|
|
return {
|
|
take: async () => (table === "skills" ? skills : []),
|
|
unique: async () => {
|
|
throw new Error("unique should not be used for legacy duplicate reads");
|
|
},
|
|
};
|
|
},
|
|
};
|
|
}),
|
|
},
|
|
} as never,
|
|
{ slug: "demo", hash: "a".repeat(64) } as never,
|
|
);
|
|
|
|
expect(result).toEqual({
|
|
match: null,
|
|
latestVersion: null,
|
|
ambiguous: true,
|
|
ambiguousMatches: [
|
|
{ slug: "demo", ownerHandle: "one" },
|
|
{ slug: "demo", ownerHandle: "two" },
|
|
],
|
|
});
|
|
});
|
|
|
|
it("returns an ambiguous result for legacy slug-only public skill lookups", async () => {
|
|
const skills = [
|
|
{
|
|
_id: "skills:one",
|
|
slug: "demo",
|
|
ownerUserId: "users:one",
|
|
ownerPublisherId: "publishers:one",
|
|
latestVersionId: "skillVersions:one",
|
|
softDeletedAt: undefined,
|
|
},
|
|
{
|
|
_id: "skills:two",
|
|
slug: "demo",
|
|
ownerUserId: "users:two",
|
|
ownerPublisherId: "publishers:two",
|
|
latestVersionId: "skillVersions:two",
|
|
softDeletedAt: undefined,
|
|
},
|
|
];
|
|
|
|
const result = await getBySlugHandler(
|
|
{
|
|
db: {
|
|
get: vi.fn(async (id: string) => {
|
|
if (id === "publishers:one") {
|
|
return { _id: id, kind: "org", handle: "one" };
|
|
}
|
|
if (id === "publishers:two") {
|
|
return { _id: id, kind: "org", handle: "two" };
|
|
}
|
|
return skills.find((entry) => entry._id === id) ?? null;
|
|
}),
|
|
query: vi.fn((table: string) => {
|
|
return {
|
|
withIndex: (name: string) => {
|
|
if (table === "skills" && name !== "by_slug") {
|
|
throw new Error(`unexpected skills index ${name}`);
|
|
}
|
|
if (table === "skillSlugAliases" && name !== "by_slug") {
|
|
throw new Error(`unexpected skillSlugAliases index ${name}`);
|
|
}
|
|
if (table !== "skills" && table !== "skillSlugAliases") {
|
|
throw new Error(`unexpected table ${table}`);
|
|
}
|
|
return {
|
|
take: async () => (table === "skills" ? skills : []),
|
|
unique: async () => {
|
|
throw new Error("unique should not be used for legacy duplicate reads");
|
|
},
|
|
};
|
|
},
|
|
};
|
|
}),
|
|
system: {},
|
|
},
|
|
auth: { getUserIdentity: vi.fn() },
|
|
} as never,
|
|
{ slug: "demo" } as never,
|
|
);
|
|
|
|
expect(result).toMatchObject({ skill: null, ambiguous: true });
|
|
});
|
|
|
|
it("does not resolve owner-scoped public version metadata for soft-deleted skills", async () => {
|
|
const result = await resolveVersionByHashHandler(
|
|
{
|
|
db: {
|
|
get: vi.fn(async (id: string) => {
|
|
if (id === "publishers:openclaw") {
|
|
return {
|
|
_id: id,
|
|
kind: "org",
|
|
handle: "openclaw",
|
|
deletedAt: undefined,
|
|
deactivatedAt: undefined,
|
|
};
|
|
}
|
|
return null;
|
|
}),
|
|
query: vi.fn((table: string) => {
|
|
if (table === "publishers") {
|
|
return {
|
|
withIndex: (
|
|
name: string,
|
|
build: (q: { eq: (field: string, value: unknown) => unknown }) => unknown,
|
|
) => {
|
|
if (name !== "by_handle") throw new Error(`unexpected publishers index ${name}`);
|
|
const constraints: Record<string, unknown> = {};
|
|
build(chainEq(constraints));
|
|
return {
|
|
unique: async () =>
|
|
constraints.handle === "openclaw"
|
|
? {
|
|
_id: "publishers:openclaw",
|
|
kind: "org",
|
|
handle: "openclaw",
|
|
deletedAt: undefined,
|
|
deactivatedAt: undefined,
|
|
}
|
|
: null,
|
|
};
|
|
},
|
|
};
|
|
}
|
|
if (table === "skillSlugAliases") {
|
|
return {
|
|
withIndex: (
|
|
name: string,
|
|
build: (q: { eq: (field: string, value: unknown) => unknown }) => unknown,
|
|
) => {
|
|
if (name !== "by_owner_publisher_slug") {
|
|
throw new Error(`unexpected aliases index ${name}`);
|
|
}
|
|
build(chainEq({}));
|
|
return { unique: async () => null };
|
|
},
|
|
};
|
|
}
|
|
if (table !== "skills") {
|
|
throw new Error(`soft-deleted skill should stop before querying ${table}`);
|
|
}
|
|
return {
|
|
withIndex: (
|
|
name: string,
|
|
build: (q: { eq: (field: string, value: unknown) => unknown }) => unknown,
|
|
) => {
|
|
if (name !== "by_owner_publisher_slug") {
|
|
throw new Error(`unexpected skills index ${name}`);
|
|
}
|
|
const constraints: Record<string, unknown> = {};
|
|
build(chainEq(constraints));
|
|
return {
|
|
unique: async () => ({
|
|
_id: "skills:deleted",
|
|
slug: "demo",
|
|
ownerUserId: "users:openclaw",
|
|
ownerPublisherId: "publishers:openclaw",
|
|
latestVersionId: "skillVersions:latest",
|
|
softDeletedAt: 123,
|
|
}),
|
|
};
|
|
},
|
|
};
|
|
}),
|
|
},
|
|
} as never,
|
|
{
|
|
slug: "demo",
|
|
ownerHandle: "openclaw",
|
|
hash: "a".repeat(64),
|
|
},
|
|
);
|
|
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it("allows publisher admins to merge publisher-owned skills and preserves alias ownership", async () => {
|
|
const patch = vi.fn(async () => {});
|
|
const insert = vi.fn(async () => "auditLogs:1");
|
|
const skills = [
|
|
{
|
|
_id: "skills:source",
|
|
slug: "merge-source",
|
|
displayName: "Merge Source",
|
|
ownerUserId: "users:creator",
|
|
ownerPublisherId: "publishers:org",
|
|
moderationStatus: "hidden",
|
|
softDeletedAt: undefined,
|
|
statsDownloads: 7,
|
|
statsStars: 2,
|
|
},
|
|
{
|
|
_id: "skills:target",
|
|
slug: "merge-target",
|
|
displayName: "Merge Target",
|
|
ownerUserId: "users:creator",
|
|
ownerPublisherId: "publishers:org",
|
|
latestVersionId: "skillVersions:target",
|
|
moderationStatus: "hidden",
|
|
softDeletedAt: undefined,
|
|
},
|
|
];
|
|
const aliases = [
|
|
{
|
|
_id: "skillSlugAliases:old",
|
|
slug: "merge-source-old",
|
|
skillId: "skills:source",
|
|
ownerUserId: "users:creator",
|
|
ownerPublisherId: "publishers:org",
|
|
},
|
|
];
|
|
|
|
const result = await mergeOwnedSkillIntoCanonicalInternalHandler(
|
|
{
|
|
db: {
|
|
normalizeId: vi.fn(() => null),
|
|
system: {},
|
|
get: vi.fn(async (id: string) => {
|
|
if (id === "users:actor") return { _id: "users:actor", role: "user" };
|
|
if (id === "users:creator") {
|
|
return {
|
|
_id: "users:creator",
|
|
publishedSkills: 2,
|
|
totalDownloads: 9,
|
|
totalStars: 5,
|
|
};
|
|
}
|
|
if (id === "publishers:org") {
|
|
return {
|
|
_id: "publishers:org",
|
|
kind: "org",
|
|
handle: "team",
|
|
linkedUserId: undefined,
|
|
};
|
|
}
|
|
if (id === "skillVersions:target") return { _id: id, version: "1.2.3" };
|
|
return skills.find((skill) => skill._id === id) ?? null;
|
|
}),
|
|
query: vi.fn((table: string) => {
|
|
if (table === "skills") {
|
|
return {
|
|
withIndex: (name: string, build: (q: ReturnType<typeof chainEq>) => unknown) => {
|
|
const constraints: Record<string, unknown> = {};
|
|
build(chainEq(constraints));
|
|
if (name === "by_slug") {
|
|
return {
|
|
take: async () =>
|
|
skills.filter((skill) => skill.slug === constraints.slug).slice(0, 2),
|
|
unique: async () =>
|
|
skills.find((skill) => skill.slug === constraints.slug) ?? null,
|
|
};
|
|
}
|
|
if (name === "by_owner_publisher_slug") {
|
|
return {
|
|
unique: async () =>
|
|
skills.find(
|
|
(skill) =>
|
|
skill.ownerPublisherId === constraints.ownerPublisherId &&
|
|
skill.slug === constraints.slug,
|
|
) ?? null,
|
|
};
|
|
}
|
|
if (name === "by_canonical" || name === "by_fork_of") {
|
|
return { collect: async () => [] };
|
|
}
|
|
throw new Error(`unexpected skills index ${name}`);
|
|
},
|
|
};
|
|
}
|
|
if (table === "publisherMembers") {
|
|
return {
|
|
withIndex: (name: string) => {
|
|
if (name !== "by_publisher_user") {
|
|
throw new Error(`unexpected publisherMembers index ${name}`);
|
|
}
|
|
return {
|
|
unique: async () => ({
|
|
_id: "publisherMembers:1",
|
|
publisherId: "publishers:org",
|
|
userId: "users:actor",
|
|
role: "admin",
|
|
}),
|
|
};
|
|
},
|
|
};
|
|
}
|
|
if (table === "skillSlugAliases") {
|
|
return {
|
|
withIndex: (name: string, build: (q: ReturnType<typeof chainEq>) => unknown) => {
|
|
const constraints: Record<string, unknown> = {};
|
|
build(chainEq(constraints));
|
|
if (name === "by_skill") {
|
|
return {
|
|
take: async (limit: number) =>
|
|
aliases
|
|
.filter((alias) => alias.skillId === constraints.skillId)
|
|
.slice(0, limit),
|
|
};
|
|
}
|
|
if (name === "by_slug") {
|
|
return {
|
|
take: async () =>
|
|
aliases.filter((alias) => alias.slug === constraints.slug).slice(0, 2),
|
|
unique: async () =>
|
|
aliases.find((alias) => alias.slug === constraints.slug) ?? null,
|
|
};
|
|
}
|
|
if (name === "by_owner_publisher") {
|
|
return {
|
|
take: async () =>
|
|
aliases.filter(
|
|
(alias) => alias.ownerPublisherId === constraints.ownerPublisherId,
|
|
),
|
|
};
|
|
}
|
|
if (name === "by_owner_publisher_slug") {
|
|
return {
|
|
unique: async () =>
|
|
aliases.find(
|
|
(alias) =>
|
|
alias.ownerPublisherId === constraints.ownerPublisherId &&
|
|
alias.slug === constraints.slug,
|
|
) ?? null,
|
|
};
|
|
}
|
|
if (name === "by_owner_slug") {
|
|
return {
|
|
unique: async () =>
|
|
aliases.find(
|
|
(alias) =>
|
|
alias.ownerUserId === constraints.ownerUserId &&
|
|
alias.slug === constraints.slug,
|
|
) ?? null,
|
|
};
|
|
}
|
|
if (name === "by_owner") {
|
|
return {
|
|
take: async () =>
|
|
aliases.filter((alias) => alias.ownerUserId === constraints.ownerUserId),
|
|
};
|
|
}
|
|
throw new Error(`unexpected skillSlugAliases index ${name}`);
|
|
},
|
|
};
|
|
}
|
|
if (table === "skillEmbeddings") {
|
|
return {
|
|
withIndex: (name: string) => {
|
|
if (name !== "by_skill") {
|
|
throw new Error(`unexpected skillEmbeddings index ${name}`);
|
|
}
|
|
return { collect: async () => [] };
|
|
},
|
|
};
|
|
}
|
|
throw new Error(`unexpected table ${table}`);
|
|
}),
|
|
patch,
|
|
insert,
|
|
},
|
|
} as never,
|
|
{
|
|
actorUserId: "users:actor",
|
|
sourceSlug: "merge-source",
|
|
targetSlug: "merge-target",
|
|
},
|
|
);
|
|
|
|
expect(result).toEqual({
|
|
ok: true,
|
|
sourceSlug: "merge-source",
|
|
targetSlug: "merge-target",
|
|
});
|
|
expect(patch).toHaveBeenCalledWith(
|
|
"skillSlugAliases:old",
|
|
expect.objectContaining({
|
|
skillId: "skills:target",
|
|
}),
|
|
);
|
|
expect(insert).toHaveBeenCalledWith(
|
|
"skillSlugAliases",
|
|
expect.objectContaining({
|
|
slug: "merge-source",
|
|
skillId: "skills:target",
|
|
ownerUserId: "users:creator",
|
|
ownerPublisherId: "publishers:org",
|
|
}),
|
|
);
|
|
expect(patch).toHaveBeenCalledWith(
|
|
"skills:source",
|
|
expect.objectContaining({
|
|
canonicalSkillId: "skills:target",
|
|
forkOf: expect.objectContaining({
|
|
skillId: "skills:target",
|
|
kind: "duplicate",
|
|
version: "1.2.3",
|
|
}),
|
|
moderationReason: "owner.merged",
|
|
}),
|
|
);
|
|
expect(patch).toHaveBeenCalledWith(
|
|
"users:creator",
|
|
expect.objectContaining({
|
|
publishedSkills: 1,
|
|
totalDownloads: 2,
|
|
totalStars: 3,
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("promotes an automatically detected duplicate target when merging its source into it", async () => {
|
|
const patch = vi.fn(async (_id: string, _value: unknown) => {});
|
|
const insert = vi.fn(async () => "auditLogs:1");
|
|
const skills = [
|
|
{
|
|
_id: "skills:source",
|
|
slug: "archive-demo",
|
|
displayName: "Archive Demo",
|
|
ownerUserId: "users:creator",
|
|
ownerPublisherId: "publishers:org",
|
|
moderationStatus: "active",
|
|
softDeletedAt: undefined,
|
|
statsDownloads: 0,
|
|
statsStars: 0,
|
|
statsInstallsCurrent: 0,
|
|
statsInstallsAllTime: 0,
|
|
},
|
|
{
|
|
_id: "skills:target",
|
|
slug: "demo",
|
|
displayName: "Demo",
|
|
ownerUserId: "users:creator",
|
|
ownerPublisherId: "publishers:org",
|
|
latestVersionId: "skillVersions:target",
|
|
canonicalSkillId: "skills:source",
|
|
forkOf: {
|
|
skillId: "skills:source",
|
|
kind: "duplicate",
|
|
at: 100,
|
|
},
|
|
moderationStatus: "active",
|
|
softDeletedAt: undefined,
|
|
},
|
|
];
|
|
|
|
const result = await mergeOwnedSkillIntoCanonicalInternalHandler(
|
|
{
|
|
db: {
|
|
normalizeId: vi.fn(() => null),
|
|
system: {},
|
|
get: vi.fn(async (id: string) => {
|
|
if (id === "users:actor") return { _id: "users:actor", role: "user" };
|
|
if (id === "users:creator") {
|
|
return {
|
|
_id: "users:creator",
|
|
publishedSkills: 2,
|
|
totalDownloads: 0,
|
|
totalStars: 0,
|
|
};
|
|
}
|
|
if (id === "publishers:org") {
|
|
return {
|
|
_id: "publishers:org",
|
|
kind: "org",
|
|
handle: "team",
|
|
linkedUserId: undefined,
|
|
};
|
|
}
|
|
if (id === "skillVersions:target") return { _id: id, version: "1.0.0" };
|
|
return skills.find((skill) => skill._id === id) ?? null;
|
|
}),
|
|
query: vi.fn((table: string) => {
|
|
if (table === "skills") {
|
|
return {
|
|
withIndex: (name: string, build: (q: ReturnType<typeof chainEq>) => unknown) => {
|
|
const constraints: Record<string, unknown> = {};
|
|
build(chainEq(constraints));
|
|
if (name === "by_slug") {
|
|
return {
|
|
take: async () =>
|
|
skills.filter((skill) => skill.slug === constraints.slug).slice(0, 2),
|
|
unique: async () =>
|
|
skills.find((skill) => skill.slug === constraints.slug) ?? null,
|
|
};
|
|
}
|
|
if (name === "by_owner_publisher_slug") {
|
|
return {
|
|
unique: async () =>
|
|
skills.find(
|
|
(skill) =>
|
|
skill.ownerPublisherId === constraints.ownerPublisherId &&
|
|
skill.slug === constraints.slug,
|
|
) ?? null,
|
|
};
|
|
}
|
|
if (name === "by_canonical") {
|
|
return {
|
|
collect: async () =>
|
|
skills.filter(
|
|
(skill) => skill.canonicalSkillId === constraints.canonicalSkillId,
|
|
),
|
|
};
|
|
}
|
|
if (name === "by_fork_of") {
|
|
return {
|
|
collect: async () =>
|
|
skills.filter(
|
|
(skill) => skill.forkOf?.skillId === constraints["forkOf.skillId"],
|
|
),
|
|
};
|
|
}
|
|
throw new Error(`unexpected skills index ${name}`);
|
|
},
|
|
};
|
|
}
|
|
if (table === "publisherMembers") {
|
|
return {
|
|
withIndex: (name: string) => {
|
|
if (name !== "by_publisher_user") {
|
|
throw new Error(`unexpected publisherMembers index ${name}`);
|
|
}
|
|
return {
|
|
unique: async () => ({
|
|
_id: "publisherMembers:1",
|
|
publisherId: "publishers:org",
|
|
userId: "users:actor",
|
|
role: "admin",
|
|
}),
|
|
};
|
|
},
|
|
};
|
|
}
|
|
if (table === "skillSlugAliases") {
|
|
return {
|
|
withIndex: (name: string, build: (q: ReturnType<typeof chainEq>) => unknown) => {
|
|
const constraints: Record<string, unknown> = {};
|
|
build(chainEq(constraints));
|
|
if (name === "by_skill" || name === "by_owner_publisher") {
|
|
return { take: async () => [] };
|
|
}
|
|
if (
|
|
name === "by_slug" ||
|
|
name === "by_owner_publisher_slug" ||
|
|
name === "by_owner_slug"
|
|
) {
|
|
return {
|
|
take: async () => [],
|
|
unique: async () => null,
|
|
};
|
|
}
|
|
throw new Error(`unexpected skillSlugAliases index ${name}`);
|
|
},
|
|
};
|
|
}
|
|
if (table === "skillEmbeddings") {
|
|
return {
|
|
withIndex: (name: string) => {
|
|
if (name !== "by_skill") {
|
|
throw new Error(`unexpected skillEmbeddings index ${name}`);
|
|
}
|
|
return { collect: async () => [] };
|
|
},
|
|
};
|
|
}
|
|
throw new Error(`unexpected table ${table}`);
|
|
}),
|
|
patch,
|
|
insert,
|
|
},
|
|
} as never,
|
|
{
|
|
actorUserId: "users:actor",
|
|
sourceSlug: "archive-demo",
|
|
targetSlug: "demo",
|
|
},
|
|
);
|
|
|
|
expect(result).toEqual({
|
|
ok: true,
|
|
sourceSlug: "archive-demo",
|
|
targetSlug: "demo",
|
|
});
|
|
expect(patch).toHaveBeenCalledWith(
|
|
"skills:target",
|
|
expect.objectContaining({
|
|
canonicalSkillId: undefined,
|
|
forkOf: undefined,
|
|
}),
|
|
);
|
|
const targetPatches = patch.mock.calls
|
|
.filter(([id]) => id === "skills:target")
|
|
.map(([, value]) => value as { canonicalSkillId?: string; forkOf?: { skillId?: string } });
|
|
expect(
|
|
targetPatches.some(
|
|
(value) =>
|
|
value.canonicalSkillId === "skills:target" || value.forkOf?.skillId === "skills:target",
|
|
),
|
|
).toBe(false);
|
|
expect(patch).toHaveBeenCalledWith(
|
|
"skills:source",
|
|
expect.objectContaining({
|
|
canonicalSkillId: "skills:target",
|
|
forkOf: expect.objectContaining({
|
|
skillId: "skills:target",
|
|
kind: "duplicate",
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("preserves source-owner redirects when merging across owner namespaces", async () => {
|
|
const patch = vi.fn(async () => {});
|
|
const insert = vi.fn(async () => "auditLogs:1");
|
|
const deleteAlias = vi.fn(async () => {});
|
|
const skills = [
|
|
{
|
|
_id: "skills:source",
|
|
slug: "merge-source",
|
|
displayName: "Merge Source",
|
|
ownerUserId: "users:creator",
|
|
ownerPublisherId: "publishers:alice",
|
|
moderationStatus: "hidden",
|
|
statsDownloads: 0,
|
|
statsStars: 0,
|
|
statsInstallsCurrent: 0,
|
|
statsInstallsAllTime: 0,
|
|
softDeletedAt: undefined,
|
|
},
|
|
{
|
|
_id: "skills:target",
|
|
slug: "merge-target",
|
|
displayName: "Merge Target",
|
|
ownerUserId: "users:creator",
|
|
ownerPublisherId: "publishers:team",
|
|
moderationStatus: "hidden",
|
|
statsDownloads: 0,
|
|
statsStars: 0,
|
|
statsInstallsCurrent: 0,
|
|
statsInstallsAllTime: 0,
|
|
softDeletedAt: undefined,
|
|
},
|
|
];
|
|
const aliases = [
|
|
{
|
|
_id: "skillSlugAliases:source-old",
|
|
slug: "shared-old",
|
|
skillId: "skills:source",
|
|
ownerUserId: "users:creator",
|
|
ownerPublisherId: "publishers:alice",
|
|
},
|
|
{
|
|
_id: "skillSlugAliases:team-old",
|
|
slug: "shared-old",
|
|
skillId: "skills:other",
|
|
ownerUserId: "users:creator",
|
|
ownerPublisherId: "publishers:team",
|
|
},
|
|
];
|
|
const publishers = [
|
|
{
|
|
_id: "publishers:alice",
|
|
kind: "org",
|
|
handle: "alice",
|
|
deletedAt: undefined,
|
|
deactivatedAt: undefined,
|
|
},
|
|
{
|
|
_id: "publishers:team",
|
|
kind: "org",
|
|
handle: "team",
|
|
deletedAt: undefined,
|
|
deactivatedAt: undefined,
|
|
},
|
|
];
|
|
|
|
const result = await mergeOwnedSkillIntoCanonicalInternalHandler(
|
|
{
|
|
db: {
|
|
normalizeId: vi.fn(() => null),
|
|
system: {},
|
|
get: vi.fn(async (id: string) => {
|
|
if (id === "users:actor") return { _id: "users:actor", role: "user" };
|
|
if (id === "users:creator") return { _id: "users:creator" };
|
|
return (
|
|
skills.find((skill) => skill._id === id) ??
|
|
publishers.find((publisher) => publisher._id === id) ??
|
|
null
|
|
);
|
|
}),
|
|
query: vi.fn((table: string) => {
|
|
if (table === "skills") {
|
|
return {
|
|
withIndex: (name: string, build: (q: ReturnType<typeof chainEq>) => unknown) => {
|
|
const constraints: Record<string, unknown> = {};
|
|
build(chainEq(constraints));
|
|
if (name === "by_owner_publisher_slug") {
|
|
return {
|
|
unique: async () =>
|
|
skills.find(
|
|
(skill) =>
|
|
skill.ownerPublisherId === constraints.ownerPublisherId &&
|
|
skill.slug === constraints.slug,
|
|
) ?? null,
|
|
};
|
|
}
|
|
if (name === "by_canonical" || name === "by_fork_of") {
|
|
return { collect: async () => [] };
|
|
}
|
|
throw new Error(`unexpected skills index ${name}`);
|
|
},
|
|
};
|
|
}
|
|
if (table === "publishers") {
|
|
return {
|
|
withIndex: (name: string, build: (q: ReturnType<typeof chainEq>) => unknown) => {
|
|
const constraints: Record<string, unknown> = {};
|
|
build(chainEq(constraints));
|
|
if (name !== "by_handle") {
|
|
throw new Error(`unexpected publishers index ${name}`);
|
|
}
|
|
return {
|
|
unique: async () =>
|
|
publishers.find((publisher) => publisher.handle === constraints.handle) ??
|
|
null,
|
|
};
|
|
},
|
|
};
|
|
}
|
|
if (table === "publisherMembers") {
|
|
return {
|
|
withIndex: (name: string) => {
|
|
if (name !== "by_publisher_user") {
|
|
throw new Error(`unexpected publisherMembers index ${name}`);
|
|
}
|
|
return {
|
|
unique: async () => ({
|
|
_id: "publisherMembers:1",
|
|
publisherId: "publishers:team",
|
|
userId: "users:actor",
|
|
role: "admin",
|
|
}),
|
|
};
|
|
},
|
|
};
|
|
}
|
|
if (table === "skillSlugAliases") {
|
|
return {
|
|
withIndex: (name: string, build: (q: ReturnType<typeof chainEq>) => unknown) => {
|
|
const constraints: Record<string, unknown> = {};
|
|
build(chainEq(constraints));
|
|
if (name === "by_skill") {
|
|
return {
|
|
take: async (limit: number) =>
|
|
aliases
|
|
.filter((alias) => alias.skillId === constraints.skillId)
|
|
.slice(0, limit),
|
|
};
|
|
}
|
|
if (name === "by_owner_publisher_slug") {
|
|
return {
|
|
unique: async () =>
|
|
aliases.find(
|
|
(alias) =>
|
|
alias.ownerPublisherId === constraints.ownerPublisherId &&
|
|
alias.slug === constraints.slug,
|
|
) ?? null,
|
|
};
|
|
}
|
|
if (name === "by_owner_publisher") {
|
|
return {
|
|
take: async () =>
|
|
aliases
|
|
.filter(
|
|
(alias) => alias.ownerPublisherId === constraints.ownerPublisherId,
|
|
)
|
|
.slice(0, 25),
|
|
};
|
|
}
|
|
if (name === "by_owner_slug") {
|
|
return {
|
|
take: async () =>
|
|
aliases
|
|
.filter(
|
|
(alias) =>
|
|
alias.ownerUserId === constraints.ownerUserId &&
|
|
alias.slug === constraints.slug,
|
|
)
|
|
.slice(0, 25),
|
|
};
|
|
}
|
|
throw new Error(`unexpected skillSlugAliases index ${name}`);
|
|
},
|
|
};
|
|
}
|
|
if (table === "skillEmbeddings") {
|
|
return {
|
|
withIndex: (name: string) => {
|
|
if (name !== "by_skill") {
|
|
throw new Error(`unexpected skillEmbeddings index ${name}`);
|
|
}
|
|
return { collect: async () => [] };
|
|
},
|
|
};
|
|
}
|
|
throw new Error(`unexpected table ${table}`);
|
|
}),
|
|
patch,
|
|
insert,
|
|
delete: deleteAlias,
|
|
},
|
|
} as never,
|
|
{
|
|
actorUserId: "users:actor",
|
|
sourceSlug: "merge-source",
|
|
targetSlug: "merge-target",
|
|
sourceOwnerHandle: "alice",
|
|
targetOwnerHandle: "team",
|
|
},
|
|
);
|
|
|
|
expect(result).toEqual({
|
|
ok: true,
|
|
sourceSlug: "merge-source",
|
|
targetSlug: "merge-target",
|
|
});
|
|
expect(patch).toHaveBeenCalledWith(
|
|
"skillSlugAliases:source-old",
|
|
expect.objectContaining({
|
|
skillId: "skills:target",
|
|
}),
|
|
);
|
|
expect(insert).toHaveBeenCalledWith(
|
|
"skillSlugAliases",
|
|
expect.objectContaining({
|
|
slug: "merge-source",
|
|
skillId: "skills:target",
|
|
ownerUserId: "users:creator",
|
|
ownerPublisherId: "publishers:alice",
|
|
}),
|
|
);
|
|
expect(deleteAlias).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("sets duplicate relationships by canonical skill id when slugs are ambiguous", async () => {
|
|
vi.mocked(getAuthUserId).mockResolvedValue("users:moderator" as never);
|
|
const patch = vi.fn(async () => {});
|
|
const insert = vi.fn(async () => "auditLogs:1");
|
|
const skills = [
|
|
{
|
|
_id: "skills:source",
|
|
slug: "demo",
|
|
ownerUserId: "users:alice",
|
|
ownerPublisherId: "publishers:alice",
|
|
},
|
|
{
|
|
_id: "skills:canonical",
|
|
slug: "demo",
|
|
ownerUserId: "users:team",
|
|
ownerPublisherId: "publishers:team",
|
|
latestVersionId: "skillVersions:canonical",
|
|
},
|
|
];
|
|
|
|
await setDuplicateHandler(
|
|
{
|
|
db: {
|
|
normalizeId: vi.fn(() => null),
|
|
system: {},
|
|
get: vi.fn(async (id: string) => {
|
|
if (id === "users:moderator") return { _id: id, role: "moderator" };
|
|
if (id === "skillVersions:canonical") return { _id: id, version: "2.0.0" };
|
|
return skills.find((skill) => skill._id === id) ?? null;
|
|
}),
|
|
query: vi.fn(),
|
|
patch,
|
|
insert,
|
|
},
|
|
} as never,
|
|
{ skillId: "skills:source", canonicalSkillId: "skills:canonical" },
|
|
);
|
|
|
|
expect(patch).toHaveBeenCalledWith(
|
|
"skills:source",
|
|
expect.objectContaining({
|
|
canonicalSkillId: "skills:canonical",
|
|
forkOf: expect.objectContaining({
|
|
skillId: "skills:canonical",
|
|
kind: "duplicate",
|
|
version: "2.0.0",
|
|
}),
|
|
}),
|
|
);
|
|
expect(insert).toHaveBeenCalledWith(
|
|
"auditLogs",
|
|
expect.objectContaining({
|
|
action: "skill.duplicate.set",
|
|
metadata: { canonicalSlug: "demo", canonicalSkillId: "skills:canonical" },
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("allows publisher admins to rename beyond the former historical alias quota", async () => {
|
|
const patch = vi.fn(async () => {});
|
|
const insert = vi.fn(async () => "skillSlugAliases:old");
|
|
const skill = {
|
|
_id: "skills:source",
|
|
slug: "old-name",
|
|
displayName: "Old Name",
|
|
ownerUserId: "users:creator",
|
|
ownerPublisherId: "publishers:org",
|
|
latestVersionId: "skillVersions:latest",
|
|
softDeletedAt: undefined,
|
|
};
|
|
const latestVersion = {
|
|
_id: "skillVersions:latest",
|
|
version: "1.0.0",
|
|
files: [{ path: "SKILL.md", size: 5, storageId: "storage:skill", sha256: "sha" }],
|
|
createdAt: 1_700_000_000_000,
|
|
softDeletedAt: undefined,
|
|
};
|
|
const aliases = Array.from({ length: 25 }, (_, index) => ({
|
|
_id: `skillSlugAliases:old-${index}`,
|
|
slug: `historical-name-${index}`,
|
|
skillId: "skills:source",
|
|
ownerUserId: "users:creator",
|
|
ownerPublisherId: "publishers:org",
|
|
}));
|
|
|
|
const result = await renameOwnedSkillInternalHandler(
|
|
{
|
|
scheduler: { runAfter: vi.fn(async () => {}) },
|
|
db: {
|
|
normalizeId: vi.fn(() => null),
|
|
get: vi.fn(async (id: string) => {
|
|
if (id === "users:actor") return { _id: "users:actor", role: "user" };
|
|
if (id === "publishers:org") {
|
|
return { _id: "publishers:org", kind: "org", handle: "org" };
|
|
}
|
|
if (id === "skillVersions:latest") return latestVersion;
|
|
return null;
|
|
}),
|
|
query: vi.fn((table: string) => {
|
|
if (table === "skills") {
|
|
return {
|
|
withIndex: (name: string, build: (q: ReturnType<typeof chainEq>) => unknown) => {
|
|
const constraints: Record<string, unknown> = {};
|
|
build(chainEq(constraints));
|
|
if (name === "by_owner_publisher_slug") {
|
|
return { unique: async () => null };
|
|
}
|
|
if (name !== "by_slug") throw new Error(`unexpected skills index ${name}`);
|
|
return {
|
|
take: async () => (constraints.slug === "old-name" ? [skill] : []),
|
|
unique: async () => (constraints.slug === "old-name" ? skill : null),
|
|
};
|
|
},
|
|
};
|
|
}
|
|
if (table === "publisherMembers") {
|
|
return {
|
|
withIndex: (name: string) => {
|
|
if (name !== "by_publisher_user") {
|
|
throw new Error(`unexpected publisherMembers index ${name}`);
|
|
}
|
|
return {
|
|
unique: async () => ({
|
|
_id: "publisherMembers:1",
|
|
publisherId: "publishers:org",
|
|
userId: "users:actor",
|
|
role: "admin",
|
|
}),
|
|
};
|
|
},
|
|
};
|
|
}
|
|
if (table === "skillSlugAliases") {
|
|
return {
|
|
withIndex: (name: string) => {
|
|
if (name === "by_owner_publisher_slug") return { unique: async () => null };
|
|
if (name === "by_owner_slug") return { unique: async () => null };
|
|
if (name === "by_slug") return { take: async () => [], unique: async () => null };
|
|
if (name === "by_skill") return { collect: async () => aliases };
|
|
if (name === "by_owner_publisher") return { take: async () => aliases };
|
|
throw new Error(`unexpected skillSlugAliases index ${name}`);
|
|
},
|
|
};
|
|
}
|
|
if (table === "reservedSlugs") {
|
|
return {
|
|
withIndex: () => ({
|
|
order: () => ({ take: async () => [] }),
|
|
}),
|
|
};
|
|
}
|
|
throw new Error(`unexpected table ${table}`);
|
|
}),
|
|
patch,
|
|
insert,
|
|
delete: vi.fn(),
|
|
},
|
|
} as never,
|
|
{
|
|
actorUserId: "users:actor",
|
|
slug: "old-name",
|
|
newSlug: "new-name",
|
|
},
|
|
);
|
|
|
|
expect(result).toEqual({ ok: true, slug: "new-name", previousSlug: "old-name" });
|
|
expect(patch).toHaveBeenCalledWith(
|
|
"skills:source",
|
|
expect.objectContaining({ slug: "new-name" }),
|
|
);
|
|
expect(insert).toHaveBeenCalledWith(
|
|
"skillSlugAliases",
|
|
expect.objectContaining({
|
|
slug: "old-name",
|
|
skillId: "skills:source",
|
|
ownerUserId: "users:creator",
|
|
ownerPublisherId: "publishers:org",
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("allows publisher admins to move a skill into an org they administer", async () => {
|
|
const patch = vi.fn(async () => {});
|
|
const deleteDoc = vi.fn(async () => {});
|
|
const insert = vi.fn(async () => "auditLogs:1");
|
|
const skill = {
|
|
_id: "skills:source",
|
|
slug: "portable",
|
|
displayName: "Portable",
|
|
ownerUserId: "users:actor",
|
|
ownerPublisherId: "publishers:personal",
|
|
softDeletedAt: undefined,
|
|
moderationVerdict: "clean",
|
|
moderationReasonCodes: ["suspicious.dynamic_code_execution"],
|
|
stats: defaultSkillStats,
|
|
};
|
|
const aliases = [
|
|
{
|
|
_id: "skillSlugAliases:old",
|
|
slug: "portable-old",
|
|
skillId: "skills:source",
|
|
ownerUserId: "users:actor",
|
|
ownerPublisherId: "publishers:personal",
|
|
},
|
|
{
|
|
_id: "skillSlugAliases:source-owned",
|
|
slug: "source-namespace-redirect",
|
|
skillId: "skills:source",
|
|
ownerUserId: "users:creator",
|
|
ownerPublisherId: "publishers:source",
|
|
},
|
|
];
|
|
|
|
const result = await transferSkillOwnerForUserInternalHandler(
|
|
{
|
|
db: {
|
|
normalizeId: vi.fn(() => null),
|
|
get: vi.fn(async (id: string) => {
|
|
if (id === "users:actor") return { _id: "users:actor", role: "user" };
|
|
if (id === "publishers:personal") {
|
|
return {
|
|
_id: "publishers:personal",
|
|
kind: "user",
|
|
handle: "actor",
|
|
linkedUserId: "users:actor",
|
|
};
|
|
}
|
|
if (id === "publishers:org") {
|
|
return {
|
|
_id: "publishers:org",
|
|
kind: "org",
|
|
handle: "team",
|
|
displayName: "Team",
|
|
};
|
|
}
|
|
return null;
|
|
}),
|
|
query: vi.fn((table: string) => {
|
|
if (table === "skills") {
|
|
return {
|
|
withIndex: (name: string, build: (q: ReturnType<typeof chainEq>) => unknown) => {
|
|
const constraints: Record<string, unknown> = {};
|
|
build(chainEq(constraints));
|
|
if (name !== "by_slug" && name !== "by_owner_publisher_slug") {
|
|
throw new Error(`unexpected skills index ${name}`);
|
|
}
|
|
return {
|
|
take: async () =>
|
|
name === "by_slug" && constraints.slug === "portable" ? [skill] : [],
|
|
unique: async () =>
|
|
name === "by_slug" && constraints.slug === "portable" ? skill : null,
|
|
};
|
|
},
|
|
};
|
|
}
|
|
if (table === "publishers") {
|
|
return {
|
|
withIndex: (name: string) => {
|
|
if (name !== "by_handle") throw new Error(`unexpected publishers index ${name}`);
|
|
return {
|
|
unique: async () => ({
|
|
_id: "publishers:org",
|
|
kind: "org",
|
|
handle: "team",
|
|
deletedAt: undefined,
|
|
deactivatedAt: undefined,
|
|
}),
|
|
};
|
|
},
|
|
};
|
|
}
|
|
if (table === "publisherMembers") {
|
|
return {
|
|
withIndex: (name: string) => {
|
|
if (name !== "by_publisher_user") {
|
|
throw new Error(`unexpected publisherMembers index ${name}`);
|
|
}
|
|
return {
|
|
unique: async () => ({
|
|
_id: "publisherMembers:1",
|
|
publisherId: "publishers:org",
|
|
userId: "users:actor",
|
|
role: "admin",
|
|
}),
|
|
};
|
|
},
|
|
};
|
|
}
|
|
if (table === "skillSlugAliases") {
|
|
return {
|
|
withIndex: (name: string, build?: (q: ReturnType<typeof chainEq>) => unknown) => {
|
|
const constraints: Record<string, unknown> = {};
|
|
build?.(chainEq(constraints));
|
|
if (
|
|
name !== "by_skill" &&
|
|
name !== "by_owner_publisher_slug" &&
|
|
name !== "by_slug"
|
|
) {
|
|
throw new Error(`unexpected skillSlugAliases index ${name}`);
|
|
}
|
|
return {
|
|
collect: async () => aliases,
|
|
unique: async () =>
|
|
name === "by_owner_publisher_slug" &&
|
|
constraints.ownerPublisherId === "publishers:org" &&
|
|
constraints.slug === "portable"
|
|
? {
|
|
_id: "skillSlugAliases:destination-conflict",
|
|
slug: "portable",
|
|
skillId: "skills:destination",
|
|
ownerUserId: "users:actor",
|
|
ownerPublisherId: "publishers:org",
|
|
}
|
|
: null,
|
|
};
|
|
},
|
|
};
|
|
}
|
|
if (table === "skillSearchDigest") {
|
|
return {
|
|
withIndex: (name: string) => {
|
|
if (name !== "by_skill") {
|
|
throw new Error(`unexpected skillSearchDigest index ${name}`);
|
|
}
|
|
return { unique: async () => ({ _id: "skillSearchDigest:source" }) };
|
|
},
|
|
};
|
|
}
|
|
throw new Error(`unexpected table ${table}`);
|
|
}),
|
|
patch,
|
|
delete: deleteDoc,
|
|
insert,
|
|
},
|
|
} as never,
|
|
{
|
|
actorUserId: "users:actor",
|
|
slug: "portable",
|
|
toOwner: "team",
|
|
},
|
|
);
|
|
|
|
expect(result).toEqual(
|
|
expect.objectContaining({
|
|
ok: true,
|
|
transferred: true,
|
|
skillSlug: "portable",
|
|
toPublisherHandle: "team",
|
|
}),
|
|
);
|
|
expect(patch).toHaveBeenCalledWith(
|
|
"skills:source",
|
|
expect.objectContaining({
|
|
ownerUserId: "users:actor",
|
|
ownerPublisherId: "publishers:org",
|
|
}),
|
|
);
|
|
expect(patch).not.toHaveBeenCalledWith("skillSlugAliases:old", expect.anything());
|
|
expect(patch).not.toHaveBeenCalledWith("skillSlugAliases:source-owned", expect.anything());
|
|
expect(deleteDoc).toHaveBeenCalledWith("skillSlugAliases:destination-conflict");
|
|
expect(patch).toHaveBeenCalledWith(
|
|
"skillSearchDigest:source",
|
|
expect.objectContaining({
|
|
ownerUserId: "users:actor",
|
|
ownerPublisherId: "publishers:org",
|
|
ownerHandle: "team",
|
|
ownerKind: "org",
|
|
}),
|
|
);
|
|
expect(insert).toHaveBeenCalledWith(
|
|
"auditLogs",
|
|
expect.objectContaining({
|
|
action: "skill.owner.transfer",
|
|
metadata: expect.objectContaining({
|
|
replacedDestinationAliasId: "skillSlugAliases:destination-conflict",
|
|
replacedDestinationAliasSkillId: "skills:destination",
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("rejects direct owner transfers when the destination already has the slug", async () => {
|
|
const patch = vi.fn(async () => {});
|
|
const insert = vi.fn(async () => "auditLogs:1");
|
|
const skill = {
|
|
_id: "skills:source",
|
|
slug: "portable",
|
|
displayName: "Portable",
|
|
ownerUserId: "users:actor",
|
|
ownerPublisherId: "publishers:personal",
|
|
softDeletedAt: undefined,
|
|
};
|
|
const destinationSkill = {
|
|
...skill,
|
|
_id: "skills:destination",
|
|
ownerPublisherId: "publishers:org",
|
|
};
|
|
|
|
await expect(
|
|
transferSkillOwnerForUserInternalHandler(
|
|
{
|
|
db: {
|
|
normalizeId: vi.fn(() => null),
|
|
get: vi.fn(async (id: string) => {
|
|
if (id === "users:actor") return { _id: "users:actor", role: "user" };
|
|
if (id === "publishers:personal") {
|
|
return {
|
|
_id: "publishers:personal",
|
|
kind: "user",
|
|
handle: "actor",
|
|
linkedUserId: "users:actor",
|
|
};
|
|
}
|
|
if (id === "publishers:org") {
|
|
return {
|
|
_id: "publishers:org",
|
|
kind: "org",
|
|
handle: "team",
|
|
displayName: "Team",
|
|
};
|
|
}
|
|
return null;
|
|
}),
|
|
query: vi.fn((table: string) => {
|
|
if (table === "skills") {
|
|
return {
|
|
withIndex: (name: string, build: (q: ReturnType<typeof chainEq>) => unknown) => {
|
|
const constraints: Record<string, unknown> = {};
|
|
build(chainEq(constraints));
|
|
if (name !== "by_slug" && name !== "by_owner_publisher_slug") {
|
|
throw new Error(`unexpected skills index ${name}`);
|
|
}
|
|
return {
|
|
take: async () => {
|
|
if (name === "by_slug" && constraints.slug === "portable") return [skill];
|
|
if (
|
|
name === "by_owner_publisher_slug" &&
|
|
constraints.slug === "portable" &&
|
|
constraints.ownerPublisherId === "publishers:org"
|
|
) {
|
|
return [destinationSkill];
|
|
}
|
|
return [];
|
|
},
|
|
unique: async () => {
|
|
if (name === "by_slug" && constraints.slug === "portable") return skill;
|
|
if (
|
|
name === "by_owner_publisher_slug" &&
|
|
constraints.slug === "portable" &&
|
|
constraints.ownerPublisherId === "publishers:org"
|
|
) {
|
|
return destinationSkill;
|
|
}
|
|
return null;
|
|
},
|
|
};
|
|
},
|
|
};
|
|
}
|
|
if (table === "publishers") {
|
|
return {
|
|
withIndex: (name: string) => {
|
|
if (name !== "by_handle")
|
|
throw new Error(`unexpected publishers index ${name}`);
|
|
return {
|
|
unique: async () => ({
|
|
_id: "publishers:org",
|
|
kind: "org",
|
|
handle: "team",
|
|
deletedAt: undefined,
|
|
deactivatedAt: undefined,
|
|
}),
|
|
};
|
|
},
|
|
};
|
|
}
|
|
if (table === "skillSlugAliases") {
|
|
return {
|
|
withIndex: (name: string) => {
|
|
if (name !== "by_skill" && name !== "by_slug") {
|
|
throw new Error(`unexpected skillSlugAliases index ${name}`);
|
|
}
|
|
return {
|
|
collect: async () => [],
|
|
take: async () => [],
|
|
};
|
|
},
|
|
};
|
|
}
|
|
if (table === "publisherMembers") {
|
|
return {
|
|
withIndex: (name: string) => {
|
|
if (name !== "by_publisher_user") {
|
|
throw new Error(`unexpected publisherMembers index ${name}`);
|
|
}
|
|
return {
|
|
unique: async () => ({
|
|
_id: "publisherMembers:1",
|
|
publisherId: "publishers:org",
|
|
userId: "users:actor",
|
|
role: "admin",
|
|
}),
|
|
};
|
|
},
|
|
};
|
|
}
|
|
if (table === "skillSlugAliases") {
|
|
return {
|
|
withIndex: (name: string) => {
|
|
if (name !== "by_slug") {
|
|
throw new Error(`unexpected skillSlugAliases index ${name}`);
|
|
}
|
|
return { take: async () => [] };
|
|
},
|
|
};
|
|
}
|
|
if (table === "skillSlugAliases") {
|
|
return {
|
|
withIndex: (name: string) => {
|
|
if (name !== "by_slug") {
|
|
throw new Error(`unexpected skillSlugAliases index ${name}`);
|
|
}
|
|
return { take: async () => [] };
|
|
},
|
|
};
|
|
}
|
|
if (table === "skillSlugAliases") {
|
|
return {
|
|
withIndex: (name: string) => {
|
|
if (name !== "by_slug") {
|
|
throw new Error(`unexpected skillSlugAliases index ${name}`);
|
|
}
|
|
return { take: async () => [] };
|
|
},
|
|
};
|
|
}
|
|
if (table === "skillSlugAliases") {
|
|
return {
|
|
withIndex: (name: string) => {
|
|
if (name !== "by_slug") {
|
|
throw new Error(`unexpected skillSlugAliases index ${name}`);
|
|
}
|
|
return { take: async () => [] };
|
|
},
|
|
};
|
|
}
|
|
throw new Error(`unexpected table ${table}`);
|
|
}),
|
|
patch,
|
|
insert,
|
|
},
|
|
} as never,
|
|
{
|
|
actorUserId: "users:actor",
|
|
slug: "portable",
|
|
toOwner: "team",
|
|
},
|
|
),
|
|
).rejects.toThrow(
|
|
'Destination owner @team already has skill "portable". Choose a different slug or publish without migrating ownership.',
|
|
);
|
|
|
|
expect(patch).not.toHaveBeenCalled();
|
|
expect(insert).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("allows platform admins to transfer a soft-deleted skill without restoring it", async () => {
|
|
const patch = vi.fn(async () => {});
|
|
const insert = vi.fn(async () => "auditLogs:1");
|
|
const softDeletedAt = 123;
|
|
const skill = {
|
|
_id: "skills:deleted",
|
|
slug: "deleted-demo",
|
|
displayName: "Deleted Demo",
|
|
ownerUserId: "users:owner",
|
|
ownerPublisherId: "publishers:previous",
|
|
softDeletedAt,
|
|
hiddenBy: "users:admin",
|
|
moderationStatus: "hidden",
|
|
moderationVerdict: "clean",
|
|
forkOf: undefined as
|
|
| {
|
|
skillId: string;
|
|
kind: "duplicate";
|
|
at: number;
|
|
}
|
|
| undefined,
|
|
stats: defaultSkillStats,
|
|
};
|
|
let hideAuditActorRole: "admin" | "moderator" | "user" = "user";
|
|
let ownerCurrentRole: "admin" | "moderator" | "user" = "user";
|
|
let hasMergeAudit = false;
|
|
|
|
const ctx = {
|
|
db: {
|
|
normalizeId: vi.fn(() => null),
|
|
get: vi.fn(async (id: string) => {
|
|
if (id === "users:admin") return { _id: "users:admin", role: "admin" };
|
|
if (id === "users:owner") return { _id: "users:owner", role: ownerCurrentRole };
|
|
if (id === "publishers:previous") {
|
|
return {
|
|
_id: "publishers:previous",
|
|
kind: "user",
|
|
handle: "owner",
|
|
linkedUserId: "users:owner",
|
|
};
|
|
}
|
|
if (id === "publishers:team") {
|
|
return {
|
|
_id: "publishers:team",
|
|
kind: "org",
|
|
handle: "team",
|
|
displayName: "Team",
|
|
};
|
|
}
|
|
return null;
|
|
}),
|
|
query: vi.fn((table: string) => {
|
|
if (table === "skills") {
|
|
return {
|
|
withIndex: (name: string, build: (q: ReturnType<typeof chainEq>) => unknown) => {
|
|
const constraints: Record<string, unknown> = {};
|
|
build(chainEq(constraints));
|
|
if (name !== "by_slug" && name !== "by_owner_publisher_slug") {
|
|
throw new Error(`unexpected skills index ${name}`);
|
|
}
|
|
return {
|
|
take: async () =>
|
|
name === "by_slug" && constraints.slug === "deleted-demo" ? [skill] : [],
|
|
unique: async () => null,
|
|
};
|
|
},
|
|
};
|
|
}
|
|
if (table === "publishers") {
|
|
return {
|
|
withIndex: (name: string) => {
|
|
if (name !== "by_handle") throw new Error(`unexpected publishers index ${name}`);
|
|
return {
|
|
unique: async () => ({
|
|
_id: "publishers:team",
|
|
kind: "org",
|
|
handle: "team",
|
|
displayName: "Team",
|
|
deletedAt: undefined,
|
|
deactivatedAt: undefined,
|
|
}),
|
|
};
|
|
},
|
|
};
|
|
}
|
|
if (table === "auditLogs") {
|
|
return {
|
|
withIndex: (name: string, build: (q: ReturnType<typeof chainEq>) => unknown) => {
|
|
const constraints: Record<string, unknown> = {};
|
|
build(chainEq(constraints));
|
|
if (name !== "by_target_createdAt") {
|
|
throw new Error(`unexpected auditLogs index ${name}`);
|
|
}
|
|
return {
|
|
take: async () => {
|
|
if (constraints.createdAt === softDeletedAt) {
|
|
return [
|
|
{
|
|
_id: "auditLogs:delete",
|
|
action: "skill.delete",
|
|
actorUserId: skill.hiddenBy,
|
|
targetType: "skill",
|
|
targetId: skill._id,
|
|
createdAt: softDeletedAt,
|
|
metadata: {
|
|
actorRole: hideAuditActorRole,
|
|
softDeletedAt,
|
|
},
|
|
},
|
|
];
|
|
}
|
|
const duplicate = skill.forkOf;
|
|
if (hasMergeAudit && duplicate && constraints.createdAt === duplicate.at) {
|
|
return [
|
|
{
|
|
_id: "auditLogs:merge",
|
|
action: "skill.merge",
|
|
actorUserId: skill.hiddenBy,
|
|
targetType: "skill",
|
|
targetId: skill._id,
|
|
createdAt: duplicate.at,
|
|
metadata: {
|
|
targetSkillId: duplicate.skillId,
|
|
},
|
|
},
|
|
];
|
|
}
|
|
return [];
|
|
},
|
|
};
|
|
},
|
|
};
|
|
}
|
|
if (table === "skillSlugAliases") {
|
|
return {
|
|
withIndex: (name: string) => {
|
|
if (
|
|
name !== "by_skill" &&
|
|
name !== "by_slug" &&
|
|
name !== "by_owner_publisher_slug" &&
|
|
name !== "by_owner_slug"
|
|
) {
|
|
throw new Error(`unexpected skillSlugAliases index ${name}`);
|
|
}
|
|
return { collect: async () => [], take: async () => [], unique: async () => null };
|
|
},
|
|
};
|
|
}
|
|
if (table === "skillEmbeddings") {
|
|
return {
|
|
withIndex: (name: string) => {
|
|
if (name !== "by_skill")
|
|
throw new Error(`unexpected skillEmbeddings index ${name}`);
|
|
return { collect: async () => [] };
|
|
},
|
|
};
|
|
}
|
|
if (table === "skillSearchDigest") {
|
|
return {
|
|
withIndex: (name: string) => {
|
|
if (name !== "by_skill") {
|
|
throw new Error(`unexpected skillSearchDigest index ${name}`);
|
|
}
|
|
return { unique: async () => ({ _id: "skillSearchDigest:deleted" }) };
|
|
},
|
|
};
|
|
}
|
|
throw new Error(`unexpected table ${table}`);
|
|
}),
|
|
patch,
|
|
insert,
|
|
},
|
|
} as never;
|
|
|
|
await expect(
|
|
transferSkillOwnerForUserInternalHandler(ctx, {
|
|
actorUserId: "users:admin",
|
|
slug: "deleted-demo",
|
|
toOwner: "team",
|
|
reason: "Publisher recovery",
|
|
}),
|
|
).rejects.toThrow("Skill is not eligible for ownership transfer while under moderation");
|
|
|
|
skill.hiddenBy = "users:owner";
|
|
|
|
hideAuditActorRole = "moderator";
|
|
await expect(
|
|
transferSkillOwnerForUserInternalHandler(ctx, {
|
|
actorUserId: "users:admin",
|
|
slug: "deleted-demo",
|
|
toOwner: "team",
|
|
reason: "Publisher recovery",
|
|
}),
|
|
).rejects.toThrow("Skill is not eligible for ownership transfer while under moderation");
|
|
|
|
hideAuditActorRole = "user";
|
|
skill.forkOf = {
|
|
skillId: "skills:canonical",
|
|
kind: "duplicate",
|
|
at: 100,
|
|
};
|
|
hasMergeAudit = true;
|
|
await expect(
|
|
transferSkillOwnerForUserInternalHandler(ctx, {
|
|
actorUserId: "users:admin",
|
|
slug: "deleted-demo",
|
|
toOwner: "team",
|
|
reason: "Publisher recovery",
|
|
}),
|
|
).rejects.toThrow("Skill is not eligible for ownership transfer while under moderation");
|
|
hasMergeAudit = false;
|
|
|
|
// Deletion-time audit provenance remains authoritative after a later staff promotion.
|
|
ownerCurrentRole = "admin";
|
|
await expect(
|
|
transferSkillOwnerForUserInternalHandler(ctx, {
|
|
actorUserId: "users:admin",
|
|
slug: "deleted-demo",
|
|
toOwner: "team",
|
|
}),
|
|
).rejects.toThrow("Reason required for soft-deleted skill ownership transfer");
|
|
|
|
const result = await transferSkillOwnerForUserInternalHandler(ctx, {
|
|
actorUserId: "users:admin",
|
|
slug: "deleted-demo",
|
|
toOwner: "team",
|
|
reason: "Publisher recovery",
|
|
});
|
|
|
|
expect(result).toMatchObject({
|
|
ok: true,
|
|
transferred: true,
|
|
skillSlug: "deleted-demo",
|
|
toPublisherHandle: "team",
|
|
});
|
|
expect(patch).toHaveBeenCalledWith(
|
|
"skills:deleted",
|
|
expect.objectContaining({
|
|
ownerUserId: "users:admin",
|
|
ownerPublisherId: "publishers:team",
|
|
}),
|
|
);
|
|
expect(patch).not.toHaveBeenCalledWith(
|
|
"skills:deleted",
|
|
expect.objectContaining({ softDeletedAt: undefined }),
|
|
);
|
|
});
|
|
|
|
it("rejects stale personal publisher memberships as skill transfer destinations", async () => {
|
|
const patch = vi.fn(async () => {});
|
|
const skill = {
|
|
_id: "skills:source",
|
|
slug: "portable",
|
|
displayName: "Portable",
|
|
ownerUserId: "users:actor",
|
|
ownerPublisherId: "publishers:actor",
|
|
softDeletedAt: undefined,
|
|
};
|
|
|
|
await expect(
|
|
transferSkillOwnerForUserInternalHandler(
|
|
{
|
|
db: {
|
|
normalizeId: vi.fn(() => null),
|
|
get: vi.fn(async (id: string) => {
|
|
if (id === "users:actor") return { _id: "users:actor", role: "user" };
|
|
if (id === "users:owner") return { _id: "users:owner", role: "user" };
|
|
if (id === "publishers:actor") {
|
|
return {
|
|
_id: "publishers:actor",
|
|
kind: "user",
|
|
handle: "actor",
|
|
linkedUserId: "users:actor",
|
|
};
|
|
}
|
|
if (id === "publishers:owner") {
|
|
return {
|
|
_id: "publishers:owner",
|
|
kind: "user",
|
|
handle: "owner",
|
|
linkedUserId: "users:owner",
|
|
deletedAt: undefined,
|
|
deactivatedAt: undefined,
|
|
};
|
|
}
|
|
return null;
|
|
}),
|
|
query: vi.fn((table: string) => {
|
|
if (table === "skills") {
|
|
return {
|
|
withIndex: (name: string, build: (q: ReturnType<typeof chainEq>) => unknown) => {
|
|
const constraints: Record<string, unknown> = {};
|
|
build(chainEq(constraints));
|
|
if (name !== "by_slug" && name !== "by_owner_publisher_slug") {
|
|
throw new Error(`unexpected skills index ${name}`);
|
|
}
|
|
return {
|
|
take: async () =>
|
|
name === "by_slug" && constraints.slug === "portable" ? [skill] : [],
|
|
unique: async () =>
|
|
name === "by_slug" && constraints.slug === "portable" ? skill : null,
|
|
};
|
|
},
|
|
};
|
|
}
|
|
if (table === "publishers") {
|
|
return {
|
|
withIndex: (name: string) => {
|
|
if (name !== "by_handle") {
|
|
throw new Error(`unexpected publishers index ${name}`);
|
|
}
|
|
return {
|
|
unique: async () => ({
|
|
_id: "publishers:owner",
|
|
kind: "user",
|
|
handle: "owner",
|
|
linkedUserId: "users:owner",
|
|
deletedAt: undefined,
|
|
deactivatedAt: undefined,
|
|
}),
|
|
};
|
|
},
|
|
};
|
|
}
|
|
if (table === "publisherMembers") {
|
|
return {
|
|
withIndex: (name: string) => {
|
|
if (name !== "by_publisher_user") {
|
|
throw new Error(`unexpected publisherMembers index ${name}`);
|
|
}
|
|
return {
|
|
unique: async () => ({
|
|
_id: "publisherMembers:stale",
|
|
publisherId: "publishers:owner",
|
|
userId: "users:actor",
|
|
role: "admin",
|
|
}),
|
|
};
|
|
},
|
|
};
|
|
}
|
|
if (table === "skillSlugAliases") {
|
|
return {
|
|
withIndex: (name: string) => {
|
|
if (name !== "by_slug") {
|
|
throw new Error(`unexpected skillSlugAliases index ${name}`);
|
|
}
|
|
return { take: async () => [] };
|
|
},
|
|
};
|
|
}
|
|
throw new Error(`unexpected table ${table}`);
|
|
}),
|
|
patch,
|
|
insert: vi.fn(),
|
|
},
|
|
} as never,
|
|
{
|
|
actorUserId: "users:actor",
|
|
slug: "portable",
|
|
toOwner: "owner",
|
|
},
|
|
),
|
|
).rejects.toThrow('admin access for "@owner"');
|
|
|
|
expect(patch).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("allows transfers into the actor's legacy no-link personal publisher", async () => {
|
|
const patch = vi.fn(async () => {});
|
|
const insert = vi.fn(async () => "auditLogs:1");
|
|
const skill = {
|
|
_id: "skills:source",
|
|
slug: "portable",
|
|
displayName: "Portable",
|
|
ownerUserId: "users:actor",
|
|
ownerPublisherId: "publishers:actor",
|
|
softDeletedAt: undefined,
|
|
stats: defaultSkillStats,
|
|
};
|
|
const aliases = [
|
|
{
|
|
_id: "skillSlugAliases:old",
|
|
slug: "portable-old",
|
|
skillId: "skills:source",
|
|
ownerUserId: "users:actor",
|
|
ownerPublisherId: "publishers:actor",
|
|
},
|
|
];
|
|
|
|
const result = await transferSkillOwnerForUserInternalHandler(
|
|
{
|
|
db: {
|
|
normalizeId: vi.fn(() => null),
|
|
get: vi.fn(async (id: string) => {
|
|
if (id === "users:actor") {
|
|
return {
|
|
_id: "users:actor",
|
|
role: "user",
|
|
personalPublisherId: "publishers:actor-legacy",
|
|
};
|
|
}
|
|
if (id === "publishers:actor") {
|
|
return {
|
|
_id: "publishers:actor",
|
|
kind: "user",
|
|
handle: "actor",
|
|
linkedUserId: "users:actor",
|
|
};
|
|
}
|
|
if (id === "publishers:actor-legacy") {
|
|
return {
|
|
_id: "publishers:actor-legacy",
|
|
kind: "user",
|
|
handle: "actor-legacy",
|
|
linkedUserId: undefined,
|
|
deletedAt: undefined,
|
|
deactivatedAt: undefined,
|
|
};
|
|
}
|
|
return null;
|
|
}),
|
|
query: vi.fn((table: string) => {
|
|
if (table === "skills") {
|
|
return {
|
|
withIndex: (name: string, build: (q: ReturnType<typeof chainEq>) => unknown) => {
|
|
const constraints: Record<string, unknown> = {};
|
|
build(chainEq(constraints));
|
|
if (name !== "by_slug" && name !== "by_owner_publisher_slug") {
|
|
throw new Error(`unexpected skills index ${name}`);
|
|
}
|
|
return {
|
|
take: async () =>
|
|
name === "by_slug" && constraints.slug === "portable" ? [skill] : [],
|
|
unique: async () =>
|
|
name === "by_slug" && constraints.slug === "portable" ? skill : null,
|
|
};
|
|
},
|
|
};
|
|
}
|
|
if (table === "publishers") {
|
|
return {
|
|
withIndex: (name: string) => {
|
|
if (name !== "by_handle") {
|
|
throw new Error(`unexpected publishers index ${name}`);
|
|
}
|
|
return {
|
|
unique: async () => ({
|
|
_id: "publishers:actor-legacy",
|
|
kind: "user",
|
|
handle: "actor-legacy",
|
|
linkedUserId: undefined,
|
|
deletedAt: undefined,
|
|
deactivatedAt: undefined,
|
|
}),
|
|
};
|
|
},
|
|
};
|
|
}
|
|
if (table === "users") {
|
|
return {
|
|
withIndex: (name: string) => {
|
|
if (name !== "handle") {
|
|
throw new Error(`unexpected users index ${name}`);
|
|
}
|
|
return { unique: async () => null };
|
|
},
|
|
};
|
|
}
|
|
if (table === "skillSlugAliases") {
|
|
return {
|
|
withIndex: (name: string) => {
|
|
if (
|
|
name !== "by_skill" &&
|
|
name !== "by_owner_publisher_slug" &&
|
|
name !== "by_slug"
|
|
) {
|
|
throw new Error(`unexpected skillSlugAliases index ${name}`);
|
|
}
|
|
return {
|
|
collect: async () => aliases,
|
|
unique: async () => null,
|
|
};
|
|
},
|
|
};
|
|
}
|
|
if (table === "skillSearchDigest") {
|
|
return {
|
|
withIndex: (name: string) => {
|
|
if (name !== "by_skill") {
|
|
throw new Error(`unexpected skillSearchDigest index ${name}`);
|
|
}
|
|
return { unique: async () => ({ _id: "skillSearchDigest:source" }) };
|
|
},
|
|
};
|
|
}
|
|
if (table === "skillSlugAliases") {
|
|
return {
|
|
withIndex: (name: string) => {
|
|
if (name !== "by_slug") {
|
|
throw new Error(`unexpected skillSlugAliases index ${name}`);
|
|
}
|
|
return { take: async () => [] };
|
|
},
|
|
};
|
|
}
|
|
throw new Error(`unexpected table ${table}`);
|
|
}),
|
|
patch,
|
|
insert,
|
|
},
|
|
} as never,
|
|
{
|
|
actorUserId: "users:actor",
|
|
slug: "portable",
|
|
toOwner: "actor-legacy",
|
|
},
|
|
);
|
|
|
|
expect(result).toMatchObject({
|
|
ok: true,
|
|
transferred: true,
|
|
toPublisherHandle: "actor-legacy",
|
|
});
|
|
expect(patch).toHaveBeenCalledWith(
|
|
"skills:source",
|
|
expect.objectContaining({
|
|
ownerUserId: "users:actor",
|
|
ownerPublisherId: "publishers:actor-legacy",
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("rejects direct owner transfers for skills under moderation", async () => {
|
|
const moderationStates = [
|
|
{ moderationStatus: "hidden" },
|
|
{ moderationStatus: "removed" },
|
|
{ moderationVerdict: "suspicious" },
|
|
{ moderationVerdict: "malicious" },
|
|
{ isSuspicious: true },
|
|
{ moderationFlags: ["flagged.suspicious"] },
|
|
{ moderationFlags: ["blocked.malware"] },
|
|
{ moderationReason: "scanner.llm.suspicious" },
|
|
{ moderationReasonCodes: ["suspicious.dynamic_code_execution"] },
|
|
{ moderationReasonCodes: ["malicious.crypto_mining"] },
|
|
];
|
|
|
|
for (const moderationState of moderationStates) {
|
|
const patch = vi.fn(async () => {});
|
|
const skill = {
|
|
_id: "skills:source",
|
|
slug: "portable",
|
|
displayName: "Portable",
|
|
ownerUserId: "users:actor",
|
|
ownerPublisherId: "publishers:personal",
|
|
softDeletedAt: undefined,
|
|
...moderationState,
|
|
};
|
|
|
|
await expect(
|
|
transferSkillOwnerForUserInternalHandler(
|
|
{
|
|
db: {
|
|
normalizeId: vi.fn(() => null),
|
|
get: vi.fn(async (id: string) => {
|
|
if (id === "users:actor") return { _id: "users:actor", role: "user" };
|
|
if (id === "publishers:personal") {
|
|
return {
|
|
_id: "publishers:personal",
|
|
kind: "user",
|
|
handle: "actor",
|
|
linkedUserId: "users:actor",
|
|
};
|
|
}
|
|
return null;
|
|
}),
|
|
query: vi.fn((table: string) => {
|
|
if (table === "skills") {
|
|
return {
|
|
withIndex: (
|
|
name: string,
|
|
build: (q: ReturnType<typeof chainEq>) => unknown,
|
|
) => {
|
|
const constraints: Record<string, unknown> = {};
|
|
build(chainEq(constraints));
|
|
if (name !== "by_slug") throw new Error(`unexpected skills index ${name}`);
|
|
return {
|
|
unique: async () => (constraints.slug === "portable" ? skill : null),
|
|
};
|
|
},
|
|
};
|
|
}
|
|
if (table === "skillSlugAliases") {
|
|
return {
|
|
withIndex: (name: string) => {
|
|
if (name !== "by_slug") {
|
|
throw new Error(`unexpected skillSlugAliases index ${name}`);
|
|
}
|
|
return { take: async () => [] };
|
|
},
|
|
};
|
|
}
|
|
throw new Error(`unexpected table ${table}`);
|
|
}),
|
|
patch,
|
|
insert: vi.fn(async () => "auditLogs:1"),
|
|
},
|
|
} as never,
|
|
{
|
|
actorUserId: "users:actor",
|
|
slug: "portable",
|
|
toOwner: "team",
|
|
},
|
|
),
|
|
).rejects.toThrow("under moderation");
|
|
|
|
expect(patch).not.toHaveBeenCalledWith("skills:source", expect.anything());
|
|
}
|
|
});
|
|
|
|
it("rejects admin owner changes for skills under moderation", async () => {
|
|
vi.mocked(getAuthUserId).mockResolvedValue("users:admin" as never);
|
|
const patch = vi.fn(async () => {});
|
|
|
|
await expect(
|
|
changeOwnerHandler(
|
|
{
|
|
db: {
|
|
normalizeId: vi.fn(() => null),
|
|
system: {},
|
|
get: vi.fn(async (id: string) => {
|
|
if (id === "users:admin") return { _id: "users:admin", role: "admin" };
|
|
if (id === "users:next") return { _id: "users:next", role: "user" };
|
|
if (id === "skills:source") {
|
|
return {
|
|
_id: "skills:source",
|
|
slug: "portable",
|
|
displayName: "Portable",
|
|
ownerUserId: "users:owner",
|
|
moderationReasonCodes: ["malicious.crypto_mining"],
|
|
};
|
|
}
|
|
return null;
|
|
}),
|
|
query: vi.fn(() => {
|
|
throw new Error("unexpected query");
|
|
}),
|
|
patch,
|
|
insert: vi.fn(async () => "auditLogs:1"),
|
|
},
|
|
} as never,
|
|
{
|
|
skillId: "skills:source",
|
|
ownerUserId: "users:next",
|
|
},
|
|
),
|
|
).rejects.toThrow("under moderation");
|
|
|
|
expect(patch).not.toHaveBeenCalledWith("skills:source", expect.anything());
|
|
});
|
|
|
|
it("checks direct transfer permissions before revealing moderation state", async () => {
|
|
const patch = vi.fn(async () => {});
|
|
const skill = {
|
|
_id: "skills:source",
|
|
slug: "portable",
|
|
displayName: "Portable",
|
|
ownerUserId: "users:owner",
|
|
ownerPublisherId: "publishers:personal",
|
|
softDeletedAt: undefined,
|
|
moderationStatus: "hidden",
|
|
};
|
|
|
|
await expect(
|
|
transferSkillOwnerForUserInternalHandler(
|
|
{
|
|
db: {
|
|
normalizeId: vi.fn(() => null),
|
|
get: vi.fn(async (id: string) => {
|
|
if (id === "users:actor") return { _id: "users:actor", role: "user" };
|
|
if (id === "publishers:personal") {
|
|
return {
|
|
_id: "publishers:personal",
|
|
kind: "user",
|
|
handle: "owner",
|
|
linkedUserId: "users:owner",
|
|
};
|
|
}
|
|
return null;
|
|
}),
|
|
query: vi.fn((table: string) => {
|
|
if (table === "skills") {
|
|
return {
|
|
withIndex: (name: string, build: (q: ReturnType<typeof chainEq>) => unknown) => {
|
|
const constraints: Record<string, unknown> = {};
|
|
build(chainEq(constraints));
|
|
if (name !== "by_slug") throw new Error(`unexpected skills index ${name}`);
|
|
return {
|
|
unique: async () => (constraints.slug === "portable" ? skill : null),
|
|
};
|
|
},
|
|
};
|
|
}
|
|
if (table === "publisherMembers") {
|
|
return {
|
|
withIndex: () => ({
|
|
unique: async () => null,
|
|
}),
|
|
};
|
|
}
|
|
if (table === "skillSlugAliases") {
|
|
return {
|
|
withIndex: (name: string) => {
|
|
if (name !== "by_slug") {
|
|
throw new Error(`unexpected skillSlugAliases index ${name}`);
|
|
}
|
|
return { take: async () => [] };
|
|
},
|
|
};
|
|
}
|
|
throw new Error(`unexpected table ${table}`);
|
|
}),
|
|
patch,
|
|
insert: vi.fn(async () => "auditLogs:1"),
|
|
},
|
|
} as never,
|
|
{
|
|
actorUserId: "users:actor",
|
|
slug: "portable",
|
|
toOwner: "team",
|
|
},
|
|
),
|
|
).rejects.toThrow("Forbidden");
|
|
|
|
expect(patch).not.toHaveBeenCalledWith("skills:source", expect.anything());
|
|
});
|
|
|
|
it("bounds aliases rewritten by a single merge transaction", async () => {
|
|
const patch = vi.fn(async () => {});
|
|
const insert = vi.fn(async () => "auditLogs:1");
|
|
const skills = [
|
|
{
|
|
_id: "skills:source",
|
|
slug: "merge-source",
|
|
displayName: "Merge Source",
|
|
ownerUserId: "users:actor",
|
|
moderationStatus: "hidden",
|
|
softDeletedAt: undefined,
|
|
},
|
|
{
|
|
_id: "skills:target",
|
|
slug: "merge-target",
|
|
displayName: "Merge Target",
|
|
ownerUserId: "users:actor",
|
|
moderationStatus: "hidden",
|
|
softDeletedAt: undefined,
|
|
},
|
|
];
|
|
const aliases = Array.from({ length: 201 }, (_, index) => ({
|
|
_id: `skillSlugAliases:target-${index}`,
|
|
slug: `target-old-${index}`,
|
|
skillId: "skills:target",
|
|
ownerUserId: "users:actor",
|
|
ownerPublisherId: undefined,
|
|
}));
|
|
|
|
await expect(
|
|
mergeOwnedSkillIntoCanonicalInternalHandler(
|
|
{
|
|
db: {
|
|
normalizeId: vi.fn(() => null),
|
|
system: {},
|
|
get: vi.fn(async (id: string) => {
|
|
if (id === "users:actor") return { _id: "users:actor", role: "user" };
|
|
return skills.find((skill) => skill._id === id) ?? null;
|
|
}),
|
|
query: vi.fn((table: string) => {
|
|
if (table === "skills") {
|
|
return {
|
|
withIndex: (name: string, build: (q: ReturnType<typeof chainEq>) => unknown) => {
|
|
const constraints: Record<string, unknown> = {};
|
|
build(chainEq(constraints));
|
|
if (name === "by_slug") {
|
|
return {
|
|
take: async () =>
|
|
skills.filter((skill) => skill.slug === constraints.slug).slice(0, 2),
|
|
unique: async () =>
|
|
skills.find((skill) => skill.slug === constraints.slug) ?? null,
|
|
};
|
|
}
|
|
if (name === "by_owner_publisher_slug") {
|
|
return { unique: async () => null };
|
|
}
|
|
if (name === "by_owner_slug") {
|
|
return {
|
|
take: async () =>
|
|
skills
|
|
.filter(
|
|
(skill) =>
|
|
skill.ownerUserId === constraints.ownerUserId &&
|
|
skill.slug === constraints.slug,
|
|
)
|
|
.slice(0, 25),
|
|
};
|
|
}
|
|
throw new Error(`unexpected skills index ${name}`);
|
|
},
|
|
};
|
|
}
|
|
if (table === "skillSlugAliases") {
|
|
return {
|
|
withIndex: (name: string, build: (q: ReturnType<typeof chainEq>) => unknown) => {
|
|
const constraints: Record<string, unknown> = {};
|
|
build(chainEq(constraints));
|
|
if (name === "by_skill") {
|
|
return {
|
|
take: async (limit: number) =>
|
|
aliases
|
|
.filter((alias) => alias.skillId === constraints.skillId)
|
|
.slice(0, limit),
|
|
};
|
|
}
|
|
if (name === "by_slug") {
|
|
return {
|
|
take: async () =>
|
|
aliases.filter((alias) => alias.slug === constraints.slug).slice(0, 2),
|
|
};
|
|
}
|
|
if (name === "by_owner_publisher_slug") {
|
|
return { unique: async () => null };
|
|
}
|
|
if (name === "by_owner_slug") {
|
|
return {
|
|
take: async () =>
|
|
aliases
|
|
.filter(
|
|
(alias) =>
|
|
alias.ownerUserId === constraints.ownerUserId &&
|
|
alias.slug === constraints.slug,
|
|
)
|
|
.slice(0, 25),
|
|
};
|
|
}
|
|
throw new Error(`unexpected skillSlugAliases index ${name}`);
|
|
},
|
|
};
|
|
}
|
|
throw new Error(`unexpected table ${table}`);
|
|
}),
|
|
patch,
|
|
insert,
|
|
},
|
|
} as never,
|
|
{
|
|
actorUserId: "users:actor",
|
|
sourceSlug: "merge-source",
|
|
targetSlug: "merge-target",
|
|
},
|
|
),
|
|
).rejects.toThrow(/cannot be merged in one transaction/);
|
|
|
|
expect(patch).not.toHaveBeenCalled();
|
|
expect(insert).not.toHaveBeenCalled();
|
|
});
|
|
});
|