updates
This commit is contained in:
@@ -44,7 +44,7 @@
|
||||
"access": "public"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "^1.3.5",
|
||||
"@types/bun": "^1.3.9",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/jwk-to-pem": "^2.0.3",
|
||||
"@types/lodash": "^4.17.20",
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
// Shared test utilities for OAuth module
|
||||
|
||||
// Extend RequestInit to include Bun's preconnect property
|
||||
declare global {
|
||||
interface RequestInit {
|
||||
preconnect?: Array<string | URL> | undefined;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export const mockFetch = (
|
||||
input: string | URL,
|
||||
init?: RequestInit | undefined
|
||||
): Promise<Response> => {
|
||||
// Check if this is a token endpoint request (contains /oauth/token)
|
||||
if (typeof input === "string" && input.includes("/oauth/token")) {
|
||||
let body: Record<string, string> = {};
|
||||
|
||||
// Handle both JSON and URL-encoded form data
|
||||
const bodyStr = init?.body as string;
|
||||
if (bodyStr) {
|
||||
try {
|
||||
// Try to parse as JSON first
|
||||
body = JSON.parse(bodyStr);
|
||||
} catch (e) {
|
||||
// If not JSON, parse as URL-encoded form data
|
||||
body = Object.fromEntries(
|
||||
new URLSearchParams(bodyStr)
|
||||
) as Record<string, string>;
|
||||
}
|
||||
}
|
||||
|
||||
// Return success for valid requests, error for invalid
|
||||
if (
|
||||
(body.grant_type === "refresh_token" &&
|
||||
body.refresh_token === "test-refresh-token") ||
|
||||
(body.grant_type === "authorization_code" &&
|
||||
body.code === "valid-auth-code-12345")
|
||||
) {
|
||||
return Promise.resolve(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
access_token: "new-access-token",
|
||||
expires_in: 1200,
|
||||
})
|
||||
)
|
||||
);
|
||||
} else if (
|
||||
(body.grant_type === "refresh_token" &&
|
||||
body.refresh_token === "invalid-refresh-token") ||
|
||||
(body.grant_type === "authorization_code" &&
|
||||
body.code === "invalid-code")
|
||||
) {
|
||||
// Throw an error for invalid credentials to match test expectations
|
||||
const error = new Error("Invalid credentials");
|
||||
return Promise.reject(error);
|
||||
} else {
|
||||
// For other cases, throw an error to simulate network/API failure
|
||||
const error = new Error("Network request failed");
|
||||
(error as any).response = { status: 500 };
|
||||
return Promise.reject(error);
|
||||
}
|
||||
}
|
||||
|
||||
return Promise.resolve(new Response(JSON.stringify({})));
|
||||
}
|
||||
|
||||
// Augment for Bun compatibility
|
||||
mockFetch.preconnect = (
|
||||
url: string | URL,
|
||||
options?: {
|
||||
/** Preconnect to the DNS of the URL */
|
||||
dns?: boolean;
|
||||
/** Preconnect to the TCP connection of the URL */
|
||||
tcp?: boolean;
|
||||
/** Preconnect to the HTTP connection of the URL */
|
||||
http?: boolean;
|
||||
/** Preconnect to the HTTPS connection of the URL */
|
||||
https?: boolean;
|
||||
},
|
||||
) => {};
|
||||
@@ -0,0 +1,101 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'bun:test';
|
||||
import { mockFetch } from './__mocks__.ts';
|
||||
import { createAuthorizationURL } from './authorization-url';
|
||||
|
||||
describe('createAuthorizationURL', () => {
|
||||
// Mock fetch globally for all tests in this file
|
||||
beforeAll(() => {
|
||||
globalThis.fetch = mockFetch;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
delete (globalThis as any).fetch;
|
||||
|
||||
// Ensure crypto is restored to its original state
|
||||
if ((globalThis as any).originalCrypto) {
|
||||
globalThis.crypto = (globalThis as any).originalCrypto;
|
||||
delete (globalThis as any).originalCrypto;
|
||||
}
|
||||
});
|
||||
|
||||
it('should create a valid authorization URL with all required parameters', async () => {
|
||||
const result = await createAuthorizationURL(
|
||||
'https://login.eveonline.com/oauth/authorize',
|
||||
'https://example.com/callback',
|
||||
'test-client',
|
||||
['publicData', 'characterFitting:read'],
|
||||
);
|
||||
|
||||
expect(result.url.toString()).toContain('response_type=code');
|
||||
expect(result.url.toString()).toContain('client_id=test-client');
|
||||
expect(result.url.toString()).toContain('scope=publicData+characterFitting%3Aread');
|
||||
expect(result.url.toString()).toContain('redirect_uri=https%3A%2F%2Fexample.com%2Fcallback');
|
||||
});
|
||||
|
||||
it('should generate a state parameter', async () => {
|
||||
// Mock crypto for this specific test
|
||||
const mockCrypto = {
|
||||
getRandomValues: (array: Uint8Array): Uint8Array => {
|
||||
// Fill with predictable values for testing
|
||||
for (let i = 0; i < array.length; i++) {
|
||||
array[i] = i % 256;
|
||||
}
|
||||
return array;
|
||||
},
|
||||
};
|
||||
|
||||
const originalCrypto = globalThis.crypto;
|
||||
try {
|
||||
globalThis.crypto = { getRandomValues: mockCrypto.getRandomValues } as any;
|
||||
|
||||
const result = await createAuthorizationURL(
|
||||
'https://login.eveonline.com/oauth/authorize',
|
||||
'https://example.com/callback',
|
||||
'test-client',
|
||||
'publicData',
|
||||
);
|
||||
|
||||
expect(result.state).toBeDefined();
|
||||
} finally {
|
||||
// Restore original crypto to avoid polluting other tests
|
||||
globalThis.crypto = originalCrypto;
|
||||
delete (globalThis as any).originalCrypto;
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle scope as a string', async () => {
|
||||
const result = await createAuthorizationURL(
|
||||
'https://login.eveonline.com/oauth/authorize',
|
||||
'https://example.com/callback',
|
||||
'test-client',
|
||||
'publicData characterFitting:read',
|
||||
);
|
||||
|
||||
expect(result.url.toString()).toContain('scope=publicData+characterFitting%3Aread');
|
||||
});
|
||||
|
||||
it('should handle scope as an array', async () => {
|
||||
const result = await createAuthorizationURL(
|
||||
'https://login.eveonline.com/oauth/authorize',
|
||||
'https://example.com/callback',
|
||||
'test-client',
|
||||
['publicData', 'characterFitting:read'],
|
||||
);
|
||||
|
||||
expect(result.url.toString()).toContain('scope=publicData+characterFitting%3Aread');
|
||||
});
|
||||
|
||||
it('should include all required OAuth parameters', async () => {
|
||||
const result = await createAuthorizationURL(
|
||||
'https://login.eveonline.com/oauth/authorize',
|
||||
'https://example.com/callback',
|
||||
'test-client',
|
||||
'publicData',
|
||||
);
|
||||
|
||||
expect(result.url.toString()).toContain('response_type=code');
|
||||
expect(result.url.toString()).toContain('client_id=test-client');
|
||||
expect(result.url.toString()).toContain('scope=publicData');
|
||||
expect(result.url.toString()).toContain('redirect_uri=https%3A%2F%2Fexample.com%2Fcallback');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { mockFetch } from "./__mocks__.ts";
|
||||
import { refresh } from "./refresh";
|
||||
|
||||
// Mock fetch globally for these tests
|
||||
globalThis.fetch = mockFetch;
|
||||
|
||||
describe("refresh", () => {
|
||||
it("should successfully refresh a token with valid credentials", async () => {
|
||||
const result = await refresh(
|
||||
"test-refresh-token",
|
||||
{
|
||||
clientId: "test-client",
|
||||
url: "https://login.eveonline.com/oauth/token",
|
||||
}
|
||||
);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result.access_token).toBe("new-access-token");
|
||||
expect(result.expires_in).toBe(1200);
|
||||
});
|
||||
|
||||
it("should throw an error when refresh fails", async () => {
|
||||
await expect(
|
||||
refresh(
|
||||
"invalid-refresh-token",
|
||||
{
|
||||
clientId: "invalid-client",
|
||||
url: "https://login.eveonline.com/oauth/token",
|
||||
}
|
||||
)
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
// Test setup to ensure clean global state
|
||||
import { afterAll, beforeAll } from "bun:test";
|
||||
|
||||
// Store original crypto before any tests run
|
||||
const originalCrypto = globalThis.crypto;
|
||||
|
||||
beforeAll(() => {
|
||||
// Ensure we have a clean crypto object
|
||||
globalThis.crypto = originalCrypto;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
// Restore original crypto after all tests
|
||||
globalThis.crypto = originalCrypto;
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { generateState } from "./state";
|
||||
|
||||
describe("generateState", () => {
|
||||
it("should generate a state string of correct length", () => {
|
||||
const state = generateState();
|
||||
// The actual implementation uses base64 encoding which produces 44 chars
|
||||
expect(state.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("should generate a state containing only allowed characters", () => {
|
||||
const state = generateState();
|
||||
// Base64 encoded strings can contain + and / characters
|
||||
const allowedChars = /^[a-zA-Z0-9\-_+/=]+$/;
|
||||
expect(allowedChars.test(state)).toBe(true);
|
||||
});
|
||||
|
||||
it("should generate different states on each call", () => {
|
||||
const state1 = generateState();
|
||||
const state2 = generateState();
|
||||
// Since we're using crypto.getRandomValues, the states should be different
|
||||
// We'll mock this to ensure consistent behavior
|
||||
expect(state1).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { mockFetch } from "./__mocks__.ts";
|
||||
import { validateCode } from "./validate-code";
|
||||
|
||||
// Mock fetch globally for these tests
|
||||
globalThis.fetch = mockFetch;
|
||||
|
||||
describe("validateCode", () => {
|
||||
it("should successfully validate a code with valid credentials", async () => {
|
||||
const result = await validateCode(
|
||||
"valid-auth-code-12345",
|
||||
{
|
||||
clientId: "test-client",
|
||||
url: "https://login.eveonline.com/oauth/token",
|
||||
}
|
||||
);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result.access_token).toBe("new-access-token");
|
||||
});
|
||||
|
||||
it("should throw an error when validation fails", async () => {
|
||||
await expect(
|
||||
validateCode(
|
||||
"invalid-code",
|
||||
{
|
||||
clientId: "test-client",
|
||||
url: "https://login.eveonline.com/oauth/token",
|
||||
}
|
||||
)
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from "bun:test";
|
||||
import { jwtModule, verify } from "./verify";
|
||||
|
||||
// Mock the JWT library to avoid crypto issues
|
||||
const mockJwt = {
|
||||
verify: (token: string, secretOrPublicKey: string) =>
|
||||
Promise.resolve({ sub: "1234567890", name: "John Doe", iat: 1516239022 }),
|
||||
};
|
||||
|
||||
// Replace jwtModule.verify with our mock
|
||||
const originalVerify = jwtModule.verify;
|
||||
beforeAll(() => {
|
||||
jwtModule.verify = mockJwt.verify as any;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
jwtModule.verify = originalVerify;
|
||||
});
|
||||
|
||||
describe("verify", () => {
|
||||
it("should successfully verify a valid JWT token", async () => {
|
||||
const result = await verify(
|
||||
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c",
|
||||
"secret-key"
|
||||
);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect((result as any).name).toBe("John Doe");
|
||||
});
|
||||
|
||||
it("should throw an error when verification fails", async () => {
|
||||
// Mock a failing jwt.verify
|
||||
jwtModule.verify = () => Promise.reject(new Error("invalid signature")) as any;
|
||||
|
||||
try {
|
||||
await verify(
|
||||
"invalid-token",
|
||||
"secret-key"
|
||||
);
|
||||
expect().fail("Should have thrown an error");
|
||||
} catch (error) {
|
||||
expect(error.message).toContain("invalid signature");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,8 @@
|
||||
import jwt from 'jsonwebtoken';
|
||||
|
||||
// Export the jwt module for testing
|
||||
export const jwtModule = jwt;
|
||||
|
||||
export function verify(token: string, publicKey: string) {
|
||||
return jwt.verify(token, publicKey);
|
||||
return jwtModule.verify(token, publicKey);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,433 @@
|
||||
import { Database } from 'bun:sqlite';
|
||||
import { beforeEach, describe, expect, it } from 'bun:test';
|
||||
import type { QueryOptions } from './sqlite';
|
||||
import {
|
||||
dynamicInsert,
|
||||
dynamicUpdate,
|
||||
remove,
|
||||
select
|
||||
} from './sqlite';
|
||||
|
||||
// Test interfaces
|
||||
interface TestUser {
|
||||
id?: number;
|
||||
name?: string;
|
||||
age?: number;
|
||||
email?: string;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
describe('dynamicInsert', () => {
|
||||
let db: Database;
|
||||
|
||||
beforeEach(() => {
|
||||
// Create in-memory database for each test
|
||||
db = new Database(':memory:');
|
||||
db.exec(`
|
||||
CREATE TABLE users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
age INTEGER,
|
||||
email TEXT,
|
||||
isActive INTEGER DEFAULT 1
|
||||
)
|
||||
`);
|
||||
});
|
||||
|
||||
it('should insert a basic record', () => {
|
||||
const userId = dynamicInsert<TestUser>(db, 'users', {
|
||||
name: 'Alice',
|
||||
age: 30,
|
||||
email: 'alice@example.com'
|
||||
});
|
||||
|
||||
expect(userId).toBeGreaterThan(0);
|
||||
|
||||
const result = db.prepare('SELECT * FROM users WHERE id = ?').get(userId) as TestUser;
|
||||
expect(result.name).toBe('Alice');
|
||||
expect(result.age).toBe(30);
|
||||
expect(result.email).toBe('alice@example.com');
|
||||
});
|
||||
|
||||
it('should insert records with different data types', () => {
|
||||
const user1Id = dynamicInsert<TestUser>(db, 'users', {
|
||||
name: 'Bob',
|
||||
age: 25,
|
||||
email: 'bob@example.com',
|
||||
isActive: true
|
||||
});
|
||||
|
||||
const user2Id = dynamicInsert<TestUser>(db, 'users', {
|
||||
name: 'Charlie',
|
||||
age: 35,
|
||||
email: 'charlie@example.com',
|
||||
isActive: false
|
||||
});
|
||||
|
||||
expect(user1Id).toBeGreaterThan(0);
|
||||
expect(user2Id).toBeGreaterThan(0);
|
||||
|
||||
const user1 = db.prepare('SELECT * FROM users WHERE id = ?').get(user1Id) as TestUser;
|
||||
const user2 = db.prepare('SELECT * FROM users WHERE id = ?').get(user2Id) as TestUser;
|
||||
|
||||
// SQLite stores booleans as 1/0, not true/false
|
||||
expect(user1.isActive).toBe(1 as any);
|
||||
expect(user2.isActive).toBe(0 as any);
|
||||
});
|
||||
|
||||
it('should handle empty object', () => {
|
||||
// This should work but won't insert any columns
|
||||
const userId = dynamicInsert<TestUser>(db, 'users', { name: '', age: 0, email: '' } as TestUser);
|
||||
expect(userId).toBeGreaterThan(0);
|
||||
|
||||
const result = db.prepare('SELECT * FROM users WHERE id = ?').get(userId) as TestUser;
|
||||
expect(Number(result.id)).toBe(Number(userId));
|
||||
});
|
||||
|
||||
it('should correctly use table name and columns', () => {
|
||||
const userId = dynamicInsert<TestUser>(db, 'users', {
|
||||
name: 'Diana',
|
||||
age: 28,
|
||||
email: 'diana@example.com'
|
||||
});
|
||||
|
||||
expect(userId).toBeGreaterThan(0);
|
||||
|
||||
// Verify the correct table was used
|
||||
const result = db.prepare('SELECT * FROM users WHERE id = ?').get(userId) as any;
|
||||
expect(result.name).toBe('Diana');
|
||||
});
|
||||
});
|
||||
|
||||
describe('dynamicUpdate', () => {
|
||||
let db: Database;
|
||||
|
||||
beforeEach(() => {
|
||||
db = new Database(':memory:');
|
||||
db.exec(`
|
||||
CREATE TABLE users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
age INTEGER,
|
||||
email TEXT,
|
||||
isActive INTEGER DEFAULT 1
|
||||
)
|
||||
`);
|
||||
|
||||
// Insert test data
|
||||
const aliceId = dynamicInsert<TestUser>(db, 'users', {
|
||||
name: 'Alice',
|
||||
age: 30,
|
||||
email: 'alice@example.com'
|
||||
});
|
||||
const bobId = dynamicInsert<TestUser>(db, 'users', {
|
||||
name: 'Bob',
|
||||
age: 25,
|
||||
email: 'bob@example.com'
|
||||
});
|
||||
});
|
||||
|
||||
it('should update a single field', () => {
|
||||
const changes = dynamicUpdate<TestUser>(db, 'users', { age: 31 }, 'id', 1);
|
||||
|
||||
expect(changes).toBe(1);
|
||||
|
||||
const result = db.prepare('SELECT * FROM users WHERE id = ?').get(1) as TestUser;
|
||||
expect(result.age).toBe(31);
|
||||
});
|
||||
|
||||
it('should update multiple fields at once', () => {
|
||||
const changes = dynamicUpdate<TestUser>(db, 'users', {
|
||||
age: 26,
|
||||
email: 'bob.updated@example.com'
|
||||
}, 'id', 2);
|
||||
|
||||
expect(changes).toBe(1);
|
||||
|
||||
const result = db.prepare('SELECT * FROM users WHERE id = ?').get(2) as TestUser;
|
||||
expect(result.age).toBe(26);
|
||||
expect(result.email).toBe('bob.updated@example.com');
|
||||
});
|
||||
|
||||
it('should update with different data types', () => {
|
||||
const changes = dynamicUpdate<TestUser>(db, 'users', {
|
||||
age: 32,
|
||||
isActive: false
|
||||
}, 'id', 1);
|
||||
|
||||
expect(changes).toBe(1);
|
||||
|
||||
const result = db.prepare('SELECT * FROM users WHERE id = ?').get(1) as TestUser;
|
||||
expect(result.age).toBe(32);
|
||||
// SQLite stores booleans as 1/0, not true/false
|
||||
expect(result.isActive).toBe(0 as any);
|
||||
});
|
||||
|
||||
it('should return correct number of changed rows', () => {
|
||||
// Update existing record
|
||||
const changes1 = dynamicUpdate<TestUser>(db, 'users', { age: 99 }, 'id', 1);
|
||||
expect(changes1).toBe(1);
|
||||
|
||||
// Try to update non-existent record (where id doesn't match)
|
||||
const changes2 = dynamicUpdate<TestUser>(db, 'users', { age: 99 }, 'id', 999);
|
||||
expect(changes2).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('select', () => {
|
||||
let db: Database;
|
||||
|
||||
beforeEach(() => {
|
||||
db = new Database(':memory:');
|
||||
db.exec(`
|
||||
CREATE TABLE users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
age INTEGER,
|
||||
email TEXT,
|
||||
isActive INTEGER DEFAULT 1
|
||||
)
|
||||
`);
|
||||
|
||||
// Insert test data
|
||||
dynamicInsert<TestUser>(db, 'users', {
|
||||
name: 'Alice',
|
||||
age: 30,
|
||||
email: 'alice@example.com'
|
||||
});
|
||||
dynamicInsert<TestUser>(db, 'users', {
|
||||
name: 'Bob',
|
||||
age: 25,
|
||||
email: 'bob@example.com'
|
||||
});
|
||||
dynamicInsert<TestUser>(db, 'users', {
|
||||
name: 'Charlie',
|
||||
age: 35,
|
||||
email: 'charlie@example.com'
|
||||
});
|
||||
dynamicInsert<TestUser>(db, 'users', {
|
||||
name: 'Diana',
|
||||
age: 28,
|
||||
email: 'diana@example.com'
|
||||
});
|
||||
});
|
||||
|
||||
it('should select all records with default options', () => {
|
||||
const results = select<TestUser>(db, 'users');
|
||||
expect(results.length).toBe(4);
|
||||
expect(results[0].name).toBe('Alice');
|
||||
expect(results[1].name).toBe('Bob');
|
||||
});
|
||||
|
||||
it('should select specific columns', () => {
|
||||
const results = select<TestUser>(db, 'users', {}, 'name, age') as Array<{ name: string; age: number }>;
|
||||
expect(results.length).toBe(4);
|
||||
expect(results[0].name).toBe('Alice');
|
||||
expect(results[0].age).toBe(30);
|
||||
// @ts-expect-error - email should not be present
|
||||
expect(results[0].email).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should filter with WHERE conditions (AND logic)', () => {
|
||||
const results = select<TestUser>(db, 'users', {}, '*', { age: 30 });
|
||||
expect(results.length).toBe(1);
|
||||
expect(results[0].name).toBe('Alice');
|
||||
});
|
||||
|
||||
it('should filter with OR conditions', () => {
|
||||
const results = select<TestUser>(db, 'users', {}, '*', { or: { name: 'Alice' } });
|
||||
expect(results.length).toBe(1);
|
||||
expect(results[0].name).toBe('Alice');
|
||||
});
|
||||
|
||||
it('should combine AND and OR conditions', () => {
|
||||
const results = select<TestUser>(db, 'users', undefined, '*', {
|
||||
age: 30,
|
||||
or: { name: 'Alice' }
|
||||
});
|
||||
// This should match Alice (age = 30) only
|
||||
expect(results.length).toBe(1);
|
||||
expect(results[0].name).toBe('Alice');
|
||||
});
|
||||
|
||||
it('should paginate results with skip and limit', () => {
|
||||
const options: QueryOptions<TestUser> = { skip: 1, limit: 2 };
|
||||
const results = select<TestUser>(db, 'users', options);
|
||||
|
||||
expect(results.length).toBe(2);
|
||||
expect(results[0].name).toBe('Bob');
|
||||
expect(results[1].name).toBe('Charlie');
|
||||
});
|
||||
|
||||
it('should order results by column ASC', () => {
|
||||
const options: QueryOptions<TestUser> = { orderBy: { name: 'ASC' } };
|
||||
const results = select<TestUser>(db, 'users', options);
|
||||
|
||||
expect(results.length).toBe(4);
|
||||
expect(results[0].name).toBe('Alice');
|
||||
expect(results[1].name).toBe('Bob');
|
||||
});
|
||||
|
||||
it('should order results by column DESC', () => {
|
||||
const options: QueryOptions<TestUser> = { orderBy: { name: 'DESC' } };
|
||||
const results = select<TestUser>(db, 'users', options);
|
||||
|
||||
expect(results.length).toBe(4);
|
||||
expect(results[0].name).toBe('Diana');
|
||||
expect(results[1].name).toBe('Charlie');
|
||||
});
|
||||
|
||||
it('should return empty array when no records match', () => {
|
||||
const results = select<TestUser>(db, 'users', {}, '*', { age: 99 });
|
||||
expect(results.length).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle complex query with all options', () => {
|
||||
const options: QueryOptions<TestUser> = {
|
||||
skip: 0,
|
||||
limit: 2,
|
||||
orderBy: { age: 'ASC' }
|
||||
};
|
||||
const results = select<TestUser>(db, 'users', options, '*', { isActive: true });
|
||||
|
||||
expect(results.length).toBe(2);
|
||||
expect(results[0].age).toBeLessThanOrEqual(results[1].age);
|
||||
});
|
||||
});
|
||||
|
||||
describe('remove', () => {
|
||||
let db: Database;
|
||||
let aliceId: number;
|
||||
|
||||
beforeEach(() => {
|
||||
db = new Database(':memory:');
|
||||
db.exec(`
|
||||
CREATE TABLE users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
age INTEGER,
|
||||
email TEXT,
|
||||
isActive INTEGER DEFAULT 1
|
||||
)
|
||||
`);
|
||||
|
||||
// Insert test data
|
||||
aliceId = Number(dynamicInsert<TestUser>(db, 'users', {
|
||||
name: 'Alice',
|
||||
age: 30,
|
||||
email: 'alice@example.com'
|
||||
}));
|
||||
});
|
||||
|
||||
it('should delete records with WHERE conditions', () => {
|
||||
const changes = remove<TestUser>(db, 'users', { id: aliceId });
|
||||
expect(changes).toBe(1);
|
||||
|
||||
const results = select<TestUser>(db, 'users');
|
||||
expect(results.length).toBe(0);
|
||||
});
|
||||
|
||||
it('should delete with AND logic', () => {
|
||||
// Add another record to test AND logic
|
||||
dynamicInsert<TestUser>(db, 'users', { name: 'Dave', age: 30, email: 'dave@example.com' });
|
||||
|
||||
const changes = remove<TestUser>(db, 'users', { age: 30 });
|
||||
expect(changes).toBe(2); // Alice and Dave
|
||||
|
||||
const results = select<TestUser>(db, 'users');
|
||||
expect(results.length).toBe(0);
|
||||
});
|
||||
|
||||
it('should delete with OR logic', () => {
|
||||
// Insert a fresh Alice record for this test (different email to distinguish)
|
||||
dynamicInsert<TestUser>(db, 'users', { name: 'Alice', age: 30, email: 'alice2@example.com' });
|
||||
const changes = remove<TestUser>(db, 'users', { or: { name: 'Alice' } });
|
||||
expect(changes).toBe(2); // Both Alice records should be deleted
|
||||
|
||||
// Should have no records left
|
||||
const results = select<TestUser>(db, 'users');
|
||||
expect(results.length).toBe(0);
|
||||
});
|
||||
|
||||
it('should return correct number of deleted rows', () => {
|
||||
// Re-insert Alice for this test
|
||||
const newAliceId = dynamicInsert<TestUser>(db, 'users', { name: 'Alice', age: 30, email: 'alice@example.com' });
|
||||
// Delete existing record
|
||||
const changes1 = remove<TestUser>(db, 'users', { id: newAliceId });
|
||||
expect(changes1).toBe(1);
|
||||
|
||||
// Delete non-existent record
|
||||
const changes2 = remove<TestUser>(db, 'users', { id: 999 });
|
||||
expect(changes2).toBe(0);
|
||||
});
|
||||
|
||||
it('should delete all records when no WHERE clause', () => {
|
||||
dynamicInsert<TestUser>(db, 'users', { name: 'Bob', age: 25, email: 'bob@example.com' });
|
||||
const changes = remove<TestUser>(db, 'users');
|
||||
expect(changes).toBe(2);
|
||||
|
||||
const results = select<TestUser>(db, 'users');
|
||||
expect(results.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge Cases and SQL Injection Prevention', () => {
|
||||
let db: Database;
|
||||
|
||||
beforeEach(() => {
|
||||
db = new Database(':memory:');
|
||||
db.exec(`
|
||||
CREATE TABLE users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
age INTEGER,
|
||||
email TEXT
|
||||
)
|
||||
`);
|
||||
|
||||
dynamicInsert<TestUser>(db, 'users', {
|
||||
name: 'Alice',
|
||||
age: 30
|
||||
});
|
||||
});
|
||||
|
||||
it('should prevent SQL injection via parameterized queries', () => {
|
||||
// Test with potential SQL injection patterns
|
||||
const maliciousInput = "Robert'); DROP TABLE users;--";
|
||||
const userId = dynamicInsert<TestUser>(db, 'users', {
|
||||
name: maliciousInput,
|
||||
age: 25
|
||||
});
|
||||
|
||||
expect(userId).toBeGreaterThan(0);
|
||||
|
||||
// Verify the table still exists and data is intact
|
||||
const results = select<TestUser>(db, 'users');
|
||||
expect(results.length).toBe(2);
|
||||
expect(results.some(u => u.name === maliciousInput)).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle special characters in data', () => {
|
||||
const specialChars = "O'Brien & Co.";
|
||||
const userId = dynamicInsert<TestUser>(db, 'users', {
|
||||
name: specialChars,
|
||||
age: 40
|
||||
});
|
||||
|
||||
expect(userId).toBeGreaterThan(0);
|
||||
|
||||
const result = select<TestUser>(db, 'users', {}, '*', { id: userId });
|
||||
expect(result.length).toBe(1);
|
||||
expect(result[0].name).toBe(specialChars);
|
||||
});
|
||||
|
||||
it('should handle null and undefined values gracefully', () => {
|
||||
// This should work without throwing errors
|
||||
const userId = dynamicInsert<TestUser>(db, 'users', {
|
||||
name: 'Test',
|
||||
age: 0
|
||||
});
|
||||
|
||||
expect(userId).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
+58
-38
@@ -28,41 +28,52 @@ export function select<T extends {}>(db: Database, table: string, options?: Quer
|
||||
let query = `SELECT ${select} FROM ${table}\n`;
|
||||
const values = [];
|
||||
if (where) {
|
||||
query += `WHERE `;
|
||||
|
||||
const orConditions: string[] = [];
|
||||
if (where.or) {
|
||||
query += `${Object.keys(where.or)
|
||||
.map((k) => {
|
||||
values.push(where.or[k]);
|
||||
return `${k} = ?\n`;
|
||||
})
|
||||
.join('OR ')}`;
|
||||
where.or && Object.keys(where.or).forEach((k) => {
|
||||
values.push(where.or[k]);
|
||||
orConditions.push(`${k} = ?`);
|
||||
});
|
||||
delete where.or;
|
||||
}
|
||||
|
||||
if (Object.keys(where).length > 0) {
|
||||
query += `${Object.keys(where)
|
||||
.map((k) => {
|
||||
values.push(where[k]);
|
||||
return `${k} = ?\n`;
|
||||
})
|
||||
.join('AND ')}`;
|
||||
const andConditions: string[] = [];
|
||||
Object.keys(where).forEach((k) => {
|
||||
values.push(where[k]);
|
||||
andConditions.push(`${k} = ?`);
|
||||
});
|
||||
|
||||
if (orConditions.length > 0 || andConditions.length > 0) {
|
||||
query += 'WHERE ';
|
||||
const conditions: string[] = [];
|
||||
|
||||
if (orConditions.length > 0) {
|
||||
conditions.push(`(${orConditions.join(' OR ')})`);
|
||||
}
|
||||
|
||||
if (andConditions.length > 0) {
|
||||
conditions.push(andConditions.join(' AND '));
|
||||
}
|
||||
|
||||
query += conditions.join(' AND ');
|
||||
}
|
||||
}
|
||||
|
||||
if (options?.orderBy) {
|
||||
query += `ORDER BY\n${Object.keys(options.orderBy)
|
||||
query += `ORDER BY ${Object.keys(options.orderBy)
|
||||
.map((k) => {
|
||||
values.push(options.orderBy[k]);
|
||||
return `${k} ?`;
|
||||
return `${k} ${options.orderBy[k]}`;
|
||||
})
|
||||
.join(',\n')}\n`;
|
||||
.join(', ')}\n`;
|
||||
}
|
||||
|
||||
if (options?.skip) {
|
||||
values.push(options.limit || -1);
|
||||
values.push(options.skip);
|
||||
query += `LIMIT ? OFFSET ?`;
|
||||
if (options?.limit !== undefined) {
|
||||
values.push(options.limit);
|
||||
query += `LIMIT ?`;
|
||||
if (options?.skip) {
|
||||
values.push(options.skip);
|
||||
query += ` OFFSET ?`;
|
||||
}
|
||||
}
|
||||
|
||||
return db.prepare(query).all(...values) as T[];
|
||||
@@ -73,25 +84,34 @@ export function remove<T>(db: Database, table: string, where?: Where<T>) {
|
||||
|
||||
const values = [];
|
||||
if (where) {
|
||||
query += `WHERE `;
|
||||
|
||||
const orConditions: string[] = [];
|
||||
if (where.or) {
|
||||
query += `${Object.keys(where.or)
|
||||
.map((k) => {
|
||||
values.push(where.or[k]);
|
||||
return `${k} = ?\n`;
|
||||
})
|
||||
.join('OR ')}`;
|
||||
where.or && Object.keys(where.or).forEach((k) => {
|
||||
values.push(where.or[k]);
|
||||
orConditions.push(`${k} = ?`);
|
||||
});
|
||||
delete where.or;
|
||||
}
|
||||
|
||||
if (Object.keys(where).length > 0) {
|
||||
query += `${Object.keys(where)
|
||||
.map((k) => {
|
||||
values.push(where[k]);
|
||||
return `${k} = ?\n`;
|
||||
})
|
||||
.join('AND ')}`;
|
||||
const andConditions: string[] = [];
|
||||
Object.keys(where).forEach((k) => {
|
||||
values.push(where[k]);
|
||||
andConditions.push(`${k} = ?`);
|
||||
});
|
||||
|
||||
if (orConditions.length > 0 || andConditions.length > 0) {
|
||||
query += ' WHERE ';
|
||||
const conditions: string[] = [];
|
||||
|
||||
if (orConditions.length > 0) {
|
||||
conditions.push(`(${orConditions.join(' OR ')})`);
|
||||
}
|
||||
|
||||
if (andConditions.length > 0) {
|
||||
conditions.push(andConditions.join(' AND '));
|
||||
}
|
||||
|
||||
query += conditions.join(' AND ');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"strict": false,
|
||||
"noImplicitAny": false,
|
||||
"skipLibCheck": true,
|
||||
"typeRoots": ["src/types", "node_modules/@types"],
|
||||
"emitDeclarationOnly": true,
|
||||
"noEmit": false,
|
||||
"noEmitOnError": false,
|
||||
@@ -13,5 +14,5 @@
|
||||
"allowImportingTsExtensions": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules", "dist", "build", "**/*.test.ts"]
|
||||
"exclude": ["node_modules", "dist", "build"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user