Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 7 additions & 6 deletions src/repository/message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
10 changes: 10 additions & 0 deletions tests/api/v1/sessions/sessions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
74 changes: 49 additions & 25 deletions tests/unit/repository/message-session-request-query.test.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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(() => {
Expand Down Expand Up @@ -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<readonly RequestRow[]>([]);
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<readonly RequestRow[]>([]);
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<readonly RequestRow[]>([]);
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<readonly RequestRow[]>([]);
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([
Expand Down
Loading