Compare commits

..

1 Commits

Author SHA1 Message Date
Jiayuan Zhang
a6a5ef0aa8 feat(views): show issue title in detail page header
Previously the issue detail top bar only showed 'workspace name > identifier'.
Add the issue title next to the identifier so users can see what issue they're
viewing without scrolling.
2026-04-20 00:34:01 +08:00
17 changed files with 28 additions and 476 deletions

View File

@@ -6,16 +6,6 @@ POSTGRES_PORT=5432
DATABASE_URL=postgres://multica:multica@localhost:5432/multica?sslmode=disable
# Server
# APP_ENV gates dev-only auth shortcuts (primarily the 888888 master code).
# - Docker self-host: docker-compose.selfhost.yml already pins APP_ENV to
# "production" by default, so 888888 is DISABLED — a public instance can't
# be logged into with any email + 888888.
# - Local dev (make dev): leave APP_ENV unset so 888888 works out of the box.
# - Docker self-host on a private network you fully control, or evaluation
# without Resend: set APP_ENV=development to re-enable 888888. Do NOT
# enable on a publicly reachable instance.
# See SELF_HOSTING.md for the full login setup.
APP_ENV=
PORT=8080
JWT_SECRET=change-me-in-production
MULTICA_SERVER_URL=ws://localhost:8080/ws
@@ -32,8 +22,7 @@ MULTICA_CODEX_WORKDIR=
MULTICA_CODEX_TIMEOUT=20m
# Email (Resend)
# For local/dev use, leave RESEND_API_KEY empty — codes print to stdout, and
# master code 888888 works (only when APP_ENV != "production"; see above).
# For local/dev use, leave RESEND_API_KEY empty — codes print to stdout, and master code 888888 works.
# For production, set your Resend API key and change RESEND_FROM_EMAIL to a domain verified in your Resend account.
RESEND_API_KEY=
RESEND_FROM_EMAIL=noreply@multica.ai
@@ -74,18 +63,3 @@ NEXT_PUBLIC_WS_URL=
# Remote API (optional) — set to proxy local frontend to a remote backend
# Leave empty to use local backend (localhost:8080)
# REMOTE_API_URL=https://multica-api.copilothub.ai
# ==================== Self-hosting: Control Signups (fixes #930) ====================
# Set to "false" to completely disable new user signups (recommended for private instances)
ALLOW_SIGNUP=true
# Must match ALLOW_SIGNUP for the UI to reflect the same signup setting.
# Note: in typical Next.js builds, NEXT_PUBLIC_* values are baked into the client bundle,
# so changing this usually requires rebuilding/redeploying the frontend (not just restarting the backend).
NEXT_PUBLIC_ALLOW_SIGNUP=true
# Optional: Only allow emails from these domains (comma-separated)
ALLOWED_EMAIL_DOMAINS=
# Optional: Only allow these exact email addresses (comma-separated)
ALLOWED_EMAILS=

View File

@@ -66,8 +66,7 @@ selfhost:
echo " Frontend: http://localhost:$${FRONTEND_PORT:-3000}"; \
echo " Backend: http://localhost:$${PORT:-8080}"; \
echo ""; \
echo "Log in: configure RESEND_API_KEY in .env for email codes,"; \
echo " or set APP_ENV=development in .env (private networks only) to enable code 888888."; \
echo "Log in with any email + verification code: 888888"; \
echo ""; \
echo "Next — install the CLI and connect your machine:"; \
echo " brew install multica-ai/tap/multica"; \

View File

