diff --git a/src/repository/message.ts b/src/repository/message.ts index 605614a31..60901edad 100644 --- a/src/repository/message.ts +++ b/src/repository/message.ts @@ -77,12 +77,13 @@ function messageSessionLookup(identityOrPhysicalId: string, ownerUserId?: number function messageCanonicalSessionLookup(identity: string, ownerUserId?: number) { const canonicalCondition = isReservedSessionIdentity(identity) - ? ownerUserId !== undefined - ? or( - eq(messageRequest.sessionIdentity, identity), - and(isNull(messageRequest.sessionIdentity), eq(messageRequest.sessionId, identity)) - ) - : eq(messageRequest.sessionIdentity, identity) + ? and( + // 保留 expression index 入口, 同时避免 reserved identity 混入同名物理 Session. + eq(messageSessionIdentity, identity), + ownerUserId !== undefined + ? or(eq(messageRequest.sessionIdentity, identity), isNull(messageRequest.sessionIdentity)) + : eq(messageRequest.sessionIdentity, identity) + ) : eq(messageSessionIdentity, identity); return and( diff --git a/tests/api/v1/sessions/sessions.test.ts b/tests/api/v1/sessions/sessions.test.ts index 2b89cac21..c1e3cff3c 100644 --- a/tests/api/v1/sessions/sessions.test.ts +++ b/tests/api/v1/sessions/sessions.test.ts @@ -137,6 +137,16 @@ describe("v1 session endpoints", () => { expect(requests.response.status).toBe(200); expect(getSessionRequestsMock).toHaveBeenCalledWith("s1", 2, 5, "desc"); + for (const identity of ["pfx:scope:fingerprint", "sid:canonical-session"]) { + const encodedRequests = await callV1Route({ + method: "GET", + pathname: `/api/v1/sessions/${encodeURIComponent(identity)}/requests?page=1&pageSize=20&order=desc`, + headers, + }); + expect(encodedRequests.response.status).toBe(200); + expect(getSessionRequestsMock).toHaveBeenLastCalledWith(identity, 1, 20, "desc"); + } + await callV1Route({ method: "GET", pathname: "/api/v1/sessions/s1/requests", diff --git a/tests/unit/repository/message-session-request-query.test.ts b/tests/unit/repository/message-session-request-query.test.ts index c05a81e5c..530280233 100644 --- a/tests/unit/repository/message-session-request-query.test.ts +++ b/tests/unit/repository/message-session-request-query.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, test, vi } from "vitest"; +import { PgDialect } from "drizzle-orm/pg-core"; import { messageRequest } from "@/drizzle/schema"; import { keys as keysTable } from "@/drizzle/schema"; import { @@ -50,6 +51,12 @@ type RequestRow = Pick< const firstCreatedAt = new Date("2026-05-04T10:00:00.000Z"); const secondCreatedAt = new Date("2026-05-04T10:01:00.000Z"); +const dialect = new PgDialect(); + +function compileWhere(values: readonly unknown[]) { + const query = dialect.sqlToQuery(values.at(0) as never); + return { sql: query.sql.toLowerCase(), params: query.params }; +} describe("message repository session request queries", () => { beforeEach(() => { @@ -237,33 +244,50 @@ describe("message repository session request queries", () => { expect(rowsWhere.match(/shared-session/g)).toHaveLength(1); }); - test("includes a legacy null-identity physical fallback for owner-scoped reserved identities", async () => { - const count = createDrizzleQuery([{ count: 1 }]); - const rows = createDrizzleQuery([]); - boundary.select.mockReturnValueOnce(count).mockReturnValueOnce(rows); - - await findRequestsBySessionIdentity("pfx:legacy-client", { ownerUserId: 17 } as never); - - for (const where of [sqlText(count.trace.where), sqlText(rows.trace.where)]) { - expect(where).toContain("user_id"); - expect(where).toContain("is null"); - expect(where).toContain("session_id"); - expect(where.match(/pfx:legacy-client/g)).toHaveLength(2); + test.each(["pfx:legacy-client", "sid:legacy-client"])( + "includes an owner-scoped legacy fallback without aliasing a non-null identity: %s", + async (identity) => { + const count = createDrizzleQuery([{ count: 1 }]); + const rows = createDrizzleQuery([]); + boundary.select.mockReturnValueOnce(count).mockReturnValueOnce(rows); + + await findRequestsBySessionIdentity(identity, { ownerUserId: 17 } as never); + + for (const where of [count.trace.where, rows.trace.where]) { + const compiled = compileWhere(where); + expect(compiled.sql).toContain( + 'coalesce("message_request"."session_identity", "message_request"."session_id") =' + ); + expect(compiled.sql).toContain( + '("message_request"."session_identity" = $2 or "message_request"."session_identity" is null)' + ); + expect(compiled.sql).not.toContain('or "message_request"."session_id" ='); + expect(compiled.sql).toContain('"message_request"."user_id" = $3'); + expect(compiled.params.slice(0, 3)).toEqual([identity, identity, 17]); + } } - }); - - test("does not add the legacy physical fallback for unscoped reserved identities", async () => { - const count = createDrizzleQuery([{ count: 1 }]); - const rows = createDrizzleQuery([]); - boundary.select.mockReturnValueOnce(count).mockReturnValueOnce(rows); - - await findRequestsBySessionIdentity("pfx:canonical", {} as never); - - for (const where of [sqlText(count.trace.where), sqlText(rows.trace.where)]) { - expect(where).not.toContain("session_identity is null"); - expect(where.match(/pfx:canonical/g)).toHaveLength(1); + ); + + test.each(["pfx:canonical", "sid:canonical"])( + "uses the canonical expression index with an explicit unscoped identity guard: %s", + async (identity) => { + const count = createDrizzleQuery([{ count: 1 }]); + const rows = createDrizzleQuery([]); + boundary.select.mockReturnValueOnce(count).mockReturnValueOnce(rows); + + await findRequestsBySessionIdentity(identity, {} as never); + + for (const where of [count.trace.where, rows.trace.where]) { + const compiled = compileWhere(where); + expect(compiled.sql).toContain( + 'coalesce("message_request"."session_identity", "message_request"."session_id") = $1 and "message_request"."session_identity" = $2' + ); + expect(compiled.sql).not.toContain('"message_request"."session_identity" is null'); + expect(compiled.sql).not.toContain('or "message_request"."session_id" ='); + expect(compiled.params.slice(0, 2)).toEqual([identity, identity]); + } } - }); + ); test("does not treat a reserved canonical identity as a physical Session alias", async () => { const locator = createDrizzleQuery([