Compare commits
8 Commits
feat/proje
...
feat/compl
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6d2e3c2eb3 | ||
| ce21db3853 | |||
|
|
767725023e | ||
| 2bd1b55fe8 | |||
|
|
0f2a93f2f0 | ||
| ce81d9d616 | |||
|
|
c6cc39c6f7 | ||
| de074d9a90 |
@@ -3,12 +3,65 @@ _mcpctl() {
|
||||
_init_completion || return
|
||||
|
||||
local commands="status login logout config get describe delete logs create edit apply backup restore help"
|
||||
local global_opts="-v --version --daemon-url --direct -h --help"
|
||||
local project_commands="attach-server detach-server get describe delete logs create edit help"
|
||||
local global_opts="-v --version --daemon-url --direct --project -h --help"
|
||||
local resources="servers instances secrets templates projects users groups rbac"
|
||||
|
||||
case "${words[1]}" in
|
||||
# Check if --project was given
|
||||
local has_project=false
|
||||
local i
|
||||
for ((i=1; i < cword; i++)); do
|
||||
if [[ "${words[i]}" == "--project" ]]; then
|
||||
has_project=true
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
# Find the first subcommand (skip --project and its argument, skip flags)
|
||||
local subcmd=""
|
||||
local subcmd_pos=0
|
||||
for ((i=1; i < cword; i++)); do
|
||||
if [[ "${words[i]}" == "--project" || "${words[i]}" == "--daemon-url" ]]; then
|
||||
((i++)) # skip the argument
|
||||
continue
|
||||
fi
|
||||
if [[ "${words[i]}" != -* ]]; then
|
||||
subcmd="${words[i]}"
|
||||
subcmd_pos=$i
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
# Find the resource type after get/describe/delete/edit
|
||||
local resource_type=""
|
||||
if [[ -n "$subcmd_pos" ]] && [[ $subcmd_pos -gt 0 ]]; then
|
||||
for ((i=subcmd_pos+1; i < cword; i++)); do
|
||||
if [[ "${words[i]}" != -* ]] && [[ " $resources " == *" ${words[i]} "* ]]; then
|
||||
resource_type="${words[i]}"
|
||||
break
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
# If completing the --project value
|
||||
if [[ "$prev" == "--project" ]]; then
|
||||
local names
|
||||
names=$(mcpctl get projects -o json 2>/dev/null | grep -oP '"name":\s*"\K[^"]+')
|
||||
COMPREPLY=($(compgen -W "$names" -- "$cur"))
|
||||
return
|
||||
fi
|
||||
|
||||
# Fetch resource names dynamically
|
||||
_mcpctl_resource_names() {
|
||||
local rt="$1"
|
||||
if [[ -n "$rt" ]]; then
|
||||
mcpctl get "$rt" -o json 2>/dev/null | grep -oP '"name":\s*"\K[^"]+'
|
||||
fi
|
||||
}
|
||||
|
||||
case "$subcmd" in
|
||||
config)
|
||||
if [[ $cword -eq 2 ]]; then
|
||||
if [[ $((cword - subcmd_pos)) -eq 1 ]]; then
|
||||
COMPREPLY=($(compgen -W "view set path reset claude-generate impersonate help" -- "$cur"))
|
||||
fi
|
||||
return ;;
|
||||
@@ -20,35 +73,29 @@ _mcpctl() {
|
||||
return ;;
|
||||
logout)
|
||||
return ;;
|
||||
get)
|
||||
if [[ $cword -eq 2 ]]; then
|
||||
get|describe|delete)
|
||||
if [[ -z "$resource_type" ]]; then
|
||||
COMPREPLY=($(compgen -W "$resources" -- "$cur"))
|
||||
else
|
||||
COMPREPLY=($(compgen -W "-o --output -h --help" -- "$cur"))
|
||||
fi
|
||||
return ;;
|
||||
describe)
|
||||
if [[ $cword -eq 2 ]]; then
|
||||
COMPREPLY=($(compgen -W "$resources" -- "$cur"))
|
||||
else
|
||||
COMPREPLY=($(compgen -W "-o --output --show-values -h --help" -- "$cur"))
|
||||
fi
|
||||
return ;;
|
||||
delete)
|
||||
if [[ $cword -eq 2 ]]; then
|
||||
COMPREPLY=($(compgen -W "$resources" -- "$cur"))
|
||||
local names
|
||||
names=$(_mcpctl_resource_names "$resource_type")
|
||||
COMPREPLY=($(compgen -W "$names -o --output -h --help" -- "$cur"))
|
||||
fi
|
||||
return ;;
|
||||
edit)
|
||||
if [[ $cword -eq 2 ]]; then
|
||||
if [[ -z "$resource_type" ]]; then
|
||||
COMPREPLY=($(compgen -W "servers projects" -- "$cur"))
|
||||
else
|
||||
local names
|
||||
names=$(_mcpctl_resource_names "$resource_type")
|
||||
COMPREPLY=($(compgen -W "$names -h --help" -- "$cur"))
|
||||
fi
|
||||
return ;;
|
||||
logs)
|
||||
COMPREPLY=($(compgen -W "--tail --since -f --follow -h --help" -- "$cur"))
|
||||
return ;;
|
||||
create)
|
||||
if [[ $cword -eq 2 ]]; then
|
||||
if [[ $((cword - subcmd_pos)) -eq 1 ]]; then
|
||||
COMPREPLY=($(compgen -W "server secret project user group rbac help" -- "$cur"))
|
||||
fi
|
||||
return ;;
|
||||
@@ -61,13 +108,23 @@ _mcpctl() {
|
||||
restore)
|
||||
COMPREPLY=($(compgen -W "-i --input -p --password -c --conflict -h --help" -- "$cur"))
|
||||
return ;;
|
||||
attach-server|detach-server)
|
||||
local names
|
||||
names=$(_mcpctl_resource_names "servers")
|
||||
COMPREPLY=($(compgen -W "$names" -- "$cur"))
|
||||
return ;;
|
||||
help)
|
||||
COMPREPLY=($(compgen -W "$commands" -- "$cur"))
|
||||
return ;;
|
||||
esac
|
||||
|
||||
if [[ $cword -eq 1 ]]; then
|
||||
COMPREPLY=($(compgen -W "$commands $global_opts" -- "$cur"))
|
||||
# No subcommand yet — offer commands based on context
|
||||
if [[ -z "$subcmd" ]]; then
|
||||
if $has_project; then
|
||||
COMPREPLY=($(compgen -W "$project_commands $global_opts" -- "$cur"))
|
||||
else
|
||||
COMPREPLY=($(compgen -W "$commands $global_opts" -- "$cur"))
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# mcpctl fish completions
|
||||
|
||||
set -l commands status login logout config get describe delete logs create edit apply backup restore help
|
||||
set -l project_commands attach-server detach-server get describe delete logs create edit help
|
||||
|
||||
# Disable file completions by default
|
||||
complete -c mcpctl -f
|
||||
@@ -9,30 +10,112 @@ complete -c mcpctl -f
|
||||
complete -c mcpctl -s v -l version -d 'Show version'
|
||||
complete -c mcpctl -l daemon-url -d 'mcplocal daemon URL' -x
|
||||
complete -c mcpctl -l direct -d 'Bypass mcplocal, connect directly to mcpd'
|
||||
complete -c mcpctl -l project -d 'Target project context' -x
|
||||
complete -c mcpctl -s h -l help -d 'Show help'
|
||||
|
||||
# Top-level commands
|
||||
complete -c mcpctl -n "not __fish_seen_subcommand_from $commands" -a status -d 'Show status and connectivity'
|
||||
complete -c mcpctl -n "not __fish_seen_subcommand_from $commands" -a login -d 'Authenticate with mcpd'
|
||||
complete -c mcpctl -n "not __fish_seen_subcommand_from $commands" -a logout -d 'Log out'
|
||||
complete -c mcpctl -n "not __fish_seen_subcommand_from $commands" -a config -d 'Manage configuration'
|
||||
complete -c mcpctl -n "not __fish_seen_subcommand_from $commands" -a get -d 'List resources'
|
||||
complete -c mcpctl -n "not __fish_seen_subcommand_from $commands" -a describe -d 'Show resource details'
|
||||
complete -c mcpctl -n "not __fish_seen_subcommand_from $commands" -a delete -d 'Delete a resource'
|
||||
complete -c mcpctl -n "not __fish_seen_subcommand_from $commands" -a logs -d 'Get instance logs'
|
||||
complete -c mcpctl -n "not __fish_seen_subcommand_from $commands" -a create -d 'Create a resource'
|
||||
complete -c mcpctl -n "not __fish_seen_subcommand_from $commands" -a edit -d 'Edit a resource'
|
||||
complete -c mcpctl -n "not __fish_seen_subcommand_from $commands" -a apply -d 'Apply configuration from file'
|
||||
complete -c mcpctl -n "not __fish_seen_subcommand_from $commands" -a backup -d 'Backup configuration'
|
||||
complete -c mcpctl -n "not __fish_seen_subcommand_from $commands" -a restore -d 'Restore from backup'
|
||||
complete -c mcpctl -n "not __fish_seen_subcommand_from $commands" -a help -d 'Show help'
|
||||
# Helper: check if --project was given
|
||||
function __mcpctl_has_project
|
||||
set -l tokens (commandline -opc)
|
||||
for i in (seq (count $tokens))
|
||||
if test "$tokens[$i]" = "--project"
|
||||
return 0
|
||||
end
|
||||
end
|
||||
return 1
|
||||
end
|
||||
|
||||
# Resource types for get/describe/delete/edit
|
||||
# Helper: check if a resource type has been selected after get/describe/delete/edit
|
||||
set -l resources servers instances secrets templates projects users groups rbac
|
||||
complete -c mcpctl -n "__fish_seen_subcommand_from get describe delete" -a "$resources" -d 'Resource type'
|
||||
complete -c mcpctl -n "__fish_seen_subcommand_from edit" -a 'servers projects' -d 'Resource type'
|
||||
|
||||
# get/describe/delete options
|
||||
function __mcpctl_needs_resource_type
|
||||
set -l tokens (commandline -opc)
|
||||
set -l found_cmd false
|
||||
for tok in $tokens
|
||||
if $found_cmd
|
||||
# Check if next token after get/describe/delete/edit is a resource type
|
||||
if contains -- $tok servers instances secrets templates projects users groups rbac
|
||||
return 1 # resource type already present
|
||||
end
|
||||
end
|
||||
if contains -- $tok get describe delete edit
|
||||
set found_cmd true
|
||||
end
|
||||
end
|
||||
if $found_cmd
|
||||
return 0 # command found but no resource type yet
|
||||
end
|
||||
return 1
|
||||
end
|
||||
|
||||
function __mcpctl_get_resource_type
|
||||
set -l tokens (commandline -opc)
|
||||
set -l found_cmd false
|
||||
for tok in $tokens
|
||||
if $found_cmd
|
||||
if contains -- $tok servers instances secrets templates projects users groups rbac
|
||||
echo $tok
|
||||
return
|
||||
end
|
||||
end
|
||||
if contains -- $tok get describe delete edit
|
||||
set found_cmd true
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Fetch resource names dynamically from the API
|
||||
function __mcpctl_resource_names
|
||||
set -l resource (__mcpctl_get_resource_type)
|
||||
if test -z "$resource"
|
||||
return
|
||||
end
|
||||
# Use mcpctl to fetch names (quick JSON parse with string manipulation)
|
||||
mcpctl get $resource -o json 2>/dev/null | string match -rg '"name":\s*"([^"]+)"'
|
||||
end
|
||||
|
||||
# Fetch project names for --project value
|
||||
function __mcpctl_project_names
|
||||
mcpctl get projects -o json 2>/dev/null | string match -rg '"name":\s*"([^"]+)"'
|
||||
end
|
||||
|
||||
# --project value completion
|
||||
complete -c mcpctl -l project -xa '(__mcpctl_project_names)'
|
||||
|
||||
# Top-level commands (without --project)
|
||||
complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a status -d 'Show status and connectivity'
|
||||
complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a login -d 'Authenticate with mcpd'
|
||||
complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a logout -d 'Log out'
|
||||
complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a config -d 'Manage configuration'
|
||||
complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a get -d 'List resources'
|
||||
complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a describe -d 'Show resource details'
|
||||
complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a delete -d 'Delete a resource'
|
||||
complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a logs -d 'Get instance logs'
|
||||
complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a create -d 'Create a resource'
|
||||
complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a edit -d 'Edit a resource'
|
||||
complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a apply -d 'Apply configuration from file'
|
||||
complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a backup -d 'Backup configuration'
|
||||
complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a restore -d 'Restore from backup'
|
||||
complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a help -d 'Show help'
|
||||
|
||||
# Project-scoped commands (with --project)
|
||||
complete -c mcpctl -n "__mcpctl_has_project; and not __fish_seen_subcommand_from $project_commands" -a attach-server -d 'Attach a server to the project'
|
||||
complete -c mcpctl -n "__mcpctl_has_project; and not __fish_seen_subcommand_from $project_commands" -a detach-server -d 'Detach a server from the project'
|
||||
complete -c mcpctl -n "__mcpctl_has_project; and not __fish_seen_subcommand_from $project_commands" -a get -d 'List resources (scoped to project)'
|
||||
complete -c mcpctl -n "__mcpctl_has_project; and not __fish_seen_subcommand_from $project_commands" -a describe -d 'Show resource details'
|
||||
complete -c mcpctl -n "__mcpctl_has_project; and not __fish_seen_subcommand_from $project_commands" -a delete -d 'Delete a resource'
|
||||
complete -c mcpctl -n "__mcpctl_has_project; and not __fish_seen_subcommand_from $project_commands" -a logs -d 'Get instance logs'
|
||||
complete -c mcpctl -n "__mcpctl_has_project; and not __fish_seen_subcommand_from $project_commands" -a create -d 'Create a resource'
|
||||
complete -c mcpctl -n "__mcpctl_has_project; and not __fish_seen_subcommand_from $project_commands" -a edit -d 'Edit a resource'
|
||||
complete -c mcpctl -n "__mcpctl_has_project; and not __fish_seen_subcommand_from $project_commands" -a help -d 'Show help'
|
||||
|
||||
# Resource types — only when resource type not yet selected
|
||||
complete -c mcpctl -n "__fish_seen_subcommand_from get describe delete; and __mcpctl_needs_resource_type" -a "$resources" -d 'Resource type'
|
||||
complete -c mcpctl -n "__fish_seen_subcommand_from edit; and __mcpctl_needs_resource_type" -a 'servers projects' -d 'Resource type'
|
||||
|
||||
# Resource names — after resource type is selected
|
||||
complete -c mcpctl -n "__fish_seen_subcommand_from get describe delete edit; and not __mcpctl_needs_resource_type" -a '(__mcpctl_resource_names)' -d 'Resource name'
|
||||
|
||||
# get/describe options
|
||||
complete -c mcpctl -n "__fish_seen_subcommand_from get" -s o -l output -d 'Output format' -xa 'table json yaml'
|
||||
complete -c mcpctl -n "__fish_seen_subcommand_from describe" -s o -l output -d 'Output format' -xa 'detail json yaml'
|
||||
complete -c mcpctl -n "__fish_seen_subcommand_from describe" -l show-values -d 'Show secret values'
|
||||
|
||||
@@ -54,6 +54,21 @@ export function createProgram(): Command {
|
||||
}));
|
||||
|
||||
const fetchResource = async (resource: string, nameOrId?: string): Promise<unknown[]> => {
|
||||
const projectName = program.opts().project as string | undefined;
|
||||
|
||||
// --project scoping for servers and instances
|
||||
if (projectName && !nameOrId && (resource === 'servers' || resource === 'instances')) {
|
||||
const projectId = await resolveNameOrId(client, 'projects', projectName);
|
||||
if (resource === 'servers') {
|
||||
return client.get<unknown[]>(`/api/v1/projects/${projectId}/servers`);
|
||||
}
|
||||
// instances: fetch project servers, then filter instances by serverId
|
||||
const projectServers = await client.get<Array<{ id: string }>>(`/api/v1/projects/${projectId}/servers`);
|
||||
const serverIds = new Set(projectServers.map((s) => s.id));
|
||||
const allInstances = await client.get<Array<{ serverId: string }>>(`/api/v1/instances`);
|
||||
return allInstances.filter((inst) => serverIds.has(inst.serverId));
|
||||
}
|
||||
|
||||
if (nameOrId) {
|
||||
// Glob pattern — use query param filtering
|
||||
if (nameOrId.includes('*')) {
|
||||
@@ -133,8 +148,8 @@ export function createProgram(): Command {
|
||||
log: (...args: string[]) => console.log(...args),
|
||||
getProject: () => program.opts().project as string | undefined,
|
||||
};
|
||||
program.addCommand(createAttachServerCommand(projectOpsDeps));
|
||||
program.addCommand(createDetachServerCommand(projectOpsDeps));
|
||||
program.addCommand(createAttachServerCommand(projectOpsDeps), { hidden: true });
|
||||
program.addCommand(createDetachServerCommand(projectOpsDeps), { hidden: true });
|
||||
|
||||
return program;
|
||||
}
|
||||
|
||||
@@ -2,9 +2,9 @@ import type { FastifyInstance } from 'fastify';
|
||||
import type { ProjectService } from '../services/project.service.js';
|
||||
|
||||
export function registerProjectRoutes(app: FastifyInstance, service: ProjectService): void {
|
||||
app.get('/api/v1/projects', async (request) => {
|
||||
// If authenticated, filter by owner; otherwise list all
|
||||
return service.list(request.userId);
|
||||
app.get('/api/v1/projects', async () => {
|
||||
// RBAC preSerialization hook handles access filtering
|
||||
return service.list();
|
||||
});
|
||||
|
||||
app.get<{ Params: { id: string } }>('/api/v1/projects/:id', async (request) => {
|
||||
|
||||
283
src/mcpd/tests/project-routes.test.ts
Normal file
283
src/mcpd/tests/project-routes.test.ts
Normal file
@@ -0,0 +1,283 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import Fastify from 'fastify';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { registerProjectRoutes } from '../src/routes/projects.js';
|
||||
import { ProjectService } from '../src/services/project.service.js';
|
||||
import { errorHandler } from '../src/middleware/error-handler.js';
|
||||
import type { IProjectRepository, ProjectWithRelations } from '../src/repositories/project.repository.js';
|
||||
import type { IMcpServerRepository, ISecretRepository } from '../src/repositories/interfaces.js';
|
||||
|
||||
let app: FastifyInstance;
|
||||
|
||||
function makeProject(overrides: Partial<ProjectWithRelations> = {}): ProjectWithRelations {
|
||||
return {
|
||||
id: 'proj-1',
|
||||
name: 'test-project',
|
||||
description: '',
|
||||
ownerId: 'user-1',
|
||||
proxyMode: 'direct',
|
||||
llmProvider: null,
|
||||
llmModel: null,
|
||||
version: 1,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
servers: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function mockProjectRepo(): IProjectRepository {
|
||||
return {
|
||||
findAll: vi.fn(async () => []),
|
||||
findById: vi.fn(async () => null),
|
||||
findByName: vi.fn(async () => null),
|
||||
create: vi.fn(async (data) => makeProject({
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
ownerId: data.ownerId,
|
||||
proxyMode: data.proxyMode,
|
||||
})),
|
||||
update: vi.fn(async (_id, data) => makeProject({ ...data as Partial<ProjectWithRelations> })),
|
||||
delete: vi.fn(async () => {}),
|
||||
setServers: vi.fn(async () => {}),
|
||||
addServer: vi.fn(async () => {}),
|
||||
removeServer: vi.fn(async () => {}),
|
||||
};
|
||||
}
|
||||
|
||||
function mockServerRepo(): IMcpServerRepository {
|
||||
return {
|
||||
findAll: vi.fn(async () => []),
|
||||
findById: vi.fn(async () => null),
|
||||
findByName: vi.fn(async () => null),
|
||||
create: vi.fn(async () => ({} as never)),
|
||||
update: vi.fn(async () => ({} as never)),
|
||||
delete: vi.fn(async () => {}),
|
||||
};
|
||||
}
|
||||
|
||||
function mockSecretRepo(): ISecretRepository {
|
||||
return {
|
||||
findAll: vi.fn(async () => []),
|
||||
findById: vi.fn(async () => null),
|
||||
findByName: vi.fn(async () => null),
|
||||
create: vi.fn(async () => ({} as never)),
|
||||
update: vi.fn(async () => ({} as never)),
|
||||
delete: vi.fn(async () => {}),
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
if (app) await app.close();
|
||||
});
|
||||
|
||||
function createApp(projectRepo: IProjectRepository, serverRepo?: IMcpServerRepository) {
|
||||
app = Fastify({ logger: false });
|
||||
app.setErrorHandler(errorHandler);
|
||||
const service = new ProjectService(projectRepo, serverRepo ?? mockServerRepo(), mockSecretRepo());
|
||||
registerProjectRoutes(app, service);
|
||||
return app.ready();
|
||||
}
|
||||
|
||||
describe('Project Routes', () => {
|
||||
describe('GET /api/v1/projects', () => {
|
||||
it('returns project list', async () => {
|
||||
const repo = mockProjectRepo();
|
||||
vi.mocked(repo.findAll).mockResolvedValue([
|
||||
makeProject({ id: 'p1', name: 'alpha', ownerId: 'user-1' }),
|
||||
makeProject({ id: 'p2', name: 'beta', ownerId: 'user-2' }),
|
||||
]);
|
||||
await createApp(repo);
|
||||
|
||||
const res = await app.inject({ method: 'GET', url: '/api/v1/projects' });
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json<Array<{ name: string }>>();
|
||||
expect(body).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('lists all projects without ownerId filtering', async () => {
|
||||
// This is the bug fix: the route must call list() without ownerId
|
||||
// so that RBAC (preSerialization) handles access filtering, not the DB query.
|
||||
const repo = mockProjectRepo();
|
||||
vi.mocked(repo.findAll).mockResolvedValue([makeProject()]);
|
||||
await createApp(repo);
|
||||
|
||||
await app.inject({ method: 'GET', url: '/api/v1/projects' });
|
||||
// findAll must be called with NO arguments (undefined ownerId)
|
||||
expect(repo.findAll).toHaveBeenCalledWith(undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/v1/projects/:id', () => {
|
||||
it('returns 404 when not found', async () => {
|
||||
const repo = mockProjectRepo();
|
||||
await createApp(repo);
|
||||
const res = await app.inject({ method: 'GET', url: '/api/v1/projects/missing' });
|
||||
expect(res.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it('returns project when found by ID', async () => {
|
||||
const repo = mockProjectRepo();
|
||||
vi.mocked(repo.findById).mockResolvedValue(makeProject({ id: 'p1', name: 'my-proj' }));
|
||||
await createApp(repo);
|
||||
const res = await app.inject({ method: 'GET', url: '/api/v1/projects/p1' });
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json<{ name: string }>().name).toBe('my-proj');
|
||||
});
|
||||
|
||||
it('resolves by name when ID not found', async () => {
|
||||
const repo = mockProjectRepo();
|
||||
vi.mocked(repo.findByName).mockResolvedValue(makeProject({ name: 'my-proj' }));
|
||||
await createApp(repo);
|
||||
const res = await app.inject({ method: 'GET', url: '/api/v1/projects/my-proj' });
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json<{ name: string }>().name).toBe('my-proj');
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/v1/projects', () => {
|
||||
it('creates a project and returns 201', async () => {
|
||||
const repo = mockProjectRepo();
|
||||
vi.mocked(repo.findById).mockResolvedValue(makeProject({ name: 'new-proj' }));
|
||||
await createApp(repo);
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/projects',
|
||||
payload: { name: 'new-proj' },
|
||||
});
|
||||
expect(res.statusCode).toBe(201);
|
||||
});
|
||||
|
||||
it('returns 400 for invalid input', async () => {
|
||||
const repo = mockProjectRepo();
|
||||
await createApp(repo);
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/projects',
|
||||
payload: { name: '' },
|
||||
});
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it('returns 409 when name already exists', async () => {
|
||||
const repo = mockProjectRepo();
|
||||
vi.mocked(repo.findByName).mockResolvedValue(makeProject());
|
||||
await createApp(repo);
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/projects',
|
||||
payload: { name: 'taken' },
|
||||
});
|
||||
expect(res.statusCode).toBe(409);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /api/v1/projects/:id', () => {
|
||||
it('updates a project', async () => {
|
||||
const repo = mockProjectRepo();
|
||||
vi.mocked(repo.findById).mockResolvedValue(makeProject({ id: 'p1' }));
|
||||
await createApp(repo);
|
||||
const res = await app.inject({
|
||||
method: 'PUT',
|
||||
url: '/api/v1/projects/p1',
|
||||
payload: { description: 'Updated' },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
});
|
||||
|
||||
it('returns 404 when not found', async () => {
|
||||
const repo = mockProjectRepo();
|
||||
await createApp(repo);
|
||||
const res = await app.inject({
|
||||
method: 'PUT',
|
||||
url: '/api/v1/projects/missing',
|
||||
payload: { description: 'x' },
|
||||
});
|
||||
expect(res.statusCode).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /api/v1/projects/:id', () => {
|
||||
it('deletes a project and returns 204', async () => {
|
||||
const repo = mockProjectRepo();
|
||||
vi.mocked(repo.findById).mockResolvedValue(makeProject({ id: 'p1' }));
|
||||
await createApp(repo);
|
||||
const res = await app.inject({ method: 'DELETE', url: '/api/v1/projects/p1' });
|
||||
expect(res.statusCode).toBe(204);
|
||||
});
|
||||
|
||||
it('returns 404 when not found', async () => {
|
||||
const repo = mockProjectRepo();
|
||||
await createApp(repo);
|
||||
const res = await app.inject({ method: 'DELETE', url: '/api/v1/projects/missing' });
|
||||
expect(res.statusCode).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/v1/projects/:id/servers (attach)', () => {
|
||||
it('attaches a server to a project', async () => {
|
||||
const projectRepo = mockProjectRepo();
|
||||
const serverRepo = mockServerRepo();
|
||||
vi.mocked(projectRepo.findById).mockResolvedValue(makeProject({ id: 'p1' }));
|
||||
vi.mocked(serverRepo.findByName).mockResolvedValue({ id: 'srv-1', name: 'my-ha' } as never);
|
||||
await createApp(projectRepo, serverRepo);
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/projects/p1/servers',
|
||||
payload: { server: 'my-ha' },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(projectRepo.addServer).toHaveBeenCalledWith('p1', 'srv-1');
|
||||
});
|
||||
|
||||
it('returns 400 when server field is missing', async () => {
|
||||
const repo = mockProjectRepo();
|
||||
vi.mocked(repo.findById).mockResolvedValue(makeProject({ id: 'p1' }));
|
||||
await createApp(repo);
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/projects/p1/servers',
|
||||
payload: {},
|
||||
});
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it('returns 404 when server not found', async () => {
|
||||
const projectRepo = mockProjectRepo();
|
||||
vi.mocked(projectRepo.findById).mockResolvedValue(makeProject({ id: 'p1' }));
|
||||
await createApp(projectRepo);
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/projects/p1/servers',
|
||||
payload: { server: 'nonexistent' },
|
||||
});
|
||||
expect(res.statusCode).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /api/v1/projects/:id/servers/:serverName (detach)', () => {
|
||||
it('detaches a server from a project', async () => {
|
||||
const projectRepo = mockProjectRepo();
|
||||
const serverRepo = mockServerRepo();
|
||||
vi.mocked(projectRepo.findById).mockResolvedValue(makeProject({ id: 'p1' }));
|
||||
vi.mocked(serverRepo.findByName).mockResolvedValue({ id: 'srv-1', name: 'my-ha' } as never);
|
||||
await createApp(projectRepo, serverRepo);
|
||||
|
||||
const res = await app.inject({ method: 'DELETE', url: '/api/v1/projects/p1/servers/my-ha' });
|
||||
expect(res.statusCode).toBe(204);
|
||||
expect(projectRepo.removeServer).toHaveBeenCalledWith('p1', 'srv-1');
|
||||
});
|
||||
|
||||
it('returns 404 when server not found', async () => {
|
||||
const projectRepo = mockProjectRepo();
|
||||
vi.mocked(projectRepo.findById).mockResolvedValue(makeProject({ id: 'p1' }));
|
||||
await createApp(projectRepo);
|
||||
|
||||
const res = await app.inject({ method: 'DELETE', url: '/api/v1/projects/p1/servers/nonexistent' });
|
||||
expect(res.statusCode).toBe(404);
|
||||
});
|
||||
});
|
||||
});
|
||||
82
tests.sh
Executable file
82
tests.sh
Executable file
@@ -0,0 +1,82 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
PATH="$HOME/.npm-global/bin:$PATH"
|
||||
|
||||
SHORT=false
|
||||
FILTER=""
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--short|-s) SHORT=true; shift ;;
|
||||
--filter|-f) FILTER="$2"; shift 2 ;;
|
||||
*) echo "Usage: tests.sh [--short|-s] [--filter|-f <package>]"; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
strip_ansi() {
|
||||
sed $'s/\033\[[0-9;]*m//g'
|
||||
}
|
||||
|
||||
run_tests() {
|
||||
local pkg="$1"
|
||||
local label="$2"
|
||||
|
||||
if $SHORT; then
|
||||
local tmpfile
|
||||
tmpfile=$(mktemp)
|
||||
trap "rm -f $tmpfile" RETURN
|
||||
|
||||
local exit_code=0
|
||||
pnpm --filter "$pkg" test:run >"$tmpfile" 2>&1 || exit_code=$?
|
||||
|
||||
# Parse from cleaned output
|
||||
local clean
|
||||
clean=$(strip_ansi < "$tmpfile")
|
||||
|
||||
local tests_line files_line duration_line
|
||||
tests_line=$(echo "$clean" | grep -oP 'Tests\s+\K.*' | tail -1 | xargs)
|
||||
files_line=$(echo "$clean" | grep -oP 'Test Files\s+\K.*' | tail -1 | xargs)
|
||||
duration_line=$(echo "$clean" | grep -oP 'Duration\s+\K[0-9.]+s' | tail -1)
|
||||
|
||||
if [[ $exit_code -eq 0 ]]; then
|
||||
printf " \033[32mPASS\033[0m %-6s %s | %s | %s\n" "$label" "$files_line" "$tests_line" "$duration_line"
|
||||
else
|
||||
printf " \033[31mFAIL\033[0m %-6s %s | %s | %s\n" "$label" "$files_line" "$tests_line" "$duration_line"
|
||||
echo "$clean" | grep -E 'FAIL |AssertionError|expected .* to' | head -10 | sed 's/^/ /'
|
||||
fi
|
||||
|
||||
rm -f "$tmpfile"
|
||||
return $exit_code
|
||||
else
|
||||
echo "=== $label ==="
|
||||
pnpm --filter "$pkg" test:run
|
||||
echo ""
|
||||
fi
|
||||
}
|
||||
|
||||
if $SHORT; then
|
||||
echo "Running tests..."
|
||||
echo ""
|
||||
fi
|
||||
|
||||
failed=0
|
||||
|
||||
if [[ -z "$FILTER" || "$FILTER" == "mcpd" ]]; then
|
||||
run_tests mcpd "mcpd" || failed=1
|
||||
fi
|
||||
|
||||
if [[ -z "$FILTER" || "$FILTER" == "cli" ]]; then
|
||||
run_tests cli "cli" || failed=1
|
||||
fi
|
||||
|
||||
if $SHORT; then
|
||||
echo ""
|
||||
if [[ $failed -eq 0 ]]; then
|
||||
echo "All tests passed."
|
||||
else
|
||||
echo "Some tests FAILED."
|
||||
fi
|
||||
fi
|
||||
|
||||
exit $failed
|
||||
Reference in New Issue
Block a user