|
1 | 1 | // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. |
2 | 2 |
|
3 | 3 | import { describe, it, expect } from 'vitest'; |
4 | | -import { resolveAuthzContext } from './resolve-authz-context.js'; |
| 4 | +import { resolveAuthzContext, resolveLocalizationContext } from './resolve-authz-context.js'; |
5 | 5 |
|
6 | 6 | /** |
7 | 7 | * Contract test for the SINGLE authorization resolver. Every authorization |
@@ -109,3 +109,63 @@ describe('resolveAuthzContext — single source of truth', () => { |
109 | 109 | expect(ctx.permissions).toEqual([]); |
110 | 110 | }); |
111 | 111 | }); |
| 112 | + |
| 113 | +// A counting ObjectQL: records how many find() calls hit each object so we can |
| 114 | +// assert the de-duplication of redundant authz/localization reads (#2409). |
| 115 | +function makeCountingQl(tables: Record<string, any[]>) { |
| 116 | + const counts: Record<string, number> = {}; |
| 117 | + return { |
| 118 | + counts, |
| 119 | + async find(object: string, opts: any) { |
| 120 | + counts[object] = (counts[object] ?? 0) + 1; |
| 121 | + const rows = tables[object] ?? []; |
| 122 | + const where = opts?.where ?? {}; |
| 123 | + return rows.filter((r) => |
| 124 | + Object.entries(where).every(([k, v]) => { |
| 125 | + if (v && typeof v === 'object' && '$in' in (v as any)) return (v as any).$in.includes(r[k]); |
| 126 | + return r[k] === v; |
| 127 | + }), |
| 128 | + ); |
| 129 | + }, |
| 130 | + }; |
| 131 | +} |
| 132 | + |
| 133 | +describe('resolveAuthzContext — request-scoped read de-duplication (#2409)', () => { |
| 134 | + it('reads sys_user at most once even when both email fallback and ai_seat need it', async () => { |
| 135 | + // No email in the session → email fallback reads sys_user; ai_seat synthesis |
| 136 | + // also needs sys_user. Previously these were two separate queries. |
| 137 | + const ql = makeCountingQl({ |
| 138 | + sys_user: [{ id: 'u1', email: 'ada@x.com', ai_access: 1 }], |
| 139 | + sys_member: [], |
| 140 | + sys_user_role: [], |
| 141 | + sys_user_permission_set: [], |
| 142 | + }); |
| 143 | + const ctx = await resolveAuthzContext({ ql, headers: H(), getSession: session('u1') }); |
| 144 | + expect(ctx.email).toBe('ada@x.com'); |
| 145 | + expect(ctx.permissions).toContain('ai_seat'); |
| 146 | + expect(ql.counts.sys_user).toBe(1); |
| 147 | + }); |
| 148 | +}); |
| 149 | + |
| 150 | +describe('resolveLocalizationContext — batched fallback read (#2409)', () => { |
| 151 | + it('reads sys_setting once (all three keys) when no settings service is wired', async () => { |
| 152 | + const ql = makeCountingQl({ |
| 153 | + sys_setting: [ |
| 154 | + { namespace: 'localization', key: 'timezone', scope: 'tenant', value: 'Asia/Tokyo' }, |
| 155 | + { namespace: 'localization', key: 'locale', scope: 'tenant', value: 'ja-JP' }, |
| 156 | + { namespace: 'localization', key: 'currency', scope: 'tenant', value: 'JPY' }, |
| 157 | + ], |
| 158 | + }); |
| 159 | + const loc = await resolveLocalizationContext({ ql, tenantId: 'o1' }); |
| 160 | + expect(loc).toEqual({ timezone: 'Asia/Tokyo', locale: 'ja-JP', currency: 'JPY' }); |
| 161 | + expect(ql.counts.sys_setting).toBe(1); |
| 162 | + }); |
| 163 | + |
| 164 | + it('falls back to UTC / en-US when no rows exist', async () => { |
| 165 | + const ql = makeCountingQl({ sys_setting: [] }); |
| 166 | + const loc = await resolveLocalizationContext({ ql }); |
| 167 | + expect(loc.timezone).toBe('UTC'); |
| 168 | + expect(loc.locale).toBe('en-US'); |
| 169 | + expect(loc.currency).toBeUndefined(); |
| 170 | + }); |
| 171 | +}); |
0 commit comments