diff --git a/src/app/api/gigs/route.test.ts b/src/app/api/gigs/route.test.ts index 3b5bd0e8..c0a6e52c 100644 --- a/src/app/api/gigs/route.test.ts +++ b/src/app/api/gigs/route.test.ts @@ -113,6 +113,33 @@ describe("GET /api/gigs", () => { expect(json.pagination.total).toBe(1); }); + it.each(["human", "agent"])("filters gigs by the %s poster relationship", async (accountType) => { + const chain = chainResult({ data: null, error: null }); + chain.range = vi.fn().mockResolvedValue({ data: [], error: null, count: 0 }); + mockFrom.mockReturnValue(chain); + + const res = await GET(makeGetRequest({ account_type: accountType })); + + expect(res.status).toBe(200); + // Filtering the embedded object alone keeps unrelated parent rows. + // An inner join must constrain the gigs and their exact pagination count. + expect(chain.select.mock.calls[0][0]).toContain("poster:profiles!poster_id!inner"); + expect(chain.eq).toHaveBeenCalledWith("poster.account_type", accountType); + expect(chain.eq).not.toHaveBeenCalledWith("poster:profiles!poster_id.account_type", accountType); + }); + + it("keeps the default left join when no account type is requested", async () => { + const chain = chainResult({ data: null, error: null }); + chain.range = vi.fn().mockResolvedValue({ data: [], error: null, count: 0 }); + mockFrom.mockReturnValue(chain); + + const res = await GET(makeGetRequest()); + + expect(res.status).toBe(200); + expect(chain.select.mock.calls[0][0]).not.toContain("!inner"); + expect(chain.eq.mock.calls.some(([column]) => column === "poster.account_type")).toBe(false); + }); + it("caps huge page values before building the Supabase range", async () => { const chain = chainResult({ data: null, error: null }); chain.select = vi.fn().mockReturnValue(chain); diff --git a/src/app/api/gigs/route.ts b/src/app/api/gigs/route.ts index 2beacd8a..44c63a9c 100644 --- a/src/app/api/gigs/route.ts +++ b/src/app/api/gigs/route.ts @@ -59,7 +59,7 @@ export async function GET(request: NextRequest) { .select( ` *, - poster:profiles!poster_id ( + poster:profiles!poster_id${account_type ? "!inner" : ""} ( id, username, full_name, @@ -121,7 +121,7 @@ export async function GET(request: NextRequest) { } if (account_type) { - query = query.eq("poster:profiles!poster_id.account_type", account_type); + query = query.eq("poster.account_type", account_type); } // Apply sorting diff --git a/src/lib/github-links.test.ts b/src/lib/github-links.test.ts index aa216433..6f6eff3f 100644 --- a/src/lib/github-links.test.ts +++ b/src/lib/github-links.test.ts @@ -7,6 +7,13 @@ import { } from "./github-links"; describe("isGitHubPrLink", () => { + it("rejects a numeric prefix followed by a non-path suffix", () => { + for (const suffix of ["12abc", "12.5", "12-closed"]) { + const url = `https://github.com/org/repo/pull/${suffix}`; + expect(isGitHubPrLink(url)).toBe(false); + expect(parseGitHubPullUrl(url)).toBeNull(); + } + }); it("accepts a single pull request URL", () => { expect(isGitHubPrLink("https://github.com/profullstack/ugig.net/pull/42")).toBe(true); expect(isGitHubPrLink("https://github.com/org/repo/pull/1/files")).toBe(true); diff --git a/src/lib/github-links.ts b/src/lib/github-links.ts index fa20a75a..8898398e 100644 --- a/src/lib/github-links.ts +++ b/src/lib/github-links.ts @@ -14,7 +14,7 @@ export function isGitHubPrLink(value: string): boolean { const path = url.pathname; return ( // A single PR: /owner/repo/pull/123 (optionally /files, #discussion, …) - /^\/[^/]+\/[^/]+\/pull\/\d+/.test(path) || + /^\/[^/]+\/[^/]+\/pull\/\d+(?:\/.*)?$/.test(path) || // A repo's PR list/search: /owner/repo/pulls /^\/[^/]+\/[^/]+\/pulls\/?$/.test(path) || // The global PR search: /pulls diff --git a/src/lib/mentions.test.ts b/src/lib/mentions.test.ts index 373ae9f4..8aab8948 100644 --- a/src/lib/mentions.test.ts +++ b/src/lib/mentions.test.ts @@ -35,6 +35,21 @@ describe("parseMentions", () => { }); describe("parseContentWithMentions", () => { + it("keeps email addresses and URL userinfo as ordinary text", () => { + for (const content of ["Contact alice@example.com", "https://user@example.com/path"]) { + expect(parseContentWithMentions(content)).toEqual([{ type: "text", value: content }]); + expect(parseMentions(content)).toEqual([]); + } + }); + + it("preserves parentheses and whitespace around real mentions", () => { + expect(parseContentWithMentions("Hi (@Alice) and\n@bob")).toEqual([ + { type: "text", value: "Hi (" }, + { type: "mention", username: "Alice" }, + { type: "text", value: ") and\n" }, + { type: "mention", username: "bob" }, + ]); + }); it("parses text with mentions into segments", () => { const segments = parseContentWithMentions("hey @alice check this"); expect(segments).toEqual([ diff --git a/src/lib/mentions.ts b/src/lib/mentions.ts index 8e2fb87f..47133c76 100644 --- a/src/lib/mentions.ts +++ b/src/lib/mentions.ts @@ -24,15 +24,18 @@ export type MentionSegment = export function parseContentWithMentions(content: string): MentionSegment[] { const segments: MentionSegment[] = []; - const regex = /@([a-zA-Z0-9_-]+)/g; + // Use the same token boundary as notification extraction. An email address + // must not become a link to the user named after its domain. + const regex = /(^|[\s(])@([a-zA-Z0-9_-]+)/g; let lastIndex = 0; let match: RegExpExecArray | null; while ((match = regex.exec(content)) !== null) { - if (match.index > lastIndex) { - segments.push({ type: "text", value: content.slice(lastIndex, match.index) }); + const mentionStart = match.index + match[1].length; + if (mentionStart > lastIndex) { + segments.push({ type: "text", value: content.slice(lastIndex, mentionStart) }); } - segments.push({ type: "mention", username: match[1] }); + segments.push({ type: "mention", username: match[2] }); lastIndex = regex.lastIndex; }