mirror of
https://github.com/bendtherules/opencode-multi-model.git
synced 2026-08-18 21:52:11 +00:00
build: add biome formatter and linter
This commit is contained in:
+228
-66
@@ -1,28 +1,39 @@
|
||||
import { expect, test, mock, spyOn, beforeEach, afterEach, describe } from "bun:test";
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
mock,
|
||||
spyOn,
|
||||
test,
|
||||
} from "bun:test";
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import { openMultiModel } from "../src/core/open";
|
||||
import {
|
||||
shellQuote,
|
||||
normalizeModels,
|
||||
findDuplicates,
|
||||
createWindowBaseName,
|
||||
createWindowPlans,
|
||||
levenshtein,
|
||||
suggestModels,
|
||||
findDuplicates,
|
||||
formatInvalidModelError,
|
||||
sanitizeName,
|
||||
runCommand,
|
||||
getWorktreePath,
|
||||
getSessionPath,
|
||||
undoWorktree,
|
||||
launchModelInWindow,
|
||||
getWorktreesForSession,
|
||||
getBinaryName,
|
||||
getSessionPath,
|
||||
getWorktreePath,
|
||||
getWorktreesForSession,
|
||||
launchModelInWindow,
|
||||
levenshtein,
|
||||
normalizeModels,
|
||||
runCommand,
|
||||
sanitizeName,
|
||||
shellQuote,
|
||||
suggestModels,
|
||||
undoWorktree,
|
||||
} from "../src/core/utils";
|
||||
|
||||
let originalBun$: typeof Bun.$ = (globalThis as any).Bun?.$;
|
||||
let mockCommandResponses: Record<string, { ok: boolean; stdout: string; stderr: string }> = {};
|
||||
const originalBun$: typeof Bun.$ = (globalThis as any).Bun?.$;
|
||||
let mockCommandResponses: Record<
|
||||
string,
|
||||
{ ok: boolean; stdout: string; stderr: string }
|
||||
> = {};
|
||||
let executedCommands: string[] = [];
|
||||
|
||||
function setupMockBun$() {
|
||||
@@ -35,7 +46,11 @@ function setupMockBun$() {
|
||||
return {
|
||||
quiet: () => ({
|
||||
nothrow: async () => {
|
||||
const response = mockCommandResponses[commandSignature] ?? { ok: true, stdout: "", stderr: "" };
|
||||
const response = mockCommandResponses[commandSignature] ?? {
|
||||
ok: true,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
};
|
||||
return {
|
||||
exitCode: response.ok ? 0 : 1,
|
||||
stdout: { toString: () => response.stdout },
|
||||
@@ -65,12 +80,29 @@ describe("multi-model launch", () => {
|
||||
executedCommands = [];
|
||||
|
||||
mockCommandResponses = {
|
||||
"git rev-parse --is-inside-work-tree": { ok: true, stdout: "true", stderr: "" },
|
||||
"git rev-parse --is-inside-work-tree": {
|
||||
ok: true,
|
||||
stdout: "true",
|
||||
stderr: "",
|
||||
},
|
||||
"command -v tmux": { ok: true, stdout: "/usr/bin/tmux", stderr: "" },
|
||||
"command -v opencode": { ok: true, stdout: "/usr/bin/opencode", stderr: "" },
|
||||
"opencode models": { ok: true, stdout: "openai/gpt-5.2\nanthropic/claude-3-5-sonnet", stderr: "" },
|
||||
"tmux has-session -t test-session": { ok: false, stdout: "", stderr: "session not found" },
|
||||
"git show-ref --verify --quiet refs/heads/opencode/test-session/gpt-5-2": { ok: false, stdout: "", stderr: "" },
|
||||
"command -v opencode": {
|
||||
ok: true,
|
||||
stdout: "/usr/bin/opencode",
|
||||
stderr: "",
|
||||
},
|
||||
"opencode models": {
|
||||
ok: true,
|
||||
stdout: "openai/gpt-5.2\nanthropic/claude-3-5-sonnet",
|
||||
stderr: "",
|
||||
},
|
||||
"tmux has-session -t test-session": {
|
||||
ok: false,
|
||||
stdout: "",
|
||||
stderr: "session not found",
|
||||
},
|
||||
"git show-ref --verify --quiet refs/heads/opencode/test-session/gpt-5-2":
|
||||
{ ok: false, stdout: "", stderr: "" },
|
||||
};
|
||||
});
|
||||
|
||||
@@ -112,7 +144,11 @@ describe("multi-model launch", () => {
|
||||
});
|
||||
|
||||
test("fails if not inside a git repository", async () => {
|
||||
mockCommandResponses["git rev-parse --is-inside-work-tree"] = { ok: false, stdout: "", stderr: "not a git repo" };
|
||||
mockCommandResponses["git rev-parse --is-inside-work-tree"] = {
|
||||
ok: false,
|
||||
stdout: "",
|
||||
stderr: "not a git repo",
|
||||
};
|
||||
|
||||
const result = await openMultiModel({
|
||||
sessionName: "test-session",
|
||||
@@ -120,12 +156,18 @@ describe("multi-model launch", () => {
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("Error: `multi-model` requires a git repository.");
|
||||
expect(result.error).toContain(
|
||||
"Error: `multi-model` requires a git repository.",
|
||||
);
|
||||
expect(executedCommands).toEqual(["git rev-parse --is-inside-work-tree"]);
|
||||
});
|
||||
|
||||
test("fails if tmux is not installed", async () => {
|
||||
mockCommandResponses["command -v tmux"] = { ok: false, stdout: "", stderr: "" };
|
||||
mockCommandResponses["command -v tmux"] = {
|
||||
ok: false,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
};
|
||||
|
||||
const result = await openMultiModel({
|
||||
sessionName: "test-session",
|
||||
@@ -133,7 +175,9 @@ describe("multi-model launch", () => {
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("Error: `tmux` is not installed or not on `PATH`.");
|
||||
expect(result.error).toContain(
|
||||
"Error: `tmux` is not installed or not on `PATH`.",
|
||||
);
|
||||
expect(executedCommands).toEqual([
|
||||
"git rev-parse --is-inside-work-tree",
|
||||
"command -v tmux",
|
||||
@@ -141,7 +185,11 @@ describe("multi-model launch", () => {
|
||||
});
|
||||
|
||||
test("fails if opencode is not installed", async () => {
|
||||
mockCommandResponses["command -v opencode"] = { ok: false, stdout: "", stderr: "" };
|
||||
mockCommandResponses["command -v opencode"] = {
|
||||
ok: false,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
};
|
||||
|
||||
const result = await openMultiModel({
|
||||
sessionName: "test-session",
|
||||
@@ -149,7 +197,9 @@ describe("multi-model launch", () => {
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("Error: `opencode` is not installed or not on `PATH`.");
|
||||
expect(result.error).toContain(
|
||||
"Error: `opencode` is not installed or not on `PATH`.",
|
||||
);
|
||||
expect(executedCommands).toEqual([
|
||||
"git rev-parse --is-inside-work-tree",
|
||||
"command -v tmux",
|
||||
@@ -164,7 +214,9 @@ describe("multi-model launch", () => {
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("Error: Model name 'invalid/model' not found");
|
||||
expect(result.error).toContain(
|
||||
"Error: Model name 'invalid/model' not found",
|
||||
);
|
||||
expect(executedCommands).toEqual([
|
||||
"git rev-parse --is-inside-work-tree",
|
||||
"command -v tmux",
|
||||
@@ -174,7 +226,11 @@ describe("multi-model launch", () => {
|
||||
});
|
||||
|
||||
test("fails if session already exists", async () => {
|
||||
mockCommandResponses["tmux has-session -t test-session"] = { ok: true, stdout: "", stderr: "" };
|
||||
mockCommandResponses["tmux has-session -t test-session"] = {
|
||||
ok: true,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
};
|
||||
|
||||
const result = await openMultiModel({
|
||||
sessionName: "test-session",
|
||||
@@ -199,13 +255,25 @@ describe("multi-model launch", () => {
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.instructions).toContain("Use `tmux attach -t test-session` to join session");
|
||||
expect(executedCommands.some(c => c.startsWith("tmux new-session -d -s test-session -n gpt-5-2"))).toBe(true);
|
||||
expect(executedCommands.some(c => c.startsWith("tmux send-keys -t test-session:gpt-5-2"))).toBe(true);
|
||||
expect(result.instructions).toContain(
|
||||
"Use `tmux attach -t test-session` to join session",
|
||||
);
|
||||
expect(
|
||||
executedCommands.some((c) =>
|
||||
c.startsWith("tmux new-session -d -s test-session -n gpt-5-2"),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
executedCommands.some((c) =>
|
||||
c.startsWith("tmux send-keys -t test-session:gpt-5-2"),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("successfully launches multiple models", async () => {
|
||||
mockCommandResponses["git show-ref --verify --quiet refs/heads/opencode/test-session/claude-3-5-sonnet"] = { ok: false, stdout: "", stderr: "" };
|
||||
mockCommandResponses[
|
||||
"git show-ref --verify --quiet refs/heads/opencode/test-session/claude-3-5-sonnet"
|
||||
] = { ok: false, stdout: "", stderr: "" };
|
||||
|
||||
const result = await openMultiModel({
|
||||
sessionName: "test-session",
|
||||
@@ -213,14 +281,30 @@ describe("multi-model launch", () => {
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.instructions).toContain("Use `tmux attach -t test-session` to join session");
|
||||
expect(executedCommands.some(c => c.startsWith("tmux new-session -d -s test-session -n gpt-5-2"))).toBe(true);
|
||||
expect(executedCommands.some(c => c.startsWith("tmux new-window -d -t test-session -n claude-3-5-sonnet"))).toBe(true);
|
||||
expect(result.instructions).toContain(
|
||||
"Use `tmux attach -t test-session` to join session",
|
||||
);
|
||||
expect(
|
||||
executedCommands.some((c) =>
|
||||
c.startsWith("tmux new-session -d -s test-session -n gpt-5-2"),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
executedCommands.some((c) =>
|
||||
c.startsWith("tmux new-window -d -t test-session -n claude-3-5-sonnet"),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("sanitizes session name", async () => {
|
||||
mockCommandResponses["tmux has-session -t test session!"] = { ok: false, stdout: "", stderr: "" };
|
||||
mockCommandResponses["git show-ref --verify --quiet refs/heads/opencode/test-session/gpt-5-2"] = { ok: false, stdout: "", stderr: "" };
|
||||
mockCommandResponses["tmux has-session -t test session!"] = {
|
||||
ok: false,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
};
|
||||
mockCommandResponses[
|
||||
"git show-ref --verify --quiet refs/heads/opencode/test-session/gpt-5-2"
|
||||
] = { ok: false, stdout: "", stderr: "" };
|
||||
|
||||
const result = await openMultiModel({
|
||||
sessionName: "test session!",
|
||||
@@ -228,18 +312,36 @@ describe("multi-model launch", () => {
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.instructions).toContain("Use `tmux attach -t test session!` to join session");
|
||||
expect(executedCommands.some(c => c.startsWith("git worktree add -b opencode/test-session/gpt-5-2"))).toBe(true);
|
||||
expect(result.instructions).toContain(
|
||||
"Use `tmux attach -t test session!` to join session",
|
||||
);
|
||||
expect(
|
||||
executedCommands.some((c) =>
|
||||
c.startsWith("git worktree add -b opencode/test-session/gpt-5-2"),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("uses sessionName for tmux commands, not safeSessionName", async () => {
|
||||
const unsanitized = "my awesome session!!";
|
||||
const sanitized = "my-awesome-session";
|
||||
mockCommandResponses[`tmux has-session -t ${unsanitized}`] = { ok: false, stdout: "", stderr: "" };
|
||||
mockCommandResponses[`git show-ref --verify --quiet refs/heads/opencode/${sanitized}/gpt-5-2`] = { ok: false, stdout: "", stderr: "" };
|
||||
mockCommandResponses[`git worktree add -b opencode/${sanitized}/gpt-5-2 /mock/home/.local/share/opencode/multi-model/${sanitized}/gpt-5-2`] = { ok: true, stdout: "", stderr: "" };
|
||||
mockCommandResponses[`tmux new-session -d -s ${unsanitized} -n gpt-5-2 -c /mock/home/.local/share/opencode/multi-model/${sanitized}/gpt-5-2`] = { ok: true, stdout: "", stderr: "" };
|
||||
mockCommandResponses[`tmux send-keys -t ${unsanitized}:gpt-5-2 opencode --model 'openai/gpt-5.2' C-m`] = { ok: true, stdout: "", stderr: "" };
|
||||
mockCommandResponses[`tmux has-session -t ${unsanitized}`] = {
|
||||
ok: false,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
};
|
||||
mockCommandResponses[
|
||||
`git show-ref --verify --quiet refs/heads/opencode/${sanitized}/gpt-5-2`
|
||||
] = { ok: false, stdout: "", stderr: "" };
|
||||
mockCommandResponses[
|
||||
`git worktree add -b opencode/${sanitized}/gpt-5-2 /mock/home/.local/share/opencode/multi-model/${sanitized}/gpt-5-2`
|
||||
] = { ok: true, stdout: "", stderr: "" };
|
||||
mockCommandResponses[
|
||||
`tmux new-session -d -s ${unsanitized} -n gpt-5-2 -c /mock/home/.local/share/opencode/multi-model/${sanitized}/gpt-5-2`
|
||||
] = { ok: true, stdout: "", stderr: "" };
|
||||
mockCommandResponses[
|
||||
`tmux send-keys -t ${unsanitized}:gpt-5-2 opencode --model 'openai/gpt-5.2' C-m`
|
||||
] = { ok: true, stdout: "", stderr: "" };
|
||||
|
||||
const result = await openMultiModel({
|
||||
sessionName: unsanitized,
|
||||
@@ -248,19 +350,45 @@ describe("multi-model launch", () => {
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
// tmux commands should use unsanitized name
|
||||
expect(executedCommands.some(c => c.startsWith(`tmux has-session -t ${unsanitized}`))).toBe(true);
|
||||
expect(executedCommands.some(c => c.startsWith(`tmux new-session -d -s ${unsanitized}`))).toBe(true);
|
||||
expect(executedCommands.some(c => c.startsWith(`tmux send-keys -t ${unsanitized}:`))).toBe(true);
|
||||
expect(
|
||||
executedCommands.some((c) =>
|
||||
c.startsWith(`tmux has-session -t ${unsanitized}`),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
executedCommands.some((c) =>
|
||||
c.startsWith(`tmux new-session -d -s ${unsanitized}`),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
executedCommands.some((c) =>
|
||||
c.startsWith(`tmux send-keys -t ${unsanitized}:`),
|
||||
),
|
||||
).toBe(true);
|
||||
// sanitized name should NOT appear in tmux commands
|
||||
expect(executedCommands.some(c => c.includes(`-t ${sanitized}`))).toBe(false);
|
||||
expect(executedCommands.some((c) => c.includes(`-t ${sanitized}`))).toBe(
|
||||
false,
|
||||
);
|
||||
// git branches and paths should use sanitized name
|
||||
expect(executedCommands.some(c => c.startsWith(`git worktree add -b opencode/${sanitized}/gpt-5-2`))).toBe(true);
|
||||
expect(
|
||||
executedCommands.some((c) =>
|
||||
c.startsWith(`git worktree add -b opencode/${sanitized}/gpt-5-2`),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("fails if tmux new-session fails for the first model and triggers undo", async () => {
|
||||
mockCommandResponses["tmux new-session -d -s test-session -n gpt-5-2 -c /mock/home/.local/share/opencode/multi-model/test-session/gpt-5-2"] = { ok: false, stdout: "", stderr: "tmux error" };
|
||||
mockCommandResponses["git worktree remove -f /mock/home/.local/share/opencode/multi-model/test-session/gpt-5-2"] = { ok: true, stdout: "", stderr: "" };
|
||||
mockCommandResponses["git branch -D opencode/test-session/gpt-5-2"] = { ok: true, stdout: "", stderr: "" };
|
||||
mockCommandResponses[
|
||||
"tmux new-session -d -s test-session -n gpt-5-2 -c /mock/home/.local/share/opencode/multi-model/test-session/gpt-5-2"
|
||||
] = { ok: false, stdout: "", stderr: "tmux error" };
|
||||
mockCommandResponses[
|
||||
"git worktree remove -f /mock/home/.local/share/opencode/multi-model/test-session/gpt-5-2"
|
||||
] = { ok: true, stdout: "", stderr: "" };
|
||||
mockCommandResponses["git branch -D opencode/test-session/gpt-5-2"] = {
|
||||
ok: true,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
};
|
||||
|
||||
const result = await openMultiModel({
|
||||
sessionName: "test-session",
|
||||
@@ -305,7 +433,9 @@ describe("multi-model launch", () => {
|
||||
});
|
||||
|
||||
test("fails if git branch exists for first model", async () => {
|
||||
mockCommandResponses["git show-ref --verify --quiet refs/heads/opencode/test-session/gpt-5-2"] = { ok: true, stdout: "", stderr: "" };
|
||||
mockCommandResponses[
|
||||
"git show-ref --verify --quiet refs/heads/opencode/test-session/gpt-5-2"
|
||||
] = { ok: true, stdout: "", stderr: "" };
|
||||
|
||||
const result = await openMultiModel({
|
||||
sessionName: "test-session",
|
||||
@@ -313,7 +443,9 @@ describe("multi-model launch", () => {
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("Branch 'opencode/test-session/gpt-5-2' already exists");
|
||||
expect(result.error).toContain(
|
||||
"Branch 'opencode/test-session/gpt-5-2' already exists",
|
||||
);
|
||||
expect(executedCommands).toEqual([
|
||||
"git rev-parse --is-inside-work-tree",
|
||||
"command -v tmux",
|
||||
@@ -325,7 +457,9 @@ describe("multi-model launch", () => {
|
||||
});
|
||||
|
||||
test("fails if git worktree add fails for first model", async () => {
|
||||
mockCommandResponses["git worktree add -b opencode/test-session/gpt-5-2 /mock/home/.local/share/opencode/multi-model/test-session/gpt-5-2"] = { ok: false, stdout: "", stderr: "git error" };
|
||||
mockCommandResponses[
|
||||
"git worktree add -b opencode/test-session/gpt-5-2 /mock/home/.local/share/opencode/multi-model/test-session/gpt-5-2"
|
||||
] = { ok: false, stdout: "", stderr: "git error" };
|
||||
|
||||
const result = await openMultiModel({
|
||||
sessionName: "test-session",
|
||||
@@ -346,9 +480,17 @@ describe("multi-model launch", () => {
|
||||
});
|
||||
|
||||
test("model name collision creates unique window names", async () => {
|
||||
mockCommandResponses["opencode models"] = { ok: true, stdout: "openai/gpt-5.2\nanthropic/gpt-5.2", stderr: "" };
|
||||
mockCommandResponses["git show-ref --verify --quiet refs/heads/opencode/test-session/gpt-5-2"] = { ok: false, stdout: "", stderr: "" };
|
||||
mockCommandResponses["git show-ref --verify --quiet refs/heads/opencode/test-session/gpt-5-2-2"] = { ok: false, stdout: "", stderr: "" };
|
||||
mockCommandResponses["opencode models"] = {
|
||||
ok: true,
|
||||
stdout: "openai/gpt-5.2\nanthropic/gpt-5.2",
|
||||
stderr: "",
|
||||
};
|
||||
mockCommandResponses[
|
||||
"git show-ref --verify --quiet refs/heads/opencode/test-session/gpt-5-2"
|
||||
] = { ok: false, stdout: "", stderr: "" };
|
||||
mockCommandResponses[
|
||||
"git show-ref --verify --quiet refs/heads/opencode/test-session/gpt-5-2-2"
|
||||
] = { ok: false, stdout: "", stderr: "" };
|
||||
|
||||
const result = await openMultiModel({
|
||||
sessionName: "test-session",
|
||||
@@ -356,15 +498,31 @@ describe("multi-model launch", () => {
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.instructions).toContain("Use `tmux attach -t test-session` to join session");
|
||||
expect(executedCommands.some(c => c.startsWith("tmux new-session -d -s test-session -n gpt-5-2"))).toBe(true);
|
||||
expect(executedCommands.some(c => c.startsWith("tmux new-window -d -t test-session -n gpt-5-2-2"))).toBe(true);
|
||||
expect(result.instructions).toContain(
|
||||
"Use `tmux attach -t test-session` to join session",
|
||||
);
|
||||
expect(
|
||||
executedCommands.some((c) =>
|
||||
c.startsWith("tmux new-session -d -s test-session -n gpt-5-2"),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
executedCommands.some((c) =>
|
||||
c.startsWith("tmux new-window -d -t test-session -n gpt-5-2-2"),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("long model name is truncated in window name", async () => {
|
||||
const longModel = "verylongmodelfrontexampleprovider";
|
||||
mockCommandResponses["opencode models"] = { ok: true, stdout: longModel, stderr: "" };
|
||||
mockCommandResponses[`git show-ref --verify --quiet refs/heads/opencode/test-session/${longModel.slice(0, 24)}`] = { ok: false, stdout: "", stderr: "" };
|
||||
mockCommandResponses["opencode models"] = {
|
||||
ok: true,
|
||||
stdout: longModel,
|
||||
stderr: "",
|
||||
};
|
||||
mockCommandResponses[
|
||||
`git show-ref --verify --quiet refs/heads/opencode/test-session/${longModel.slice(0, 24)}`
|
||||
] = { ok: false, stdout: "", stderr: "" };
|
||||
|
||||
const result = await openMultiModel({
|
||||
sessionName: "test-session",
|
||||
@@ -372,8 +530,12 @@ describe("multi-model launch", () => {
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.instructions).toContain("Use `tmux attach -t test-session` to join session");
|
||||
const windowName = executedCommands.find(c => c.includes("tmux new-session"))?.match(/-n (\S+)/)?.[1];
|
||||
expect(result.instructions).toContain(
|
||||
"Use `tmux attach -t test-session` to join session",
|
||||
);
|
||||
const windowName = executedCommands
|
||||
.find((c) => c.includes("tmux new-session"))
|
||||
?.match(/-n (\S+)/)?.[1];
|
||||
expect(windowName?.length).toBeLessThanOrEqual(24);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user