import { beforeEach, describe, expect, it, vi } from 'vitest';
import { TenantAwareDBClient } from '../tenant-db-client.js';
describe('TenantAwareDBClient', () => {
    let mockPoolClient;
    let mockPool;
    let mockDBProvider;
    let mockAuthContext;
    let tenantDBClient;
    beforeEach(() => {
        // Mock PoolClient
        mockPoolClient = {
            query: vi.fn(),
            release: vi.fn(),
        };
        // Mock Pool
        mockPool = {
            connect: vi.fn().mockResolvedValue(mockPoolClient),
        };
        // Mock DBProvider
        mockDBProvider = {
            pool: mockPool,
            healthCheck: vi.fn(),
            shutdown: vi.fn(),
            query: vi.fn(),
        };
        // Mock AuthContext
        mockAuthContext = {
            authType: 'jwt',
            token: 'token',
            user: {
                userId: 'user-123',
                roleId: 'admin',
                email: 'test@test.com',
                permissions: [],
                emailVerified: true,
                permissionsVersion: 1,
            },
            tenant: {
                businessId: 'business-456',
                roleId: 'admin',
            },
            activeReadScope: { businessIds: ['business-456'] },
        };
        const authContextProvider = { getAuthContext: () => Promise.resolve(mockAuthContext) };
        tenantDBClient = new TenantAwareDBClient(mockDBProvider, authContextProvider);
    });
    describe('query', () => {
        it('should throw if auth context is missing', async () => {
            tenantDBClient = new TenantAwareDBClient(mockDBProvider, null);
            await expect(tenantDBClient.query('SELECT 1'))
                .rejects
                .toThrow('Auth context not available. TenantAwareDBClient requires active authentication.');
        });
        it('should start a transaction if none exists', async () => {
            vi.mocked(mockPoolClient.query).mockResolvedValue({ rows: [] });
            await tenantDBClient.query('SELECT 1');
            // Check transaction flow
            expect(mockPool.connect).toHaveBeenCalled();
            expect(mockPoolClient.query).toHaveBeenCalledWith('BEGIN');
            expect(mockPoolClient.query).toHaveBeenCalledWith(expect.stringContaining("set_config('app.current_business_id', $1, true)"), expect.anything());
            expect(mockPoolClient.query).toHaveBeenCalledWith('SELECT 1', undefined);
            expect(mockPoolClient.query).toHaveBeenCalledWith('COMMIT');
            expect(mockPoolClient.release).toHaveBeenCalled();
        });
        it('should reuse existing transaction', async () => {
            vi.mocked(mockPoolClient.query).mockResolvedValue({ rows: [] });
            await tenantDBClient.transaction(async () => {
                await tenantDBClient.query('SELECT 1');
            });
            // connect called only once
            expect(mockPool.connect).toHaveBeenCalledTimes(1);
            // BEGIN called once
            expect(mockPoolClient.query).toHaveBeenCalledWith('BEGIN');
            // SELECT called inside transaction
            expect(mockPoolClient.query).toHaveBeenCalledWith('SELECT 1', undefined);
        });
    });
    describe('transaction', () => {
        it('should throw if auth context is missing', async () => {
            tenantDBClient = new TenantAwareDBClient(mockDBProvider, null);
            await expect(tenantDBClient.transaction(async () => { }))
                .rejects
                .toThrow('Auth context not available. TenantAwareDBClient requires active authentication.');
        });
        it('should handle nested transactions with savepoints', async () => {
            vi.mocked(mockPoolClient.query).mockResolvedValue({ rows: [] });
            await tenantDBClient.transaction(async () => {
                await tenantDBClient.transaction(async () => {
                    await tenantDBClient.query('SELECT 1');
                });
            });
            expect(mockPoolClient.query).toHaveBeenCalledWith('SAVEPOINT sp_2');
            expect(mockPoolClient.query).toHaveBeenCalledWith('RELEASE SAVEPOINT sp_2');
        });
        it('should fallback nested transaction on error', async () => {
            vi.mocked(mockPoolClient.query).mockResolvedValue({ rows: [] });
            const error = new Error('nested error');
            await expect(tenantDBClient.transaction(async () => {
                await tenantDBClient.transaction(async () => {
                    throw error;
                });
            })).rejects.toThrow(error);
            expect(mockPoolClient.query).toHaveBeenCalledWith('ROLLBACK TO SAVEPOINT sp_2');
        });
        it('should rollback top-level transaction on error', async () => {
            vi.mocked(mockPoolClient.query).mockResolvedValue({ rows: [] });
            const error = new Error('top error');
            await expect(tenantDBClient.transaction(async () => {
                throw error;
            })).rejects.toThrow(error);
            expect(mockPoolClient.query).toHaveBeenCalledWith('ROLLBACK');
            expect(mockPoolClient.release).toHaveBeenCalled();
        });
    });
    describe('dispose', () => {
        it('should rollback and release active client', async () => {
            // Start a transaction but don't finish it (simulate interruption by catching reference?)
            // Hard to simulate "mid-flight" without modifying state directly or using a hanging promise?
            // We can just call transaction and rely on internal state if we mock connect/query?
            // Alternatively, we can inspect private state or simulate a hanging transaction?
            // Easier: Manually set activeClient via `any` cast for testing dispose logic if needed,
            // or start a transaction that awaits a signal?
            // Let's manually set state for testing dispose specifically
            tenantDBClient.activeClient = mockPoolClient;
            await tenantDBClient.dispose();
            expect(mockPoolClient.query).toHaveBeenCalledWith('ROLLBACK');
            expect(mockPoolClient.release).toHaveBeenCalled();
            expect(tenantDBClient.activeClient).toBeNull();
        });
        it('should wait for ongoing transaction to complete before disposing', async () => {
            // Start a transaction first to initialize activeClient
            vi.mocked(mockPoolClient.query).mockResolvedValue({ rows: [] });
            let transactionFinished = false;
            void tenantDBClient.transaction(async () => {
                await new Promise(resolve => setTimeout(resolve, 50));
                transactionFinished = true;
            });
            // Ensure transaction started (mutex acquired)
            await new Promise(resolve => setTimeout(resolve, 10));
            // Call dispose. This should await the mutex (wait for transaction)
            await tenantDBClient.dispose();
            expect(transactionFinished).toBe(true);
            expect(mockPoolClient.query).toHaveBeenCalledWith('COMMIT'); // completed naturally
            expect(mockPoolClient.release).toHaveBeenCalled();
            // Verify subsequent calls fail
            await expect(tenantDBClient.query('SELECT 1')).rejects.toThrow('TenantAwareDBClient is already disposed');
        });
        it('should be idempotent', async () => {
            tenantDBClient.activeClient = mockPoolClient;
            await tenantDBClient.dispose();
            await tenantDBClient.dispose();
            expect(mockPoolClient.release).toHaveBeenCalledTimes(1);
        });
        it('should release client even if rollback fails', async () => {
            const rollbackError = new Error('Rollback failed');
            vi.mocked(mockPoolClient.query).mockRejectedValueOnce(rollbackError);
            tenantDBClient.activeClient = mockPoolClient;
            await tenantDBClient.dispose();
            expect(mockPoolClient.query).toHaveBeenCalledWith('ROLLBACK');
            expect(mockPoolClient.release).toHaveBeenCalled();
            expect(tenantDBClient.activeClient).toBeNull();
        });
        it('should return early on timeout if mutex is held (avoid race condition)', async () => {
            // Use fake timers to fast-forward the 5s timeout
            vi.useFakeTimers();
            // Mock active client
            tenantDBClient.activeClient = mockPoolClient;
            // Acquire mutex manually to simulate stuck transaction
            // We need access to the mutex which is private.
            // Casting to any allows access for testing.
            const release = await tenantDBClient.mutex.acquire();
            const disposePromise = tenantDBClient.dispose();
            // Advance timers by 5000ms + buffer (must handle async promise resolution)
            await vi.advanceTimersByTimeAsync(6000);
            await disposePromise;
            // Cleanup
            release();
            vi.useRealTimers();
            // Assertions
            // 1. Client should NOT be released because we return early to avoid destroying
            //    an actively-used connection (prevents race condition)
            expect(mockPoolClient.release).not.toHaveBeenCalled();
            // 2. activeClient should still be set (cleanup deferred to transaction's finally block)
            expect(tenantDBClient.activeClient).toBe(mockPoolClient);
            // 3. isDisposed SHOULD be true to prevent further usage of this instance
            expect(tenantDBClient.isDisposed).toBe(true);
        });
    });
    describe('RLS variables', () => {
        it('should set correct RLS variables', async () => {
            vi.mocked(mockPoolClient.query).mockResolvedValue({ rows: [] });
            await tenantDBClient.query('SELECT 1');
            const expectedVars = [
                'business-456',
                'user-123',
                'jwt',
                '{"business-456"}',
            ];
            // Find the call that sets variables
            const setCall = vi.mocked(mockPoolClient.query).mock.calls.find((call) => call[0].includes("set_config('app.current_business_id', $1, true)"));
            expect(setCall).toBeDefined();
            expect(setCall[1]).toEqual(expectedVars);
        });
        it('should throw if businessId is missing in auth context and no single-scoped business', async () => {
            vi.mocked(mockPoolClient.query).mockResolvedValue({ rows: [] });
            // No tenant.businessId and no activeReadScope → no write target can be derived
            tenantDBClient.authContext = { ...mockAuthContext, tenant: {}, activeReadScope: undefined };
            tenantDBClient.authContextInitialized = true;
            await expect(tenantDBClient.query('SELECT 1')).rejects.toThrow('Missing businessId in AuthContext');
        });
        it('should use the single scoped business as write target when tenant.businessId is absent', async () => {
            vi.mocked(mockPoolClient.query).mockResolvedValue({ rows: [] });
            // tenant.businessId missing but activeReadScope has exactly one business
            tenantDBClient.authContext = {
                ...mockAuthContext,
                tenant: {},
                activeReadScope: { businessIds: ['business-456'] },
            };
            tenantDBClient.authContextInitialized = true;
            await tenantDBClient.query('SELECT 1');
            const setCall = vi.mocked(mockPoolClient.query).mock.calls.find((call) => call[0].includes("set_config('app.current_business_id', $1, true)"));
            expect(setCall).toBeDefined();
            expect(setCall[1][0]).toBe('business-456');
        });
        it('sets the write target and the read-scope array session variables', async () => {
            vi.mocked(mockPoolClient.query).mockResolvedValue({ rows: [] });
            await tenantDBClient.query('SELECT 1');
            const setCall = vi.mocked(mockPoolClient.query).mock.calls.find((call) => call[0].includes('set_config('));
            expect(setCall).toBeDefined();
            const sql = setCall[0];
            expect(sql).toContain("set_config('app.current_business_id', $1, true)");
            expect(sql).toContain("set_config('app.current_user_id', $2, true)");
            expect(sql).toContain("set_config('app.auth_type', $3, true)");
            expect(sql).toContain("set_config('app.current_business_scope', $4, true)");
            expect(setCall[1]).toHaveLength(4);
        });
        it('serializes a multi-business read scope as a Postgres array literal', async () => {
            vi.mocked(mockPoolClient.query).mockResolvedValue({ rows: [] });
            tenantDBClient.authContext = {
                ...mockAuthContext,
                activeReadScope: { businessIds: ['business-456', 'business-789'] },
            };
            tenantDBClient.authContextInitialized = true;
            await tenantDBClient.query('SELECT 1');
            const setCall = vi.mocked(mockPoolClient.query).mock.calls.find((call) => call[0].includes("set_config('app.current_business_scope', $4, true)"));
            expect(setCall).toBeDefined();
            expect(setCall[1][3]).toBe('{"business-456","business-789"}');
        });
        it('passes an empty read scope when none is resolved (DB falls back to single business)', async () => {
            vi.mocked(mockPoolClient.query).mockResolvedValue({ rows: [] });
            tenantDBClient.authContext = {
                ...mockAuthContext,
                activeReadScope: undefined,
            };
            tenantDBClient.authContextInitialized = true;
            await tenantDBClient.query('SELECT 1');
            const setCall = vi.mocked(mockPoolClient.query).mock.calls.find((call) => call[0].includes("set_config('app.current_business_scope', $4, true)"));
            expect(setCall).toBeDefined();
            expect(setCall[1][3]).toBe('');
        });
        it('uses single scoped business as write target, overriding primary tenant', async () => {
            vi.mocked(mockPoolClient.query).mockResolvedValue({ rows: [] });
            // Primary is 'business-456', but X-Business-Scope narrows to 'business-789'
            tenantDBClient.authContext = {
                ...mockAuthContext,
                tenant: { businessId: 'business-456', roleId: 'admin' },
                activeReadScope: { businessIds: ['business-789'] },
            };
            tenantDBClient.authContextInitialized = true;
            await tenantDBClient.query('SELECT 1');
            const setCall = vi.mocked(mockPoolClient.query).mock.calls.find((call) => call[0].includes("set_config('app.current_business_id', $1, true)"));
            expect(setCall).toBeDefined();
            // write target should be the single scoped business, not the primary
            expect(setCall[1][0]).toBe('business-789');
        });
        it('falls back to primary tenant when multi-scope includes primary', async () => {
            vi.mocked(mockPoolClient.query).mockResolvedValue({ rows: [] });
            tenantDBClient.authContext = {
                ...mockAuthContext,
                tenant: { businessId: 'business-456', roleId: 'admin' },
                activeReadScope: { businessIds: ['business-456', 'business-789'] },
            };
            tenantDBClient.authContextInitialized = true;
            await tenantDBClient.query('SELECT 1');
            const setCall = vi.mocked(mockPoolClient.query).mock.calls.find((call) => call[0].includes("set_config('app.current_business_id', $1, true)"));
            expect(setCall).toBeDefined();
            expect(setCall[1][0]).toBe('business-456');
        });
        it('uses first scoped business as write target when multi-scope excludes primary', async () => {
            vi.mocked(mockPoolClient.query).mockResolvedValue({ rows: [] });
            // Primary is 'business-456' but active scope is ['business-789', 'business-abc']
            // — primary is not in scope, so writing to it would violate RLS WITH CHECK.
            tenantDBClient.authContext = {
                ...mockAuthContext,
                tenant: { businessId: 'business-456', roleId: 'admin' },
                activeReadScope: { businessIds: ['business-789', 'business-abc'] },
            };
            tenantDBClient.authContextInitialized = true;
            await tenantDBClient.query('SELECT 1');
            const setCall = vi.mocked(mockPoolClient.query).mock.calls.find((call) => call[0].includes("set_config('app.current_business_id', $1, true)"));
            expect(setCall).toBeDefined();
            expect(setCall[1][0]).toBe('business-789');
        });
    });
});
//# sourceMappingURL=tenant-db-client.test.js.map