update:增加文档模式
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
-- SQLite stores Prisma enums as TEXT, so AccessMode.none does not require a
|
||||
-- physical enum migration here. The only schema change needed is the display
|
||||
-- version override column on tools.
|
||||
ALTER TABLE "tools" ADD COLUMN "version_override" TEXT;
|
||||
BIN
server/prisma/prisma.zip
Normal file
BIN
server/prisma/prisma.zip
Normal file
Binary file not shown.
@@ -10,6 +10,7 @@ datasource db {
|
||||
enum AccessMode {
|
||||
web
|
||||
download
|
||||
none
|
||||
}
|
||||
|
||||
enum ToolStatus {
|
||||
@@ -45,6 +46,7 @@ model Tool {
|
||||
openCount Int @default(0) @map("open_count")
|
||||
accessMode AccessMode @default(download) @map("access_mode")
|
||||
openUrl String? @map("open_url")
|
||||
versionOverride String? @map("version_override")
|
||||
openInNewTab Boolean @default(true) @map("open_in_new_tab")
|
||||
latestArtifactId String? @map("latest_artifact_id")
|
||||
status ToolStatus @default(draft)
|
||||
|
||||
@@ -38,6 +38,7 @@ type ToolSeed = {
|
||||
openCount: number;
|
||||
accessMode: AccessMode;
|
||||
openUrl?: string;
|
||||
versionOverride?: string;
|
||||
openInNewTab?: boolean;
|
||||
status: ToolStatus;
|
||||
createdAt: Date;
|
||||
@@ -132,6 +133,7 @@ async function createToolWithRelations(tool: ToolSeed) {
|
||||
openCount: tool.openCount,
|
||||
accessMode: tool.accessMode,
|
||||
openUrl: tool.openUrl,
|
||||
versionOverride: tool.versionOverride,
|
||||
openInNewTab: tool.openInNewTab ?? true,
|
||||
status: tool.status,
|
||||
createdAt: tool.createdAt,
|
||||
@@ -251,6 +253,7 @@ async function main() {
|
||||
openCount: 642,
|
||||
accessMode: AccessMode.web,
|
||||
openUrl: 'https://platform.openai.com/playground',
|
||||
versionOverride: '2026.04',
|
||||
openInNewTab: true,
|
||||
status: ToolStatus.published,
|
||||
createdAt: daysAgo(45, 10, 0),
|
||||
@@ -282,6 +285,7 @@ async function main() {
|
||||
openCount: 438,
|
||||
accessMode: AccessMode.web,
|
||||
openUrl: 'https://claude.ai',
|
||||
versionOverride: '2026.03',
|
||||
openInNewTab: true,
|
||||
status: ToolStatus.published,
|
||||
createdAt: daysAgo(31, 11, 0),
|
||||
@@ -344,6 +348,7 @@ async function main() {
|
||||
openCount: 196,
|
||||
accessMode: AccessMode.web,
|
||||
openUrl: 'https://atlas.example.internal/runbooks',
|
||||
versionOverride: 'atlas-3.2',
|
||||
openInNewTab: false,
|
||||
status: ToolStatus.published,
|
||||
createdAt: daysAgo(19, 9, 0),
|
||||
@@ -379,6 +384,7 @@ async function main() {
|
||||
openCount: 153,
|
||||
accessMode: AccessMode.web,
|
||||
openUrl: 'https://www.notion.so',
|
||||
versionOverride: 'workflow-1.8',
|
||||
openInNewTab: true,
|
||||
status: ToolStatus.published,
|
||||
createdAt: daysAgo(12, 13, 30),
|
||||
@@ -413,6 +419,7 @@ async function main() {
|
||||
downloadCount: 182,
|
||||
openCount: 0,
|
||||
accessMode: AccessMode.download,
|
||||
versionOverride: 'stable-1.1',
|
||||
status: ToolStatus.published,
|
||||
createdAt: daysAgo(40, 10, 15),
|
||||
updatedAt: dateOnly(daysAgo(0)),
|
||||
@@ -515,6 +522,7 @@ async function main() {
|
||||
downloadCount: 89,
|
||||
openCount: 0,
|
||||
accessMode: AccessMode.download,
|
||||
versionOverride: 'stable-2.1',
|
||||
status: ToolStatus.published,
|
||||
createdAt: daysAgo(26, 9, 40),
|
||||
updatedAt: dateOnly(daysAgo(4)),
|
||||
@@ -569,6 +577,40 @@ async function main() {
|
||||
],
|
||||
latestArtifactId: 'art_202',
|
||||
},
|
||||
{
|
||||
id: 'tool_none_001',
|
||||
name: 'Prompt Catalog Preview',
|
||||
slug: 'prompt-catalog-preview',
|
||||
categoryId: 'cat_ai',
|
||||
description:
|
||||
'Preview-only catalog for upcoming internal prompt packs, curation notes, and launch plans.',
|
||||
rating: 4.1,
|
||||
downloadCount: 0,
|
||||
openCount: 0,
|
||||
accessMode: AccessMode.none,
|
||||
versionOverride: 'preview-2026.04',
|
||||
status: ToolStatus.published,
|
||||
createdAt: daysAgo(7, 15, 20),
|
||||
updatedAt: dateOnly(daysAgo(0)),
|
||||
features: [
|
||||
{
|
||||
id: 'feat_none_001',
|
||||
featureText: 'Upcoming prompt pack roadmap',
|
||||
sortOrder: 10,
|
||||
},
|
||||
{
|
||||
id: 'feat_none_002',
|
||||
featureText: 'Preview-only release notes',
|
||||
sortOrder: 20,
|
||||
},
|
||||
{
|
||||
id: 'feat_none_003',
|
||||
featureText: 'Owner and rollout visibility',
|
||||
sortOrder: 30,
|
||||
},
|
||||
],
|
||||
tagIds: ['tag_new', 'tag_team'],
|
||||
},
|
||||
{
|
||||
id: 'tool_draft_001',
|
||||
name: 'Local Agent Kit',
|
||||
|
||||
@@ -1,5 +1,26 @@
|
||||
const { spawn, spawnSync } = require('child_process');
|
||||
|
||||
const IS_WINDOWS = process.platform === 'win32';
|
||||
|
||||
function quoteWindowsArg(value) {
|
||||
const text = String(value ?? '');
|
||||
if (text.length === 0) {
|
||||
return '""';
|
||||
}
|
||||
|
||||
if (!/[\s"&()^<>|]/.test(text)) {
|
||||
return text;
|
||||
}
|
||||
|
||||
return `"${text
|
||||
.replace(/(\\*)"/g, '$1$1\\"')
|
||||
.replace(/(\\+)$/g, '$1$1')}"`;
|
||||
}
|
||||
|
||||
function buildWindowsCommand(command, args) {
|
||||
return [command, ...args].map(quoteWindowsArg).join(' ');
|
||||
}
|
||||
|
||||
function normalizeWrappedEnv(name) {
|
||||
const value = process.env[name];
|
||||
if (!value || value.length < 2) {
|
||||
@@ -14,11 +35,17 @@ function normalizeWrappedEnv(name) {
|
||||
}
|
||||
|
||||
function run(command, args, options = {}) {
|
||||
const result = spawnSync(command, args, {
|
||||
stdio: 'inherit',
|
||||
env: process.env,
|
||||
...options,
|
||||
});
|
||||
const result = IS_WINDOWS
|
||||
? spawnSync('cmd.exe', ['/d', '/s', '/c', buildWindowsCommand(command, args)], {
|
||||
stdio: 'inherit',
|
||||
env: process.env,
|
||||
...options,
|
||||
})
|
||||
: spawnSync(command, args, {
|
||||
stdio: 'inherit',
|
||||
env: process.env,
|
||||
...options,
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
throw result.error;
|
||||
@@ -29,23 +56,34 @@ function run(command, args, options = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
function launch(command, args, options = {}) {
|
||||
return IS_WINDOWS
|
||||
? spawn('cmd.exe', ['/d', '/s', '/c', buildWindowsCommand(command, args)], {
|
||||
stdio: 'inherit',
|
||||
env: process.env,
|
||||
...options,
|
||||
})
|
||||
: spawn(command, args, {
|
||||
stdio: 'inherit',
|
||||
env: process.env,
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
||||
function main() {
|
||||
if (!process.env.DATABASE_URL) {
|
||||
process.env.DATABASE_URL = 'file:./dev.db';
|
||||
}
|
||||
|
||||
normalizeWrappedEnv('DATABASE_URL');
|
||||
run(process.platform === 'win32' ? 'npx.cmd' : 'npx', ['prisma', 'migrate', 'deploy']);
|
||||
run('npx', ['prisma', 'migrate', 'deploy']);
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
if (args.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const child = spawn(args[0], args.slice(1), {
|
||||
stdio: 'inherit',
|
||||
env: process.env,
|
||||
});
|
||||
const child = launch(args[0], args.slice(1));
|
||||
|
||||
child.on('exit', (code, signal) => {
|
||||
if (signal) {
|
||||
|
||||
128
server/src/modules/access/access.service.spec.ts
Normal file
128
server/src/modules/access/access.service.spec.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import { AccessMode, ArtifactStatus, ToolStatus } from '@prisma/client';
|
||||
import { ERROR_CODES } from '../../common/constants/error-codes';
|
||||
import { AppException } from '../../common/exceptions/app.exception';
|
||||
import type { RequestWithContext } from '../../common/interfaces/request-with-context.interface';
|
||||
import { AccessService } from './access.service';
|
||||
|
||||
function createRequest(): RequestWithContext {
|
||||
return {
|
||||
headers: {
|
||||
'user-agent': 'Vitest',
|
||||
},
|
||||
ip: '127.0.0.1',
|
||||
} as RequestWithContext;
|
||||
}
|
||||
|
||||
function createTool(overrides = {}) {
|
||||
return {
|
||||
id: 'tool_demo',
|
||||
accessMode: AccessMode.web,
|
||||
openUrl: 'https://example.com/tool',
|
||||
openInNewTab: true,
|
||||
isDeleted: false,
|
||||
status: ToolStatus.published,
|
||||
latestArtifact: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('AccessService', () => {
|
||||
function createService() {
|
||||
const findFirst = jest.fn();
|
||||
const create = jest.fn();
|
||||
const prisma = {
|
||||
tool: {
|
||||
findFirst,
|
||||
},
|
||||
downloadTicket: {
|
||||
create,
|
||||
},
|
||||
};
|
||||
const configService = {
|
||||
get: jest.fn((key: string, fallback: number) => fallback),
|
||||
};
|
||||
|
||||
return {
|
||||
prisma,
|
||||
configService,
|
||||
service: new AccessService(prisma as never, configService as never),
|
||||
};
|
||||
}
|
||||
|
||||
it('rejects launch requests for none mode tools', async () => {
|
||||
const { prisma, service } = createService();
|
||||
prisma.tool.findFirst.mockResolvedValue(
|
||||
createTool({
|
||||
accessMode: AccessMode.none,
|
||||
openUrl: null,
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(
|
||||
service.launchTool(
|
||||
'tool_demo',
|
||||
{ channel: 'official', clientVersion: 'web-1.0.0' },
|
||||
createRequest(),
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
errorCode: ERROR_CODES.TOOL_ACCESS_MODE_MISMATCH,
|
||||
} satisfies Partial<AppException>);
|
||||
|
||||
await expect(
|
||||
service.launchTool(
|
||||
'tool_demo',
|
||||
{ channel: 'official', clientVersion: 'web-1.0.0' },
|
||||
createRequest(),
|
||||
),
|
||||
).rejects.toHaveProperty('status', 409);
|
||||
});
|
||||
|
||||
it('keeps web launch behavior unchanged for launchable tools', async () => {
|
||||
const { prisma, service } = createService();
|
||||
prisma.tool.findFirst.mockResolvedValue(
|
||||
createTool({
|
||||
accessMode: AccessMode.web,
|
||||
openUrl: 'https://example.com/tool',
|
||||
openInNewTab: false,
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(
|
||||
service.launchTool(
|
||||
'tool_demo',
|
||||
{ channel: 'official', clientVersion: 'web-1.0.0' },
|
||||
createRequest(),
|
||||
),
|
||||
).resolves.toMatchObject({
|
||||
mode: 'web',
|
||||
actionUrl: 'https://example.com/tool',
|
||||
openIn: 'same_tab',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps download ticket launches available for download tools', async () => {
|
||||
const { prisma, service } = createService();
|
||||
prisma.tool.findFirst.mockResolvedValue(
|
||||
createTool({
|
||||
accessMode: AccessMode.download,
|
||||
openUrl: null,
|
||||
latestArtifact: {
|
||||
id: 'art_demo',
|
||||
status: ArtifactStatus.active,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(
|
||||
service.launchTool(
|
||||
'tool_demo',
|
||||
{ channel: 'official', clientVersion: 'desktop-1.0.0' },
|
||||
createRequest(),
|
||||
),
|
||||
).resolves.toMatchObject({
|
||||
mode: 'download',
|
||||
actionUrl: expect.stringContaining('/api/v1/downloads/'),
|
||||
openIn: 'new_tab',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { HttpStatus, Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { ArtifactStatus, ToolStatus } from '@prisma/client';
|
||||
import { AccessMode, ArtifactStatus, ToolStatus } from '@prisma/client';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { ERROR_CODES } from '../../common/constants/error-codes';
|
||||
import { AppException } from '../../common/exceptions/app.exception';
|
||||
@@ -32,6 +32,14 @@ export class AccessService {
|
||||
throw new AppException(ERROR_CODES.NOT_FOUND, 'tool not found', HttpStatus.NOT_FOUND);
|
||||
}
|
||||
|
||||
if (tool.accessMode === AccessMode.none) {
|
||||
throw new AppException(
|
||||
ERROR_CODES.TOOL_ACCESS_MODE_MISMATCH,
|
||||
'tool is not launchable in none mode',
|
||||
HttpStatus.CONFLICT,
|
||||
);
|
||||
}
|
||||
|
||||
if (tool.accessMode === 'web') {
|
||||
if (!tool.openUrl) {
|
||||
throw new AppException(
|
||||
@@ -52,7 +60,7 @@ export class AccessService {
|
||||
return {
|
||||
mode: 'download' as const,
|
||||
actionUrl: tool.openUrl,
|
||||
openIn: tool.openInNewTab ? 'new_tab' : 'same_tab',
|
||||
openIn: 'new_tab' as const,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
111
server/src/modules/admin-overview/admin-overview.service.spec.ts
Normal file
111
server/src/modules/admin-overview/admin-overview.service.spec.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import { AccessMode, ArtifactStatus, ToolStatus } from '@prisma/client';
|
||||
import { AdminOverviewService } from './admin-overview.service';
|
||||
|
||||
describe('AdminOverviewService', () => {
|
||||
function createService() {
|
||||
const prisma = {
|
||||
category: {
|
||||
count: jest.fn().mockResolvedValue(4),
|
||||
},
|
||||
tag: {
|
||||
count: jest.fn().mockResolvedValue(8),
|
||||
},
|
||||
toolArtifact: {
|
||||
count: jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce(5)
|
||||
.mockResolvedValueOnce(3),
|
||||
},
|
||||
adminAuditLog: {
|
||||
count: jest.fn().mockResolvedValue(12),
|
||||
},
|
||||
tool: {
|
||||
count: jest.fn().mockResolvedValue(2),
|
||||
groupBy: jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce([
|
||||
{ status: ToolStatus.draft, _count: { _all: 1 } },
|
||||
{ status: ToolStatus.published, _count: { _all: 2 } },
|
||||
{ status: ToolStatus.archived, _count: { _all: 1 } },
|
||||
])
|
||||
.mockResolvedValueOnce([
|
||||
{ accessMode: AccessMode.download, _count: { _all: 2 } },
|
||||
{ accessMode: AccessMode.web, _count: { _all: 3 } },
|
||||
{ accessMode: AccessMode.none, _count: { _all: 1 } },
|
||||
]),
|
||||
aggregate: jest.fn().mockResolvedValue({
|
||||
_sum: {
|
||||
openCount: 23,
|
||||
downloadCount: 7,
|
||||
},
|
||||
}),
|
||||
findMany: jest.fn().mockResolvedValue([
|
||||
{
|
||||
id: 'tool_none',
|
||||
name: 'Prompt Catalog Preview',
|
||||
categoryId: 'cat_ai',
|
||||
category: {
|
||||
name: 'AI',
|
||||
},
|
||||
accessMode: AccessMode.none,
|
||||
openCount: 0,
|
||||
downloadCount: 0,
|
||||
modifiedAt: new Date('2026-04-14T00:00:00.000Z'),
|
||||
},
|
||||
{
|
||||
id: 'tool_web',
|
||||
name: 'OpenAI Playground',
|
||||
categoryId: 'cat_ai',
|
||||
category: {
|
||||
name: 'AI',
|
||||
},
|
||||
accessMode: AccessMode.web,
|
||||
openCount: 23,
|
||||
downloadCount: 0,
|
||||
modifiedAt: new Date('2026-04-14T00:00:00.000Z'),
|
||||
},
|
||||
]),
|
||||
},
|
||||
openRecord: {
|
||||
count: jest.fn(),
|
||||
},
|
||||
downloadRecord: {
|
||||
count: jest.fn(),
|
||||
},
|
||||
$transaction: jest
|
||||
.fn()
|
||||
.mockImplementation(async (operations: Array<Promise<number>>) => Promise.all(operations)),
|
||||
};
|
||||
|
||||
prisma.openRecord.count.mockResolvedValue(0);
|
||||
prisma.downloadRecord.count.mockResolvedValue(0);
|
||||
prisma.adminAuditLog.count
|
||||
.mockResolvedValueOnce(12)
|
||||
.mockResolvedValue(0);
|
||||
|
||||
return {
|
||||
prisma,
|
||||
service: new AdminOverviewService(prisma as never),
|
||||
};
|
||||
}
|
||||
|
||||
it('returns noneToolTotal in summary and keeps none mode separate from downloads', async () => {
|
||||
const { service } = createService();
|
||||
|
||||
await expect(service.getOverview()).resolves.toMatchObject({
|
||||
summary: expect.objectContaining({
|
||||
toolTotal: 4,
|
||||
webToolTotal: 3,
|
||||
downloadToolTotal: 2,
|
||||
noneToolTotal: 1,
|
||||
downloadReadyToolTotal: 2,
|
||||
}),
|
||||
topTools: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: 'tool_none',
|
||||
accessMode: AccessMode.none,
|
||||
}),
|
||||
]),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -141,6 +141,7 @@ export class AdminOverviewService {
|
||||
const modeStats = {
|
||||
web: 0,
|
||||
download: 0,
|
||||
none: 0,
|
||||
};
|
||||
accessModeBuckets.forEach((item) => {
|
||||
modeStats[item.accessMode] = item._count._all;
|
||||
@@ -219,6 +220,7 @@ export class AdminOverviewService {
|
||||
tagTotal,
|
||||
webToolTotal: modeStats.web,
|
||||
downloadToolTotal: modeStats.download,
|
||||
noneToolTotal: modeStats.none,
|
||||
downloadReadyToolTotal,
|
||||
openTotal,
|
||||
downloadTotal,
|
||||
|
||||
232
server/src/modules/admin-tools/admin-tools.service.spec.ts
Normal file
232
server/src/modules/admin-tools/admin-tools.service.spec.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
import { AccessMode, ArtifactStatus, Prisma, ToolStatus } from '@prisma/client';
|
||||
import { AdminToolsService } from './admin-tools.service';
|
||||
|
||||
function createToolEntity(overrides = {}) {
|
||||
return {
|
||||
id: 'tool_demo',
|
||||
name: 'Demo Tool',
|
||||
slug: 'demo-tool',
|
||||
categoryId: 'cat_dev',
|
||||
description: 'Demo description',
|
||||
rating: 4.2,
|
||||
status: ToolStatus.published,
|
||||
accessMode: AccessMode.download,
|
||||
openUrl: 'https://example.com/download',
|
||||
versionOverride: null,
|
||||
openInNewTab: true,
|
||||
downloadCount: 12,
|
||||
openCount: 34,
|
||||
updatedAt: '2026-04-14',
|
||||
createdAt: new Date('2026-04-14T00:00:00.000Z'),
|
||||
modifiedAt: new Date('2026-04-14T00:00:00.000Z'),
|
||||
category: {
|
||||
id: 'cat_dev',
|
||||
name: 'Developer Tools',
|
||||
},
|
||||
tags: [{ tag: { id: 'tag_cli', name: 'cli' } }],
|
||||
features: [{ featureText: 'Fast setup', sortOrder: 10 }],
|
||||
latestArtifact: {
|
||||
id: 'art_demo',
|
||||
version: '2.0.0',
|
||||
status: ArtifactStatus.active,
|
||||
fileName: 'demo.zip',
|
||||
fileSizeBytes: 4096,
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('AdminToolsService', () => {
|
||||
type ToolEntity = ReturnType<typeof createToolEntity>;
|
||||
type CountArgs = {
|
||||
where?: Prisma.ToolWhereInput;
|
||||
};
|
||||
type FindManyArgs = {
|
||||
where?: Prisma.ToolWhereInput;
|
||||
include?: Prisma.ToolInclude;
|
||||
orderBy?: Prisma.ToolOrderByWithRelationInput;
|
||||
skip?: number;
|
||||
take?: number;
|
||||
};
|
||||
type FindFirstArgs = {
|
||||
where?: Prisma.ToolWhereInput;
|
||||
include?: Prisma.ToolInclude;
|
||||
};
|
||||
type UpdateArgs = {
|
||||
where: { id: string };
|
||||
data: Prisma.ToolUpdateInput;
|
||||
};
|
||||
|
||||
function createService() {
|
||||
const count = jest.fn<Promise<number>, [CountArgs]>();
|
||||
const findMany = jest.fn<Promise<ToolEntity[]>, [FindManyArgs]>();
|
||||
const findFirst = jest.fn<Promise<ToolEntity | null>, [FindFirstArgs]>();
|
||||
const update = jest.fn<Promise<ToolEntity>, [UpdateArgs]>();
|
||||
const $transaction = jest
|
||||
.fn()
|
||||
.mockImplementation(async (operations: Array<Promise<unknown>>) => Promise.all(operations));
|
||||
const prisma = {
|
||||
$transaction,
|
||||
tool: {
|
||||
count,
|
||||
findMany,
|
||||
findFirst,
|
||||
update,
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
prisma,
|
||||
service: new AdminToolsService(prisma as never),
|
||||
};
|
||||
}
|
||||
|
||||
it('returns versionOverride and displayVersion for admin detail responses', async () => {
|
||||
const { prisma, service } = createService();
|
||||
prisma.tool.findFirst.mockResolvedValue(
|
||||
createToolEntity({
|
||||
accessMode: AccessMode.web,
|
||||
openUrl: 'https://example.com/tool',
|
||||
versionOverride: '2026.04',
|
||||
latestArtifact: null,
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(service.getToolById('tool_demo')).resolves.toMatchObject({
|
||||
id: 'tool_demo',
|
||||
accessMode: AccessMode.web,
|
||||
versionOverride: '2026.04',
|
||||
displayVersion: '2026.04',
|
||||
latestArtifact: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('prefers versionOverride over artifact version in admin list responses', async () => {
|
||||
const { prisma, service } = createService();
|
||||
prisma.tool.count.mockResolvedValue(1);
|
||||
prisma.tool.findMany.mockResolvedValue([
|
||||
createToolEntity({
|
||||
accessMode: AccessMode.download,
|
||||
versionOverride: 'hotfix-7',
|
||||
latestArtifact: {
|
||||
id: 'art_demo',
|
||||
version: '2.0.0',
|
||||
status: ArtifactStatus.active,
|
||||
fileName: 'demo.zip',
|
||||
fileSizeBytes: 4096,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
await expect(
|
||||
service.getTools({
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
} as never),
|
||||
).resolves.toMatchObject({
|
||||
list: [
|
||||
expect.objectContaining({
|
||||
accessMode: AccessMode.download,
|
||||
versionOverride: 'hotfix-7',
|
||||
displayVersion: 'hotfix-7',
|
||||
latestArtifact: expect.objectContaining({
|
||||
version: '2.0.0',
|
||||
}),
|
||||
}),
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('allows switching to none mode with an empty openUrl', async () => {
|
||||
const { prisma, service } = createService();
|
||||
prisma.tool.findFirst
|
||||
.mockResolvedValueOnce(
|
||||
createToolEntity({
|
||||
accessMode: AccessMode.web,
|
||||
openUrl: 'https://example.com/tool',
|
||||
versionOverride: 'preview-2',
|
||||
latestArtifact: null,
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
createToolEntity({
|
||||
accessMode: AccessMode.none,
|
||||
openUrl: null,
|
||||
openInNewTab: false,
|
||||
versionOverride: 'preview-2',
|
||||
latestArtifact: null,
|
||||
}),
|
||||
);
|
||||
prisma.tool.update.mockResolvedValue(
|
||||
createToolEntity({
|
||||
accessMode: AccessMode.none,
|
||||
openUrl: null,
|
||||
openInNewTab: false,
|
||||
versionOverride: 'preview-2',
|
||||
latestArtifact: null,
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(
|
||||
service.updateAccessMode('tool_demo', {
|
||||
accessMode: AccessMode.none,
|
||||
openUrl: null,
|
||||
openInNewTab: false,
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
accessMode: AccessMode.none,
|
||||
openUrl: null,
|
||||
versionOverride: 'preview-2',
|
||||
displayVersion: 'preview-2',
|
||||
});
|
||||
|
||||
expect(prisma.tool.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { id: 'tool_demo' },
|
||||
data: expect.objectContaining({
|
||||
accessMode: AccessMode.none,
|
||||
openUrl: null,
|
||||
openInNewTab: false,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('allows publishing a none mode tool without openUrl or artifacts', async () => {
|
||||
const { prisma, service } = createService();
|
||||
prisma.tool.findFirst
|
||||
.mockResolvedValueOnce(
|
||||
createToolEntity({
|
||||
status: ToolStatus.draft,
|
||||
accessMode: AccessMode.none,
|
||||
openUrl: null,
|
||||
versionOverride: 'preview-2',
|
||||
latestArtifact: null,
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
createToolEntity({
|
||||
status: ToolStatus.published,
|
||||
accessMode: AccessMode.none,
|
||||
openUrl: null,
|
||||
versionOverride: 'preview-2',
|
||||
latestArtifact: null,
|
||||
}),
|
||||
);
|
||||
prisma.tool.update.mockResolvedValue(
|
||||
createToolEntity({
|
||||
status: ToolStatus.published,
|
||||
accessMode: AccessMode.none,
|
||||
openUrl: null,
|
||||
versionOverride: 'preview-2',
|
||||
latestArtifact: null,
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(service.updateToolStatus('tool_demo', ToolStatus.published)).resolves.toMatchObject({
|
||||
status: ToolStatus.published,
|
||||
accessMode: AccessMode.none,
|
||||
displayVersion: 'preview-2',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -77,7 +77,9 @@ export class AdminToolsService {
|
||||
async createTool(body: CreateToolDto) {
|
||||
await this.assertCategoryExists(body.categoryId);
|
||||
await this.assertTagsExist(body.tags ?? []);
|
||||
const openUrl = this.normalizeOptionalUrl(body.openUrl);
|
||||
const openUrl =
|
||||
body.accessMode === AccessMode.none ? null : this.normalizeOptionalUrl(body.openUrl);
|
||||
const versionOverride = this.normalizeOptionalText(body.versionOverride);
|
||||
|
||||
if ((body.status ?? ToolStatus.draft) === ToolStatus.published) {
|
||||
this.assertPublishInput(body.accessMode, openUrl, undefined);
|
||||
@@ -97,6 +99,7 @@ export class AdminToolsService {
|
||||
rating: body.rating ?? 0,
|
||||
accessMode: body.accessMode,
|
||||
openUrl,
|
||||
versionOverride,
|
||||
openInNewTab: body.openInNewTab ?? true,
|
||||
status: body.status ?? ToolStatus.draft,
|
||||
updatedAt,
|
||||
@@ -159,10 +162,18 @@ export class AdminToolsService {
|
||||
const existingTool = await this.getToolEntity(id);
|
||||
const normalizedOpenUrl =
|
||||
body.openUrl !== undefined ? this.normalizeOptionalUrl(body.openUrl) : undefined;
|
||||
const normalizedVersionOverride =
|
||||
body.versionOverride !== undefined
|
||||
? this.normalizeOptionalText(body.versionOverride)
|
||||
: undefined;
|
||||
|
||||
const nextAccessMode = body.accessMode ?? existingTool.accessMode;
|
||||
const nextOpenUrl =
|
||||
normalizedOpenUrl !== undefined ? normalizedOpenUrl : existingTool.openUrl ?? null;
|
||||
nextAccessMode === AccessMode.none
|
||||
? null
|
||||
: normalizedOpenUrl !== undefined
|
||||
? normalizedOpenUrl
|
||||
: existingTool.openUrl ?? null;
|
||||
const nextStatus = body.status ?? existingTool.status;
|
||||
|
||||
if (body.categoryId) {
|
||||
@@ -188,7 +199,8 @@ export class AdminToolsService {
|
||||
description: body.description?.trim(),
|
||||
rating: body.rating,
|
||||
accessMode: body.accessMode,
|
||||
openUrl: normalizedOpenUrl,
|
||||
openUrl: nextAccessMode === AccessMode.none ? null : normalizedOpenUrl,
|
||||
versionOverride: normalizedVersionOverride,
|
||||
openInNewTab: body.openInNewTab,
|
||||
status: body.status,
|
||||
updatedAt,
|
||||
@@ -245,7 +257,11 @@ export class AdminToolsService {
|
||||
async updateAccessMode(id: string, body: UpdateAccessModeDto) {
|
||||
const tool = await this.getToolEntity(id);
|
||||
const nextOpenUrl =
|
||||
body.openUrl !== undefined ? this.normalizeOptionalUrl(body.openUrl) : tool.openUrl ?? null;
|
||||
body.accessMode === AccessMode.none
|
||||
? null
|
||||
: body.openUrl !== undefined
|
||||
? this.normalizeOptionalUrl(body.openUrl)
|
||||
: tool.openUrl ?? null;
|
||||
this.assertModeSwitchConstraint(
|
||||
tool.status,
|
||||
body.accessMode,
|
||||
@@ -369,6 +385,10 @@ export class AdminToolsService {
|
||||
return;
|
||||
}
|
||||
|
||||
if (accessMode === AccessMode.none) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (openUrl) {
|
||||
return;
|
||||
}
|
||||
@@ -389,6 +409,10 @@ export class AdminToolsService {
|
||||
tool: { latestArtifact?: { status: ArtifactStatus } | null },
|
||||
isSwitching = false,
|
||||
) {
|
||||
if (targetMode === AccessMode.none) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (targetMode === AccessMode.web && !openUrl) {
|
||||
throw new AppException(
|
||||
ERROR_CODES.WEB_OPEN_URL_NOT_CONFIGURED,
|
||||
@@ -422,6 +446,8 @@ export class AdminToolsService {
|
||||
};
|
||||
}>,
|
||||
) {
|
||||
const latestVersion = this.resolveLatestVersion(tool);
|
||||
|
||||
return {
|
||||
id: tool.id,
|
||||
name: tool.name,
|
||||
@@ -435,6 +461,8 @@ export class AdminToolsService {
|
||||
status: tool.status,
|
||||
accessMode: tool.accessMode,
|
||||
openUrl: tool.openUrl,
|
||||
versionOverride: tool.versionOverride,
|
||||
displayVersion: this.resolveDisplayVersion(tool),
|
||||
openInNewTab: tool.openInNewTab,
|
||||
downloadCount: tool.downloadCount,
|
||||
openCount: tool.openCount,
|
||||
@@ -453,6 +481,7 @@ export class AdminToolsService {
|
||||
})),
|
||||
features: tool.features.map((item) => item.featureText),
|
||||
updatedAt: tool.updatedAt,
|
||||
latestVersion,
|
||||
createdAt: tool.createdAt,
|
||||
modifiedAt: tool.modifiedAt,
|
||||
};
|
||||
@@ -496,4 +525,47 @@ export class AdminToolsService {
|
||||
const trimmed = String(value ?? '').trim();
|
||||
return trimmed ? trimmed : null;
|
||||
}
|
||||
|
||||
private normalizeOptionalText(value: string | null | undefined): string | null | undefined {
|
||||
if (value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const trimmed = String(value ?? '').trim();
|
||||
return trimmed ? trimmed : null;
|
||||
}
|
||||
|
||||
private resolveDisplayVersion(
|
||||
tool: Prisma.ToolGetPayload<{
|
||||
include: {
|
||||
category: true;
|
||||
tags: { include: { tag: true } };
|
||||
features: true;
|
||||
latestArtifact: true;
|
||||
};
|
||||
}>,
|
||||
): string | null {
|
||||
if (tool.versionOverride) {
|
||||
return tool.versionOverride;
|
||||
}
|
||||
|
||||
return this.resolveLatestVersion(tool);
|
||||
}
|
||||
|
||||
private resolveLatestVersion(
|
||||
tool: Prisma.ToolGetPayload<{
|
||||
include: {
|
||||
category: true;
|
||||
tags: { include: { tag: true } };
|
||||
features: true;
|
||||
latestArtifact: true;
|
||||
};
|
||||
}>,
|
||||
): string | null {
|
||||
if (tool.accessMode !== AccessMode.download || !tool.latestArtifact) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return tool.latestArtifact.version;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,6 +70,15 @@ export class CreateToolDto {
|
||||
})
|
||||
openUrl?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Optional display version override shown in admin and public pages',
|
||||
maxLength: 80,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(80)
|
||||
versionOverride?: string;
|
||||
|
||||
@ApiPropertyOptional({ default: true })
|
||||
@IsOptional()
|
||||
@Type(() => Boolean)
|
||||
|
||||
@@ -9,7 +9,8 @@ export class UpdateAccessModeDto {
|
||||
accessMode!: AccessMode;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Required when accessMode=web; optional external download URL when accessMode=download',
|
||||
description:
|
||||
'Required when accessMode=web; optional external download URL when accessMode=download; ignored when accessMode=none',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
|
||||
@@ -14,6 +14,7 @@ function createToolEntity(overrides = {}) {
|
||||
openCount: 42,
|
||||
accessMode: AccessMode.download,
|
||||
openUrl: 'https://example.com/download',
|
||||
versionOverride: null,
|
||||
updatedAt: '2026-04-11',
|
||||
isDeleted: false,
|
||||
status: 'published',
|
||||
@@ -37,11 +38,29 @@ describe('ToolsService', () => {
|
||||
type FindFirstArgs = {
|
||||
where?: Prisma.ToolWhereInput;
|
||||
};
|
||||
type CountArgs = {
|
||||
where?: Prisma.ToolWhereInput;
|
||||
};
|
||||
type FindManyArgs = {
|
||||
where?: Prisma.ToolWhereInput;
|
||||
include?: Prisma.ToolInclude;
|
||||
skip?: number;
|
||||
take?: number;
|
||||
orderBy?: Prisma.ToolOrderByWithRelationInput | Prisma.ToolOrderByWithRelationInput[];
|
||||
};
|
||||
|
||||
function createService() {
|
||||
const findFirst = jest.fn<Promise<ToolEntity | null>, [FindFirstArgs]>();
|
||||
const count = jest.fn<Promise<number>, [CountArgs]>();
|
||||
const findMany = jest.fn<Promise<ToolEntity[]>, [FindManyArgs]>();
|
||||
const $transaction = jest
|
||||
.fn()
|
||||
.mockImplementation(async (operations: Array<Promise<unknown>>) => Promise.all(operations));
|
||||
const prisma = {
|
||||
$transaction,
|
||||
tool: {
|
||||
count,
|
||||
findMany,
|
||||
findFirst,
|
||||
},
|
||||
};
|
||||
@@ -62,6 +81,7 @@ describe('ToolsService', () => {
|
||||
id: 'tool_demo',
|
||||
slug: 'demo-tool',
|
||||
name: 'Demo Tool',
|
||||
displayVersion: '2.0.0',
|
||||
latestVersion: '2.0.0',
|
||||
fileSize: 4096,
|
||||
downloadReady: true,
|
||||
@@ -111,9 +131,78 @@ describe('ToolsService', () => {
|
||||
await expect(
|
||||
service.getToolDetailBySlug('demo-tool'),
|
||||
).resolves.toMatchObject({
|
||||
displayVersion: null,
|
||||
downloadReady: false,
|
||||
latestVersion: null,
|
||||
fileSize: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns displayVersion from versionOverride for web tools', async () => {
|
||||
const { prisma, service } = createService();
|
||||
prisma.tool.findFirst.mockResolvedValue(
|
||||
createToolEntity({
|
||||
accessMode: AccessMode.web,
|
||||
versionOverride: '2026.04',
|
||||
latestArtifact: null,
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(service.getToolDetailBySlug('demo-tool')).resolves.toMatchObject({
|
||||
accessMode: AccessMode.web,
|
||||
displayVersion: '2026.04',
|
||||
latestVersion: null,
|
||||
downloadReady: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns displayVersion and none mode for non-launchable tools', async () => {
|
||||
const { prisma, service } = createService();
|
||||
prisma.tool.findFirst.mockResolvedValue(
|
||||
createToolEntity({
|
||||
accessMode: 'none' as AccessMode,
|
||||
openUrl: null,
|
||||
versionOverride: 'alpha-preview',
|
||||
latestArtifact: null,
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(service.getToolDetailBySlug('demo-tool')).resolves.toMatchObject({
|
||||
accessMode: 'none',
|
||||
displayVersion: 'alpha-preview',
|
||||
latestVersion: null,
|
||||
downloadReady: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns displayVersion for list items and keeps latestVersion for download tools', async () => {
|
||||
const { prisma, service } = createService();
|
||||
prisma.tool.count.mockResolvedValue(1);
|
||||
prisma.tool.findMany.mockResolvedValue([
|
||||
createToolEntity({
|
||||
accessMode: AccessMode.download,
|
||||
versionOverride: null,
|
||||
latestArtifact: {
|
||||
version: '3.1.4',
|
||||
fileSizeBytes: 2048,
|
||||
status: ArtifactStatus.active,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
await expect(
|
||||
service.getTools({
|
||||
page: 1,
|
||||
pageSize: 6,
|
||||
} as never),
|
||||
).resolves.toMatchObject({
|
||||
list: [
|
||||
expect.objectContaining({
|
||||
accessMode: AccessMode.download,
|
||||
displayVersion: '3.1.4',
|
||||
latestVersion: '3.1.4',
|
||||
}),
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -129,6 +129,7 @@ export class ToolsService {
|
||||
);
|
||||
const hasExternalDownloadUrl =
|
||||
tool.accessMode === 'download' && Boolean(tool.openUrl);
|
||||
const latestVersion = this.resolveLatestVersion(tool);
|
||||
|
||||
return {
|
||||
id: tool.id,
|
||||
@@ -142,10 +143,8 @@ export class ToolsService {
|
||||
rating: tool.rating,
|
||||
downloadCount: tool.downloadCount,
|
||||
openCount: tool.openCount,
|
||||
latestVersion:
|
||||
tool.accessMode === 'download' && tool.latestArtifact
|
||||
? tool.latestArtifact.version
|
||||
: null,
|
||||
displayVersion: this.resolveDisplayVersion(tool),
|
||||
latestVersion,
|
||||
accessMode: tool.accessMode,
|
||||
openUrl: tool.openUrl,
|
||||
hasArtifact: tool.accessMode === 'download' ? hasArtifact : false,
|
||||
@@ -160,6 +159,8 @@ export class ToolsService {
|
||||
}
|
||||
|
||||
private mapToolDetail(tool: ToolDetailEntity) {
|
||||
const latestVersion = this.resolveLatestVersion(tool);
|
||||
|
||||
return {
|
||||
id: tool.id,
|
||||
name: tool.name,
|
||||
@@ -177,10 +178,8 @@ export class ToolsService {
|
||||
features: tool.features.map((item) => item.featureText),
|
||||
updatedAt: tool.updatedAt,
|
||||
openUrl: tool.openUrl,
|
||||
latestVersion:
|
||||
tool.accessMode === 'download' && tool.latestArtifact
|
||||
? tool.latestArtifact.version
|
||||
: null,
|
||||
displayVersion: this.resolveDisplayVersion(tool),
|
||||
latestVersion,
|
||||
fileSize:
|
||||
tool.accessMode === 'download' && tool.latestArtifact
|
||||
? tool.latestArtifact.fileSizeBytes
|
||||
@@ -195,4 +194,22 @@ export class ToolsService {
|
||||
: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Keep version selection centralized so public list/detail responses always
|
||||
// expose the same display value regardless of tool access mode.
|
||||
private resolveDisplayVersion(tool: ToolDetailEntity): string | null {
|
||||
if (tool.versionOverride) {
|
||||
return tool.versionOverride;
|
||||
}
|
||||
|
||||
return this.resolveLatestVersion(tool);
|
||||
}
|
||||
|
||||
private resolveLatestVersion(tool: ToolDetailEntity): string | null {
|
||||
if (tool.accessMode !== 'download' || !tool.latestArtifact) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return tool.latestArtifact.version;
|
||||
}
|
||||
}
|
||||
|
||||
81
server/test/tool-launch-none.e2e-spec.ts
Normal file
81
server/test/tool-launch-none.e2e-spec.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { INestApplication } from '@nestjs/common';
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { AccessMode, ToolStatus } from '@prisma/client';
|
||||
import { randomUUID } from 'crypto';
|
||||
import request from 'supertest';
|
||||
import { AppModule } from '../src/app.module';
|
||||
import { PrismaService } from '../src/prisma/prisma.service';
|
||||
|
||||
describe('Tool launch none mode (e2e)', () => {
|
||||
let app: INestApplication;
|
||||
let prisma: PrismaService;
|
||||
let categoryId = '';
|
||||
let toolId = '';
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleFixture: TestingModule = await Test.createTestingModule({
|
||||
imports: [AppModule],
|
||||
}).compile();
|
||||
|
||||
app = moduleFixture.createNestApplication();
|
||||
await app.init();
|
||||
prisma = app.get(PrismaService);
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
categoryId = `cat_${randomUUID().replace(/-/g, '')}`;
|
||||
toolId = `tool_${randomUUID().replace(/-/g, '')}`;
|
||||
|
||||
await prisma.category.create({
|
||||
data: {
|
||||
id: categoryId,
|
||||
name: `Category ${toolId}`,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.tool.create({
|
||||
data: {
|
||||
id: toolId,
|
||||
name: 'Preview Tool',
|
||||
slug: `preview-${randomUUID().slice(0, 8)}`,
|
||||
categoryId,
|
||||
description: 'Preview only tool',
|
||||
accessMode: AccessMode.none,
|
||||
versionOverride: 'preview-1',
|
||||
status: ToolStatus.published,
|
||||
updatedAt: '2026-04-14',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await prisma.tool.deleteMany({
|
||||
where: {
|
||||
id: toolId,
|
||||
},
|
||||
});
|
||||
await prisma.category.deleteMany({
|
||||
where: {
|
||||
id: categoryId,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
function getHttpServer(): Parameters<typeof request>[0] {
|
||||
return app.getHttpServer() as Parameters<typeof request>[0];
|
||||
}
|
||||
|
||||
it('returns 409 when launching a none mode tool', async () => {
|
||||
await request(getHttpServer())
|
||||
.post(`/tools/${toolId}/launch`)
|
||||
.send({
|
||||
channel: 'official',
|
||||
clientVersion: 'web-1.0.0',
|
||||
})
|
||||
.expect(409);
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,8 @@ interface ToolDetailResponse {
|
||||
slug: string;
|
||||
name: string;
|
||||
description: string;
|
||||
accessMode: string;
|
||||
displayVersion: string | null;
|
||||
}
|
||||
|
||||
describe('Tools detail by slug (e2e)', () => {
|
||||
@@ -51,6 +53,7 @@ describe('Tools detail by slug (e2e)', () => {
|
||||
description: '# Manual\n\n## Install',
|
||||
accessMode: AccessMode.web,
|
||||
openUrl: 'https://example.com/tool',
|
||||
versionOverride: '2026.04',
|
||||
status: ToolStatus.published,
|
||||
updatedAt: '2026-04-11',
|
||||
},
|
||||
@@ -87,10 +90,46 @@ describe('Tools detail by slug (e2e)', () => {
|
||||
expect(body.slug).toBe(toolSlug);
|
||||
expect(body.name).toBe('Slug Detail Tool');
|
||||
expect(body.description).toContain('# Manual');
|
||||
expect(body.accessMode).toBe(AccessMode.web);
|
||||
expect(body.displayVersion).toBe('2026.04');
|
||||
});
|
||||
});
|
||||
|
||||
it('returns 404 for an unknown slug', async () => {
|
||||
await request(getHttpServer()).get('/tools/slug/missing-tool').expect(404);
|
||||
});
|
||||
|
||||
it('returns none mode tool detail with displayVersion', async () => {
|
||||
const noneToolId = `tool_${randomUUID().replace(/-/g, '')}`;
|
||||
const noneToolSlug = `none-tool-${randomUUID().slice(0, 8)}`;
|
||||
|
||||
await prisma.tool.create({
|
||||
data: {
|
||||
id: noneToolId,
|
||||
name: 'Preview Tool',
|
||||
slug: noneToolSlug,
|
||||
categoryId,
|
||||
description: '# Preview\n\n即将上线',
|
||||
accessMode: 'none' as AccessMode,
|
||||
versionOverride: 'preview-1',
|
||||
status: ToolStatus.published,
|
||||
updatedAt: '2026-04-11',
|
||||
},
|
||||
});
|
||||
|
||||
await request(getHttpServer())
|
||||
.get(`/tools/slug/${noneToolSlug}`)
|
||||
.expect(200)
|
||||
.expect(({ body }: { body: ToolDetailResponse }) => {
|
||||
expect(body.id).toBe(noneToolId);
|
||||
expect(body.accessMode).toBe('none');
|
||||
expect(body.displayVersion).toBe('preview-1');
|
||||
});
|
||||
|
||||
await prisma.tool.deleteMany({
|
||||
where: {
|
||||
id: noneToolId,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user