@@ -1,8 +1,6 @@
import { githubUrl } from "../components/shared";
import type { LandingDict } from "./types";
export const ALLOW_SIGNUP = process.env.NEXT_PUBLIC_ALLOW_SIGNUP !== "false";
export const en: LandingDict = {
header: {
github: "GitHub",
@@ -122,10 +120,9 @@ export const en: LandingDict = {
headlineFaded: "in the next hour.",
steps: [
{
title: ALLOW_SIGNUP ? "Sign up & create your workspace" : "Login to your workspace",
description: ALLOW_SIGNUP
? "Enter your email, verify with a code, and you\u2019re in. Your workspace is created automatically \u2014 no setup wizard, no configuration forms."
: "Enter your email, verify with a code, and you\u2019re logged into your workspace \u2014 no setup wizard, no configuration forms.",
title: "Sign up & create your workspace",
description:
"Enter your email, verify with a code, and you\u2019re in. Your workspace is created automatically \u2014 no setup wizard, no configuration forms.",
},
{
title: "Install the CLI & connect your machine",

View File

@@ -1,8 +1,6 @@
import { githubUrl } from "../components/shared";
import type { LandingDict } from "./types";
export const ALLOW_SIGNUP = process.env.NEXT_PUBLIC_ALLOW_SIGNUP !== "false";
export const zh: LandingDict = {
header: {
github: "GitHub",
@@ -122,10 +120,9 @@ export const zh: LandingDict = {
headlineFaded: "\u53ea\u9700\u4e00\u5c0f\u65f6\u3002",
steps: [
{
title: ALLOW_SIGNUP ? "注册并创建您的工作空间" : "登录到您的工作空间",
description: ALLOW_SIGNUP
? "输入您的邮箱,验证代码后即可使用。工作空间会自动创建——无需设置向导或配置表单。"
: "输入您的邮箱,验证代码后即可登录到您的工作空间——无需设置向导或配置表单。",
title: "\u6ce8\u518c\u5e76\u521b\u5efa\u5de5\u4f5c\u533a",
description:
"\u8f93\u5165\u90ae\u7bb1\uff0c\u9a8c\u8bc1\u7801\u786e\u8ba4\uff0c\u5373\u53ef\u8fdb\u5165\u3002\u5de5\u4f5c\u533a\u81ea\u52a8\u521b\u5efa\u2014\u2014\u65e0\u9700\u8bbe\u7f6e\u5411\u5bfc\uff0c\u65e0\u9700\u914d\u7f6e\u8868\u5355\u3002",
},
{
title: "\u5b89\u88c5 CLI \u5e76\u8fde\u63a5\u4f60\u7684\u673a\u5668",

View File

@@ -32,8 +32,6 @@ import { useEditorState } from "@tiptap/react";
import type { Editor } from "@tiptap/core";
import { posToDOMRect } from "@tiptap/core";
import { NodeSelection } from "@tiptap/pm/state";
import { toast } from "sonner";
import { useCreateIssue } from "@multica/core/issues/mutations";
import { Toggle } from "@multica/ui/components/ui/toggle";
import { Separator } from "@multica/ui/components/ui/separator";
import {
@@ -66,8 +64,6 @@ import {
Heading1,
Heading2,
Heading3,
FilePlus,
Loader2,
} from "lucide-react";
// ---------------------------------------------------------------------------
@@ -348,106 +344,11 @@ function ListDropdown({ editor, onOpenChange, isBullet, isOrdered }: { editor: E
);
}
// ---------------------------------------------------------------------------
// Create Sub-Issue Button
// ---------------------------------------------------------------------------
/**
* Turns the current selection into a sub-issue of `parentIssueId` and replaces
* the selection with a mention link to the new issue. Title is the selected
* text (trimmed, collapsed whitespace, capped). Only rendered when a parent
* issue is in scope; otherwise there's no meaningful "sub-issue of" target.
*/
function CreateSubIssueButton({
editor,
parentIssueId,
}: {
editor: Editor;
parentIssueId: string;
}) {
const createIssue = useCreateIssue();
const [pending, setPending] = useState(false);
const handleClick = useCallback(async () => {
if (pending) return;
const { from, to } = editor.state.selection;
if (from === to) return;
// Title from selection: collapse whitespace, cap length. The full selection
// still becomes the link text — only the issue title is capped.
const rawTitle = editor.state.doc.textBetween(from, to, " ", " ").trim();
const title = rawTitle.replace(/\s+/g, " ").slice(0, 200);
if (!title) return;
setPending(true);
try {
const newIssue = await createIssue.mutateAsync({
title,
parent_issue_id: parentIssueId,
});
editor
.chain()
.focus()
.insertContentAt(
{ from, to },
[
{
type: "mention",
attrs: {
id: newIssue.id,
label: newIssue.identifier,
type: "issue",
},
},
{ type: "text", text: " " },
],
)
.run();
toast.success(`Created ${newIssue.identifier}`);
} catch {
toast.error("Failed to create sub-issue");
} finally {
setPending(false);
}
}, [editor, parentIssueId, createIssue, pending]);
return (
<Tooltip>
<TooltipTrigger
render={
<Toggle
size="sm"
pressed={false}
disabled={pending}
onPressedChange={handleClick}
onMouseDown={(e) => e.preventDefault()}
/>
}
>
{pending ? (
<Loader2 className="size-3.5 animate-spin" />
) : (
<FilePlus className="size-3.5" />
)}
</TooltipTrigger>
<TooltipContent side="top" sideOffset={8}>
Create sub-issue from selection
</TooltipContent>
</Tooltip>
);
}
// ---------------------------------------------------------------------------
// Main Bubble Menu — @floating-ui/dom + portal to body
// ---------------------------------------------------------------------------
function EditorBubbleMenu({
editor,
currentIssueId,
}: {
editor: Editor;
currentIssueId?: string;
}) {
function EditorBubbleMenu({ editor }: { editor: Editor }) {
const [visible, setVisible] = useState(false);
const [mode, setMode] = useState<"toolbar" | "link-edit">("toolbar");
const floatingRef = useRef<HTMLDivElement>(null);
@@ -601,12 +502,6 @@ function EditorBubbleMenu({
</TooltipTrigger>
<TooltipContent side="top" sideOffset={8}>Quote</TooltipContent>
</Tooltip>
{currentIssueId && (
<>
<Separator orientation="vertical" className="mx-0.5 h-5" />
<CreateSubIssueButton editor={editor} parentIssueId={currentIssueId} />
</>
)}
</div>
</TooltipProvider>
)}

View File

@@ -75,12 +75,6 @@ interface ContentEditorProps {
showBubbleMenu?: boolean;
/** When true, bare Enter submits (chat-style). Mod-Enter always submits. */
submitOnEnter?: boolean;
/**
* ID of the issue this editor belongs to. When set, the bubble menu exposes
* a "Create sub-issue from selection" action that parents the new issue
* under this ID and replaces the selection with a mention link.
*/
currentIssueId?: string;
}
interface ContentEditorRef {
@@ -110,7 +104,6 @@ const ContentEditor = forwardRef<ContentEditorRef, ContentEditorProps>(
onUploadFile,
showBubbleMenu = true,
submitOnEnter = false,
currentIssueId,
},
ref,
) {
@@ -265,9 +258,7 @@ const ContentEditor = forwardRef<ContentEditorRef, ContentEditorProps>(
onMouseDown={handleContainerMouseDown}
>
<EditorContent className="flex-1 min-h-full" editor={editor} />
{editable && showBubbleMenu && (
<EditorBubbleMenu editor={editor} currentIssueId={currentIssueId} />
)}
{editable && showBubbleMenu && <EditorBubbleMenu editor={editor} />}
<LinkHoverCard {...hover} />
</div>
);

View File

@@ -1,20 +0,0 @@
import { Extension } from "@tiptap/core";
/**
* Escape → blur the editor. Without this, pressing ESC inside the
* contenteditable does nothing (browsers don't blur contenteditables by
* default), leaving users stuck in the editor with no keyboard escape hatch.
*/
export function createBlurShortcutExtension() {
return Extension.create({
name: "blurShortcut",
addKeyboardShortcuts() {
return {
Escape: ({ editor }) => {
editor.commands.blur();
return true;
},
};
},
});
}

View File

@@ -40,7 +40,6 @@ import { createMentionSuggestion } from "./mention-suggestion";
import { CodeBlockView } from "./code-block-view";
import { createMarkdownPasteExtension } from "./markdown-paste";
import { createSubmitExtension } from "./submit-shortcut";
import { createBlurShortcutExtension } from "./blur-shortcut";
import { createFileUploadExtension } from "./file-upload";
import { FileCardExtension } from "./file-card";
import { ImageView } from "./image-view";
@@ -138,7 +137,6 @@ export function createEditorExtensions(
},
{ submitOnEnter: options.submitOnEnter ?? false },
),
createBlurShortcutExtension(),
createFileUploadExtension(options.onUploadFileRef!),
);
}

View File

@@ -291,7 +291,6 @@ function CommentRow({
onSubmit={saveEdit}
onUploadFile={(file) => uploadWithToast(file, { issueId })}
debounceMs={100}
currentIssueId={issueId}
/>
</div>
<div className="flex items-center justify-between mt-2">
@@ -512,7 +511,6 @@ function CommentCard({
onSubmit={saveEdit}
onUploadFile={(file) => uploadWithToast(file, { issueId })}
debounceMs={100}
currentIssueId={issueId}
/>
</div>
<div className="flex items-center justify-between mt-2">

View File

@@ -63,7 +63,6 @@ function CommentInput({ issueId, onSubmit }: CommentInputProps) {
onSubmit={handleSubmit}
onUploadFile={handleUpload}
debounceMs={100}
currentIssueId={issueId}
/>
</div>
<div className="absolute bottom-1 right-1.5 flex items-center gap-1">

View File

@@ -1049,7 +1049,6 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
onUpdate={(md) => handleUpdateField({ description: md })}
onUploadFile={handleDescriptionUpload}
debounceMs={1500}
currentIssueId={id}
/>
<div className="flex items-center gap-1 mt-3">

View File

@@ -110,7 +110,6 @@ function ReplyInput({
onSubmit={handleSubmit}
onUploadFile={handleUpload}
debounceMs={100}
currentIssueId={issueId}
/>
</div>
</div>

View File

@@ -70,13 +70,7 @@ func NewRouter(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus) chi.Route
}
cfSigner := auth.NewCloudFrontSignerFromEnv()
signupConfig := handler.Config{
AllowSignup: os.Getenv("ALLOW_SIGNUP") != "false",
AllowedEmails: splitAndTrim(os.Getenv("ALLOWED_EMAILS")),
AllowedEmailDomains: splitAndTrim(os.Getenv("ALLOWED_EMAIL_DOMAINS")),
}
h := handler.New(queries, pool, hub, bus, emailSvc, store, cfSigner, signupConfig)
h := handler.New(queries, pool, hub, bus, emailSvc, store, cfSigner)
r := chi.NewRouter()
@@ -420,18 +414,3 @@ func parseUUID(s string) pgtype.UUID {
}
return u
}
func splitAndTrim(s string) []string {
if s == "" {
return nil
}
parts := strings.Split(s, ",")
res := make([]string, 0, len(parts))
for _, p := range parts {
trimmed := strings.TrimSpace(p)
if trimmed != "" {
res = append(res, trimmed)
}
}
return res
}

View File

@@ -2,7 +2,6 @@ package handler
import (
"context"
"errors"
"crypto/rand"
"crypto/subtle"
"encoding/binary"
@@ -23,18 +22,6 @@ import (
db "github.com/multica-ai/multica/server/pkg/db/generated"
)
// SignupError represents signup restriction errors
type SignupError struct {
Message string
}
func (e SignupError) Error() string {
return e.Message
}
var ErrSignupProhibited = SignupError{Message: "user registration is disabled on this self-hosted instance"}
var ErrEmailNotAllowed = SignupError{Message: "email address or domain not allowed on this instance"}
type UserResponse struct {
ID string `json:"id"`
Name string `json:"name"`
@@ -91,70 +78,23 @@ func (h *Handler) issueJWT(user db.User) (string, error) {
func (h *Handler) findOrCreateUser(ctx context.Context, email string) (db.User, error) {
user, err := h.Queries.GetUserByEmail(ctx, email)
isNewUser := isNotFound(err)
if err != nil && !isNewUser {
return db.User{}, err
}
if err := h.checkSignupAllowed(email, isNewUser); err != nil {
return db.User{}, err
}
if !isNewUser {
return user, nil
}
name := email
if at := strings.Index(email, "@"); at > 0 {
name = email[:at]
}
return h.Queries.CreateUser(ctx, db.CreateUserParams{
Name: name,
Email: email,
})
}
func (h *Handler) checkSignupAllowed(email string, isNewUser bool) error {
if !isNewUser {
return nil // existing users always allowed to log in
}
email = strings.ToLower(email)
domain := ""
if at := strings.Index(email, "@"); at > 0 {
domain = email[at+1:]
}
// 1. explicit email whitelist always wins
if len(h.cfg.AllowedEmails) > 0 && contains(h.cfg.AllowedEmails, email) {
return nil
}
// 2. domain whitelist always wins
if len(h.cfg.AllowedEmailDomains) > 0 && contains(h.cfg.AllowedEmailDomains, domain) {
return nil
}
// 3. general signup flag
if !h.cfg.AllowSignup {
return ErrSignupProhibited
}
// 4. if allowlists are set but didn't match, block
if len(h.cfg.AllowedEmailDomains) > 0 || len(h.cfg.AllowedEmails) > 0 {
return ErrSignupProhibited
}
return nil
}
func contains(slice []string, s string) bool {
for _, item := range slice {
if strings.EqualFold(item, s) {
return true
if err != nil {
if !isNotFound(err) {
return db.User{}, err
}
name := email
if at := strings.Index(email, "@"); at > 0 {
name = email[:at]
}
user, err = h.Queries.CreateUser(ctx, db.CreateUserParams{
Name: name,
Email: email,
})
if err != nil {
return db.User{}, err
}
}
return false
return user, nil
}
func (h *Handler) SendCode(w http.ResponseWriter, r *http.Request) {
@@ -170,40 +110,6 @@ func (h *Handler) SendCode(w http.ResponseWriter, r *http.Request) {
return
}
// Check signup restrictions before sending magic link
_, err := h.Queries.GetUserByEmail(r.Context(), email)
if err != nil {
if !isNotFound(err) {
// Real database/query error → return 500
writeError(w, http.StatusInternalServerError, "failed to lookup user")
return
}
// User does not exist → treat as new user
isNewUser := true
if err := h.checkSignupAllowed(email, isNewUser); err != nil {
var signupErr SignupError
if errors.As(err, &signupErr) {
writeError(w, http.StatusForbidden, signupErr.Error())
} else {
writeError(w, http.StatusForbidden, "user registration is disabled")
}
return
}
} else {
// User already exists → always allowed to login
isNewUser := false
if err := h.checkSignupAllowed(email, isNewUser); err != nil {
// This should rarely happen, but handle it anyway
var signupErr SignupError
if errors.As(err, &signupErr) {
writeError(w, http.StatusForbidden, signupErr.Error())
} else {
writeError(w, http.StatusForbidden, "user registration is disabled")
}
return
}
}
// Rate limit: max 1 code per 60 seconds per email
latest, err := h.Queries.GetLatestCodeByEmail(r.Context(), email)
if err == nil && time.Since(latest.CreatedAt.Time) < 60*time.Second {
@@ -274,11 +180,6 @@ func (h *Handler) VerifyCode(w http.ResponseWriter, r *http.Request) {
user, err := h.findOrCreateUser(r.Context(), email)
if err != nil {
var signupErr SignupError
if errors.As(err, &signupErr) {
writeError(w, http.StatusForbidden, signupErr.Error())
return
}
writeError(w, http.StatusInternalServerError, "failed to create user")
return
}
@@ -435,11 +336,6 @@ func (h *Handler) GoogleLogin(w http.ResponseWriter, r *http.Request) {
user, err := h.findOrCreateUser(r.Context(), email)
if err != nil {
var signupErr SignupError
if errors.As(err, &signupErr) {
writeError(w, http.StatusForbidden, signupErr.Error())
return
}
writeError(w, http.StatusInternalServerError, "failed to create user")
return
}

View File

@@ -1,108 +0,0 @@
package handler
import (
"context"
"strings"
"testing"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
db "github.com/multica-ai/multica/server/pkg/db/generated"
)
func newTestHandler(cfg Config) *Handler {
return &Handler{
cfg: cfg,
}
}
func TestSignupGating(t *testing.T) {
tests := []struct {
name string
cfg Config
email string
isNew bool
wantErr bool
}{
{"allow_signup_true_new", Config{AllowSignup: true}, "a@x.com", true, false},
{"allow_signup_false_new", Config{AllowSignup: false}, "a@x.com", true, true},
{"allow_signup_false_existing", Config{AllowSignup: false}, "a@x.com", false, false},
{"domain_allowlist_match", Config{AllowSignup: false, AllowedEmailDomains: []string{"company.com"}}, "user@company.com", true, false},
{"domain_allowlist_mismatch", Config{AllowSignup: false, AllowedEmailDomains: []string{"company.com"}}, "user@other.com", true, true},
{"email_allowlist_match", Config{AllowSignup: false, AllowedEmails: []string{"boss@x.com"}}, "boss@x.com", true, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
h := newTestHandler(tt.cfg)
err := h.checkSignupAllowed(tt.email, tt.isNew)
if (err != nil) != tt.wantErr {
t.Fatalf("got err=%v wantErr=%v", err, tt.wantErr)
}
})
}
}
type mockDB struct {
db.DBTX
getUserErr error
}
func (m *mockDB) QueryRow(ctx context.Context, sql string, args ...interface{}) pgx.Row {
return &mockRow{err: m.getUserErr}
}
func (m *mockDB) Exec(ctx context.Context, sql string, args ...interface{}) (pgconn.CommandTag, error) {
return pgconn.NewCommandTag("INSERT 1"), nil
}
type mockRow struct {
pgx.Row
err error
}
func (m *mockRow) Scan(dest ...interface{}) error {
return m.err
}
func TestFindOrCreateUserGating(t *testing.T) {
t.Run("new_user_blocked", func(t *testing.T) {
cfg := Config{AllowSignup: false}
h := newTestHandler(cfg)
h.Queries = db.New(&mockDB{getUserErr: pgx.ErrNoRows})
_, err := h.findOrCreateUser(context.Background(), "new@blocked.com")
if err == nil {
t.Fatal("expected error for new user when signup disabled")
}
if !strings.Contains(err.Error(), "registration is disabled") {
t.Fatalf("expected registration disabled error, got %v", err)
}
})
t.Run("existing_user_allowed", func(t *testing.T) {
cfg := Config{AllowSignup: false}
h := newTestHandler(cfg)
// mockDB returns nil error for Scan, simulating user found
h.Queries = db.New(&mockDB{getUserErr: nil})
_, err := h.findOrCreateUser(context.Background(), "existing@test.com")
if err != nil {
t.Fatalf("expected no error for existing user, got %v", err)
}
})
t.Run("whitelisted_user_allowed", func(t *testing.T) {
cfg := Config{AllowSignup: false, AllowedEmails: []string{"whitelisted@test.com"}}
h := newTestHandler(cfg)
h.Queries = db.New(&mockDB{getUserErr: pgx.ErrNoRows})
// This will pass checkSignupAllowed and move to CreateUser.
// Our mockDB Exec returns success, but Queries.CreateUser might expect QueryRow for RETURNING id.
// Let's see if it works.
_, err := h.findOrCreateUser(context.Background(), "whitelisted@test.com")
if err != nil && strings.Contains(err.Error(), "registration is disabled") {
t.Fatalf("expected whitelisted user to pass signup check, but got %v", err)
}
})
}

View File

@@ -31,12 +31,6 @@ type dbExecutor interface {
QueryRow(ctx context.Context, sql string, args ...any) pgx.Row
}
type Config struct {
AllowSignup bool
AllowedEmails []string
AllowedEmailDomains []string
}
type Handler struct {
Queries *db.Queries
DB dbExecutor
@@ -50,10 +44,9 @@ type Handler struct {
UpdateStore *UpdateStore
Storage storage.Storage
CFSigner *auth.CloudFrontSigner
cfg Config
}
func New(queries *db.Queries, txStarter txStarter, hub *realtime.Hub, bus *events.Bus, emailService *service.EmailService, store storage.Storage, cfSigner *auth.CloudFrontSigner, cfg Config) *Handler {
func New(queries *db.Queries, txStarter txStarter, hub *realtime.Hub, bus *events.Bus, emailService *service.EmailService, store storage.Storage, cfSigner *auth.CloudFrontSigner) *Handler {
var executor dbExecutor
if candidate, ok := txStarter.(dbExecutor); ok {
executor = candidate
@@ -73,7 +66,6 @@ func New(queries *db.Queries, txStarter txStarter, hub *realtime.Hub, bus *event
UpdateStore: NewUpdateStore(),
Storage: store,
CFSigner: cfSigner,
cfg: cfg,
}
}

View File

@@ -54,7 +54,7 @@ func TestMain(m *testing.M) {
go hub.Run()
bus := events.New()
emailSvc := service.NewEmailService()
testHandler = New(queries, pool, hub, bus, emailSvc, nil, nil, Config{AllowSignup: true})
testHandler = New(queries, pool, hub, bus, emailSvc, nil, nil)
testPool = pool
testUserID, testWorkspaceID, err = setupHandlerTestFixture(ctx, pool)
@@ -821,39 +821,6 @@ func TestSendCode(t *testing.T) {
})
}
func TestSendCodeDbError(t *testing.T) {
// We can't easily mock the DB here without changing architecture,
// but we can simulate a DB error by closing the pool temporarily or
// using a cancelled context if the query respects it.
// Create a handler with a "broken" queries object is hard because it's a struct.
// Instead, let's use a context that is already cancelled.
ctx, cancel := context.WithCancel(context.Background())
cancel()
w := httptest.NewRecorder()
body := map[string]string{"email": "dberror-test@multica.ai"}
var buf bytes.Buffer
json.NewEncoder(&buf).Encode(body)
req := httptest.NewRequest("POST", "/auth/send-code", &buf)
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(ctx)
testHandler.SendCode(w, req)
// If the DB query respects the cancelled context, it should return an error.
// pgx usually returns context.Canceled which is not what isNotFound checks for.
if w.Code != http.StatusInternalServerError {
t.Fatalf("SendCode (db error): expected 500, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]string
json.NewDecoder(w.Body).Decode(&resp)
if resp["error"] != "failed to lookup user" {
t.Fatalf("SendCode (db error): expected error message 'failed to lookup user', got '%s'", resp["error"])
}
}
func TestSendCodeRateLimit(t *testing.T) {
const email = "ratelimit-test@multica.ai"
t.Cleanup(func() {