mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-11 03:13:12 +00:00
test(docker): cover docker subcommand rendering paths
- add handler-level coverage for up, down, logs, config, update, and status - exercise success, validation, follow-mode, and boxed error output paths
This commit is contained in:
1 parent
5ad4303345
commit
5b9954cfad
7 files changed
+430
-33
No files matched your search
@@ -0,0 +1,75 @@
|
|||||||
|
import { describe, expect, it } from 'bun:test';
|
||||||
|
import {
|
||||||
|
renderCapturedLines,
|
||||||
|
useDockerSubcommandConsoleCapture,
|
||||||
|
} from './docker-subcommand-test-helpers';
|
||||||
|
|
||||||
|
const capture = useDockerSubcommandConsoleCapture();
|
||||||
|
|
||||||
|
async function loadHandleConfig() {
|
||||||
|
const mod = await import(
|
||||||
|
`../../../src/commands/docker/config-subcommand?test=${Date.now()}-${Math.random()}`
|
||||||
|
);
|
||||||
|
return mod.handleConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('docker config subcommand', () => {
|
||||||
|
it('prints the derived Docker configuration table', async () => {
|
||||||
|
const dockerModule = (await import(
|
||||||
|
'../../../src/docker'
|
||||||
|
)) as typeof import('../../../src/docker');
|
||||||
|
const originalGetConfig = dockerModule.DockerExecutor.prototype.getConfig;
|
||||||
|
dockerModule.DockerExecutor.prototype.getConfig = function () {
|
||||||
|
return {
|
||||||
|
host: 'docker-box',
|
||||||
|
remote: true,
|
||||||
|
ccsDir: '/tmp/.ccs',
|
||||||
|
dockerDir: '/tmp/docker',
|
||||||
|
composeFile: '/tmp/docker/docker-compose.integrated.yml',
|
||||||
|
dockerfile: '/tmp/docker/Dockerfile.integrated',
|
||||||
|
supervisordConfig: '/tmp/docker/supervisord.conf',
|
||||||
|
entrypoint: '/tmp/docker/entrypoint-integrated.sh',
|
||||||
|
remoteDeployDir: '~/.ccs/docker',
|
||||||
|
composeService: 'ccs-cliproxy',
|
||||||
|
containerName: 'ccs-cliproxy',
|
||||||
|
dashboardPort: 3000,
|
||||||
|
proxyPort: 8317,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const handleConfig = await loadHandleConfig();
|
||||||
|
await handleConfig(['--host', 'docker-box']);
|
||||||
|
|
||||||
|
const rendered = renderCapturedLines(capture.logLines);
|
||||||
|
expect(rendered).toContain('remote (docker-box)');
|
||||||
|
expect(rendered).toContain('/tmp/docker/docker-compose.integrated.yml');
|
||||||
|
expect(rendered).toContain('ccs-cliproxy');
|
||||||
|
expect(capture.errorLines).toEqual([]);
|
||||||
|
expect(process.exitCode).toBe(0);
|
||||||
|
} finally {
|
||||||
|
dockerModule.DockerExecutor.prototype.getConfig = originalGetConfig;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders thrown config errors as boxed failures', async () => {
|
||||||
|
const dockerModule = (await import(
|
||||||
|
'../../../src/docker'
|
||||||
|
)) as typeof import('../../../src/docker');
|
||||||
|
const originalGetConfig = dockerModule.DockerExecutor.prototype.getConfig;
|
||||||
|
dockerModule.DockerExecutor.prototype.getConfig = function () {
|
||||||
|
throw new Error('Missing bundled Docker asset: /tmp/docker/Dockerfile.integrated');
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const handleConfig = await loadHandleConfig();
|
||||||
|
await handleConfig([]);
|
||||||
|
|
||||||
|
const rendered = renderCapturedLines(capture.errorLines);
|
||||||
|
expect(rendered).toContain('Missing bundled Docker asset');
|
||||||
|
expect(process.exitCode).toBe(1);
|
||||||
|
} finally {
|
||||||
|
dockerModule.DockerExecutor.prototype.getConfig = originalGetConfig;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { describe, expect, it } from 'bun:test';
|
||||||
|
import {
|
||||||
|
renderCapturedLines,
|
||||||
|
useDockerSubcommandConsoleCapture,
|
||||||
|
} from './docker-subcommand-test-helpers';
|
||||||
|
|
||||||
|
const capture = useDockerSubcommandConsoleCapture();
|
||||||
|
|
||||||
|
async function loadHandleDown() {
|
||||||
|
const mod = await import(
|
||||||
|
`../../../src/commands/docker/down-subcommand?test=${Date.now()}-${Math.random()}`
|
||||||
|
);
|
||||||
|
return mod.handleDown;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('docker down subcommand', () => {
|
||||||
|
it('prints progress and success output for remote shutdowns', async () => {
|
||||||
|
const calls: Array<{ host?: string }> = [];
|
||||||
|
const dockerModule = (await import(
|
||||||
|
'../../../src/docker'
|
||||||
|
)) as typeof import('../../../src/docker');
|
||||||
|
const originalDown = dockerModule.DockerExecutor.prototype.down;
|
||||||
|
dockerModule.DockerExecutor.prototype.down = function (options: { host?: string }) {
|
||||||
|
calls.push(options);
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const handleDown = await loadHandleDown();
|
||||||
|
await handleDown(['--host', 'docker-box']);
|
||||||
|
|
||||||
|
const rendered = renderCapturedLines(capture.logLines);
|
||||||
|
expect(calls).toEqual([{ host: 'docker-box' }]);
|
||||||
|
expect(rendered).toContain('Stopping Docker stack on docker-box...');
|
||||||
|
expect(rendered).toContain('Docker stack stopped on docker-box.');
|
||||||
|
expect(capture.errorLines).toEqual([]);
|
||||||
|
expect(process.exitCode).toBe(0);
|
||||||
|
} finally {
|
||||||
|
dockerModule.DockerExecutor.prototype.down = originalDown;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders executor failures as boxed errors', async () => {
|
||||||
|
const dockerModule = (await import(
|
||||||
|
'../../../src/docker'
|
||||||
|
)) as typeof import('../../../src/docker');
|
||||||
|
const originalDown = dockerModule.DockerExecutor.prototype.down;
|
||||||
|
dockerModule.DockerExecutor.prototype.down = function () {
|
||||||
|
throw new Error('Docker stack shutdown failed.\nCommand timed out after 30s.');
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const handleDown = await loadHandleDown();
|
||||||
|
await handleDown([]);
|
||||||
|
|
||||||
|
const rendered = renderCapturedLines(capture.errorLines);
|
||||||
|
expect(rendered).toContain('Docker stack shutdown failed.');
|
||||||
|
expect(rendered).toContain('Command timed out after 30s.');
|
||||||
|
expect(process.exitCode).toBe(1);
|
||||||
|
} finally {
|
||||||
|
dockerModule.DockerExecutor.prototype.down = originalDown;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { describe, expect, it } from 'bun:test';
|
||||||
|
import {
|
||||||
|
renderCapturedLines,
|
||||||
|
useDockerSubcommandConsoleCapture,
|
||||||
|
} from './docker-subcommand-test-helpers';
|
||||||
|
|
||||||
|
const capture = useDockerSubcommandConsoleCapture();
|
||||||
|
|
||||||
|
async function loadHandleLogs() {
|
||||||
|
const mod = await import(
|
||||||
|
`../../../src/commands/docker/logs-subcommand?test=${Date.now()}-${Math.random()}`
|
||||||
|
);
|
||||||
|
return mod.handleLogs;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('docker logs subcommand', () => {
|
||||||
|
it('prints log snapshots returned by the executor', async () => {
|
||||||
|
const calls: Array<{ host?: string; follow: boolean; service?: 'ccs' | 'cliproxy' }> = [];
|
||||||
|
const dockerModule = (await import(
|
||||||
|
'../../../src/docker'
|
||||||
|
)) as typeof import('../../../src/docker');
|
||||||
|
const originalLogs = dockerModule.DockerExecutor.prototype.logs;
|
||||||
|
dockerModule.DockerExecutor.prototype.logs = async function (options: {
|
||||||
|
host?: string;
|
||||||
|
follow: boolean;
|
||||||
|
service?: 'ccs' | 'cliproxy';
|
||||||
|
}) {
|
||||||
|
calls.push(options);
|
||||||
|
return '== ccs ==\nready';
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const handleLogs = await loadHandleLogs();
|
||||||
|
await handleLogs(['--service', 'ccs']);
|
||||||
|
|
||||||
|
expect(calls).toEqual([{ follow: false, service: 'ccs' }]);
|
||||||
|
expect(renderCapturedLines(capture.logLines)).toContain('== ccs ==\nready');
|
||||||
|
expect(capture.errorLines).toEqual([]);
|
||||||
|
expect(process.exitCode).toBe(0);
|
||||||
|
} finally {
|
||||||
|
dockerModule.DockerExecutor.prototype.logs = originalLogs;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('announces follow mode before streaming logs', async () => {
|
||||||
|
const calls: Array<{ host?: string; follow: boolean; service?: 'ccs' | 'cliproxy' }> = [];
|
||||||
|
const dockerModule = (await import(
|
||||||
|
'../../../src/docker'
|
||||||
|
)) as typeof import('../../../src/docker');
|
||||||
|
const originalLogs = dockerModule.DockerExecutor.prototype.logs;
|
||||||
|
dockerModule.DockerExecutor.prototype.logs = async function (options: {
|
||||||
|
host?: string;
|
||||||
|
follow: boolean;
|
||||||
|
service?: 'ccs' | 'cliproxy';
|
||||||
|
}) {
|
||||||
|
calls.push(options);
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const handleLogs = await loadHandleLogs();
|
||||||
|
await handleLogs(['--host', 'docker-box', '--follow', '--service', 'cliproxy']);
|
||||||
|
|
||||||
|
const rendered = renderCapturedLines(capture.logLines);
|
||||||
|
expect(calls).toEqual([{ host: 'docker-box', follow: true, service: 'cliproxy' }]);
|
||||||
|
expect(rendered).toContain('Following Docker logs on docker-box...');
|
||||||
|
expect(process.exitCode).toBe(0);
|
||||||
|
} finally {
|
||||||
|
dockerModule.DockerExecutor.prototype.logs = originalLogs;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders executor failures as boxed errors', async () => {
|
||||||
|
const dockerModule = (await import(
|
||||||
|
'../../../src/docker'
|
||||||
|
)) as typeof import('../../../src/docker');
|
||||||
|
const originalLogs = dockerModule.DockerExecutor.prototype.logs;
|
||||||
|
dockerModule.DockerExecutor.prototype.logs = async function () {
|
||||||
|
throw new Error('Docker log retrieval failed.\nContainer is not running.');
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const handleLogs = await loadHandleLogs();
|
||||||
|
await handleLogs([]);
|
||||||
|
|
||||||
|
const rendered = renderCapturedLines(capture.errorLines);
|
||||||
|
expect(rendered).toContain('Docker log retrieval failed.');
|
||||||
|
expect(rendered).toContain('Container is not running.');
|
||||||
|
expect(process.exitCode).toBe(1);
|
||||||
|
} finally {
|
||||||
|
dockerModule.DockerExecutor.prototype.logs = originalLogs;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test';
|
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
||||||
|
|
||||||
let logLines: string[] = [];
|
let logLines: string[] = [];
|
||||||
let errorLines: string[] = [];
|
let errorLines: string[] = [];
|
||||||
@@ -26,7 +26,6 @@ afterEach(() => {
|
|||||||
console.log = originalConsoleLog;
|
console.log = originalConsoleLog;
|
||||||
console.error = originalConsoleError;
|
console.error = originalConsoleError;
|
||||||
process.exitCode = originalExitCode ?? 0;
|
process.exitCode = originalExitCode ?? 0;
|
||||||
mock.restore();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
async function loadHandleStatus() {
|
async function loadHandleStatus() {
|
||||||
@@ -38,38 +37,42 @@ async function loadHandleStatus() {
|
|||||||
|
|
||||||
describe('docker status subcommand', () => {
|
describe('docker status subcommand', () => {
|
||||||
it('prints supervisor failure details instead of masking them as a missing container', async () => {
|
it('prints supervisor failure details instead of masking them as a missing container', async () => {
|
||||||
mock.module('../../../src/docker', () => ({
|
const dockerModule = (await import(
|
||||||
DockerExecutor: class {
|
'../../../src/docker'
|
||||||
async status() {
|
)) as typeof import('../../../src/docker');
|
||||||
return {
|
const originalStatus = dockerModule.DockerExecutor.prototype.status;
|
||||||
compose: {
|
dockerModule.DockerExecutor.prototype.status = function () {
|
||||||
command: '',
|
return {
|
||||||
exitCode: 0,
|
compose: {
|
||||||
stdout: 'NAME STATUS',
|
command: '',
|
||||||
stderr: '',
|
exitCode: 0,
|
||||||
remote: false,
|
stdout: 'NAME STATUS',
|
||||||
},
|
stderr: '',
|
||||||
supervisor: {
|
remote: false,
|
||||||
command: '',
|
},
|
||||||
exitCode: 7,
|
supervisor: {
|
||||||
stdout: '',
|
command: '',
|
||||||
stderr: 'unix:///var/run/supervisor.sock no such file',
|
exitCode: 7,
|
||||||
remote: false,
|
stdout: '',
|
||||||
},
|
stderr: 'unix:///var/run/supervisor.sock no such file',
|
||||||
};
|
remote: false,
|
||||||
}
|
},
|
||||||
},
|
};
|
||||||
}));
|
};
|
||||||
|
|
||||||
const handleStatus = await loadHandleStatus();
|
try {
|
||||||
await handleStatus([]);
|
const handleStatus = await loadHandleStatus();
|
||||||
|
await handleStatus([]);
|
||||||
|
|
||||||
const rendered = logLines.join('\n');
|
const rendered = logLines.join('\n');
|
||||||
expect(rendered).toContain('Docker status');
|
expect(rendered).toContain('Docker status');
|
||||||
expect(rendered).toContain('Supervisor status check failed');
|
expect(rendered).toContain('Supervisor status check failed');
|
||||||
expect(rendered).toContain('supervisor.sock no such file');
|
expect(rendered).toContain('supervisor.sock no such file');
|
||||||
expect(rendered).not.toContain('may not be running');
|
expect(rendered).not.toContain('may not be running');
|
||||||
expect(errorLines).toEqual([]);
|
expect(errorLines).toEqual([]);
|
||||||
expect(process.exitCode).toBe(0);
|
expect(process.exitCode).toBe(0);
|
||||||
|
} finally {
|
||||||
|
dockerModule.DockerExecutor.prototype.status = originalStatus;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { afterEach, beforeEach, mock } from 'bun:test';
|
||||||
|
|
||||||
|
export function useDockerSubcommandConsoleCapture(): {
|
||||||
|
logLines: string[];
|
||||||
|
errorLines: string[];
|
||||||
|
} {
|
||||||
|
const state = {
|
||||||
|
logLines: [] as string[],
|
||||||
|
errorLines: [] as string[],
|
||||||
|
};
|
||||||
|
|
||||||
|
let originalConsoleLog: typeof console.log;
|
||||||
|
let originalConsoleError: typeof console.error;
|
||||||
|
let originalExitCode: number | undefined;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
state.logLines = [];
|
||||||
|
state.errorLines = [];
|
||||||
|
originalConsoleLog = console.log;
|
||||||
|
originalConsoleError = console.error;
|
||||||
|
originalExitCode = process.exitCode;
|
||||||
|
process.exitCode = 0;
|
||||||
|
|
||||||
|
console.log = (...args: unknown[]) => {
|
||||||
|
state.logLines.push(args.map(String).join(' '));
|
||||||
|
};
|
||||||
|
console.error = (...args: unknown[]) => {
|
||||||
|
state.errorLines.push(args.map(String).join(' '));
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
console.log = originalConsoleLog;
|
||||||
|
console.error = originalConsoleError;
|
||||||
|
process.exitCode = originalExitCode ?? 0;
|
||||||
|
mock.restore();
|
||||||
|
});
|
||||||
|
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderCapturedLines(lines: string[]): string {
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { describe, expect, it } from 'bun:test';
|
||||||
|
import {
|
||||||
|
renderCapturedLines,
|
||||||
|
useDockerSubcommandConsoleCapture,
|
||||||
|
} from './docker-subcommand-test-helpers';
|
||||||
|
|
||||||
|
const capture = useDockerSubcommandConsoleCapture();
|
||||||
|
|
||||||
|
async function loadHandleUp() {
|
||||||
|
const mod = await import(
|
||||||
|
`../../../src/commands/docker/up-subcommand?test=${Date.now()}-${Math.random()}`
|
||||||
|
);
|
||||||
|
return mod.handleUp;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('docker up subcommand', () => {
|
||||||
|
it('prints the success summary with the requested host and port mappings', async () => {
|
||||||
|
const calls: Array<{ host?: string; port: number; proxyPort: number }> = [];
|
||||||
|
const dockerModule = (await import(
|
||||||
|
'../../../src/docker'
|
||||||
|
)) as typeof import('../../../src/docker');
|
||||||
|
const originalUp = dockerModule.DockerExecutor.prototype.up;
|
||||||
|
dockerModule.DockerExecutor.prototype.up = function (options: {
|
||||||
|
host?: string;
|
||||||
|
port: number;
|
||||||
|
proxyPort: number;
|
||||||
|
}) {
|
||||||
|
calls.push(options);
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const handleUp = await loadHandleUp();
|
||||||
|
await handleUp(['--host', 'docker-box', '--port', '4000', '--proxy-port', '9317']);
|
||||||
|
|
||||||
|
const rendered = renderCapturedLines(capture.logLines);
|
||||||
|
expect(calls).toEqual([{ host: 'docker-box', port: 4000, proxyPort: 9317 }]);
|
||||||
|
expect(rendered).toContain('Starting integrated Docker stack on docker-box');
|
||||||
|
expect(rendered).toContain('Docker stack is running on docker-box.');
|
||||||
|
expect(rendered).toContain('Dashboard port: 4000');
|
||||||
|
expect(rendered).toContain('CLIProxy port: 9317');
|
||||||
|
expect(capture.errorLines).toEqual([]);
|
||||||
|
expect(process.exitCode).toBe(0);
|
||||||
|
} finally {
|
||||||
|
dockerModule.DockerExecutor.prototype.up = originalUp;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders boxed validation errors without invoking the executor', async () => {
|
||||||
|
const handleUp = await loadHandleUp();
|
||||||
|
await handleUp(['--port', '70000']);
|
||||||
|
|
||||||
|
const rendered = renderCapturedLines(capture.errorLines);
|
||||||
|
expect(rendered).toContain('Invalid value for --port');
|
||||||
|
expect(process.exitCode).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { describe, expect, it } from 'bun:test';
|
||||||
|
import {
|
||||||
|
renderCapturedLines,
|
||||||
|
useDockerSubcommandConsoleCapture,
|
||||||
|
} from './docker-subcommand-test-helpers';
|
||||||
|
|
||||||
|
const capture = useDockerSubcommandConsoleCapture();
|
||||||
|
|
||||||
|
async function loadHandleUpdate() {
|
||||||
|
const mod = await import(
|
||||||
|
`../../../src/commands/docker/update-subcommand?test=${Date.now()}-${Math.random()}`
|
||||||
|
);
|
||||||
|
return mod.handleUpdate;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('docker update subcommand', () => {
|
||||||
|
it('prints progress and success output for remote updates', async () => {
|
||||||
|
const calls: Array<{ host?: string }> = [];
|
||||||
|
const dockerModule = (await import(
|
||||||
|
'../../../src/docker'
|
||||||
|
)) as typeof import('../../../src/docker');
|
||||||
|
const originalUpdate = dockerModule.DockerExecutor.prototype.update;
|
||||||
|
dockerModule.DockerExecutor.prototype.update = function (options: { host?: string }) {
|
||||||
|
calls.push(options);
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const handleUpdate = await loadHandleUpdate();
|
||||||
|
await handleUpdate(['--host', 'docker-box']);
|
||||||
|
|
||||||
|
const rendered = renderCapturedLines(capture.logLines);
|
||||||
|
expect(calls).toEqual([{ host: 'docker-box' }]);
|
||||||
|
expect(rendered).toContain('Updating running Docker stack on docker-box...');
|
||||||
|
expect(rendered).toContain('Docker stack updated on docker-box.');
|
||||||
|
expect(capture.errorLines).toEqual([]);
|
||||||
|
expect(process.exitCode).toBe(0);
|
||||||
|
} finally {
|
||||||
|
dockerModule.DockerExecutor.prototype.update = originalUpdate;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders executor failures as boxed errors', async () => {
|
||||||
|
const dockerModule = (await import(
|
||||||
|
'../../../src/docker'
|
||||||
|
)) as typeof import('../../../src/docker');
|
||||||
|
const originalUpdate = dockerModule.DockerExecutor.prototype.update;
|
||||||
|
dockerModule.DockerExecutor.prototype.update = function () {
|
||||||
|
throw new Error('Docker stack update failed.\nSupervisor restart did not complete.');
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const handleUpdate = await loadHandleUpdate();
|
||||||
|
await handleUpdate([]);
|
||||||
|
|
||||||
|
const rendered = renderCapturedLines(capture.errorLines);
|
||||||
|
expect(rendered).toContain('Docker stack update failed.');
|
||||||
|
expect(rendered).toContain('Supervisor restart did not complete.');
|
||||||
|
expect(process.exitCode).toBe(1);
|
||||||
|
} finally {
|
||||||
|
dockerModule.DockerExecutor.prototype.update = originalUpdate;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user