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
5 changes: 3 additions & 2 deletions apps/website/src/server/routers/exercises.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
eq,
exerciseResponseTable,
exerciseTable,
getFirstFromPg,
groupTable,
inArray,
isNotNull,
Expand All @@ -32,7 +33,7 @@ export const exercisesRouter = router({
getExerciseResponse: protectedProcedure
.input(z.object({ exerciseId: z.string().min(1) }))
.query(async ({ input, ctx }) => {
const exerciseResponse = await db.getFirst(exerciseResponseTable, {
const exerciseResponse = await getFirstFromPg(db.pg, exerciseResponseTable.pg, {
filter: { exerciseId: input.exerciseId, email: ctx.auth.email },
});

Expand All @@ -54,7 +55,7 @@ export const exercisesRouter = router({
} // else undefined = "don't change"

const [existingResponse, exercise, user] = await Promise.all([
db.getFirst(exerciseResponseTable, {
getFirstFromPg(db.pg, exerciseResponseTable.pg, {
filter: { exerciseId: input.exerciseId, email: ctx.auth.email },
}),
input.completed === true
Expand Down
4 changes: 2 additions & 2 deletions apps/website/src/server/routers/resources.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import {
and, courseBuilderUserTable, desc, eq, inArray, resourceCompletionTable, unitResourceTable,
and, courseBuilderUserTable, desc, eq, getFirstFromPg, inArray, resourceCompletionTable, unitResourceTable,
} from '@bluedot/db';
import { RESOURCE_FEEDBACK } from '@bluedot/db/src/schema';
import { z } from 'zod';
Expand Down Expand Up @@ -55,7 +55,7 @@ export const resourcesRouter = router({
}))
.mutation(async ({ input, ctx }) => {
const [resourceCompletion, unitResource, cbUser] = await Promise.all([
db.getFirst(resourceCompletionTable, {
getFirstFromPg(db.pg, resourceCompletionTable.pg, {
filter: {
unitResourceId: input.unitResourceId,
email: ctx.auth.email,
Expand Down
5 changes: 5 additions & 0 deletions libraries/db/.env.test
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
APP_NAME=db-tests
PG_URL=postgresql://fake:fake@localhost:5432/fake
AIRTABLE_PERSONAL_ACCESS_TOKEN=FAKE_TOKEN
ALERTS_SLACK_CHANNEL_ID=FAKE_CHANNEL
ALERTS_SLACK_BOT_TOKEN=FAKE_TOKEN
2 changes: 2 additions & 0 deletions libraries/db/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
export { PgAirtableDb } from './lib/client';
export type { PgDatabase } from './lib/client';
export { getFirstFromPg } from './lib/pg-query';
export type { GetFirstFromPgOptions } from './lib/pg-query';
export {
createTestPgClient, createTestAirtableClient, createTestDbClients, pushTestSchema, resetTestDb,
} from './lib/test-db';
Expand Down
214 changes: 207 additions & 7 deletions libraries/db/src/lib/client.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,212 @@
import {
describe,
expect,
test,
beforeAll, beforeEach, describe, expect, test,
} from 'vitest';
import {
PgAirtableDb,
createTestDbClients,
exerciseResponseTable,
getFirstFromPg,
personTable,
pushTestSchema,
resetTestDb,
Comment thread
Will-Howard marked this conversation as resolved.
type TestPgAirtableDb,
userTable,
} from '../index';

let db: PgAirtableDb;
let testDb: TestPgAirtableDb;

beforeAll(async () => {
const { pgClient, airtableClient } = createTestDbClients();
db = new PgAirtableDb({
pgConnString: 'unused',
airtableApiKey: 'unused',
pgClient,
airtableClient,
});
testDb = db as unknown as TestPgAirtableDb;
await pushTestSchema(db);
});

beforeEach(async () => resetTestDb(db));

describe('db.getFirst', () => {
describe('with autoNumberId table', () => {
test('returns null when no record matches', async () => {
const result = await db.getFirst(userTable, { filter: { email: 'nonexistent@example.com' } });
expect(result).toBeNull();
});

test('returns the matching record', async () => {
await testDb.insert(userTable, { id: 'u1', email: 'a@x', name: 'Alice' });
const result = await db.getFirst(userTable, { filter: { email: 'a@x' } });
expect(result?.email).toBe('a@x');
expect(result?.name).toBe('Alice');
});

test('defaults to autoNumberId DESC (newest first) with no sortBy', async () => {
await testDb.insert(userTable, {
id: 'u1', email: 'shared@x', name: 'First', autoNumberId: 1,
});
await testDb.insert(userTable, {
id: 'u2', email: 'shared@x', name: 'Second', autoNumberId: 2,
});
await testDb.insert(userTable, {
id: 'u3', email: 'shared@x', name: 'Third', autoNumberId: 3,
});

const result = await db.getFirst(userTable, { filter: { email: 'shared@x' } });
expect(result?.name).toBe('Third');
});

test('explicit sortBy as string defaults to ASC', async () => {
await testDb.insert(userTable, { id: 'u1', email: 'b@x', name: 'B' });
await testDb.insert(userTable, { id: 'u2', email: 'a@x', name: 'A' });
await testDb.insert(userTable, { id: 'u3', email: 'c@x', name: 'C' });

const result = await db.getFirst(userTable, { sortBy: 'email' });
expect(result?.email).toBe('a@x');
});

test('explicit sortBy: autoNumberId as string defaults to DESC (special case)', async () => {
await testDb.insert(userTable, {
id: 'u1', email: 'a@x', name: 'A', autoNumberId: 1,
});
await testDb.insert(userTable, {
id: 'u2', email: 'a@x', name: 'B', autoNumberId: 2,
});

const result = await db.getFirst(userTable, { sortBy: 'autoNumberId' });
expect(result?.autoNumberId).toBe(2);
});

test('explicit sortBy as object respects direction', async () => {
await testDb.insert(userTable, { id: 'u1', email: 'b@x', name: 'B' });
await testDb.insert(userTable, { id: 'u2', email: 'a@x', name: 'A' });

const result = await db.getFirst(userTable, { sortBy: { field: 'email', direction: 'desc' } });
expect(result?.email).toBe('b@x');
});
});

describe('with non-autoNumberId table', () => {
test('throws when sortBy is not provided', async () => {
// The type system enforces sortBy at compile time; runtime check is the safety net.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const call = db.getFirst(personTable, { filter: { email: 'a@x' } } as any);
await expect(call).rejects.toThrow(/autoNumberId for default sorting/);
});

test('works with explicit sortBy', async () => {
await testDb.insert(personTable, { id: 'p1', email: 'b@x' });
await testDb.insert(personTable, { id: 'p2', email: 'a@x' });

const result = await db.getFirst(personTable, { sortBy: 'email' });
expect(result?.email).toBe('a@x');
});
});

describe('filter handling', () => {
test('AND filter combines conditions', async () => {
await testDb.insert(userTable, { id: 'u1', email: 'a@x', name: 'Alice' });
await testDb.insert(userTable, { id: 'u2', email: 'a@x', name: 'Bob' });

const result = await db.getFirst(userTable, {
filter: { AND: [{ email: 'a@x' }, { name: 'Bob' }] },
});
expect(result?.name).toBe('Bob');
});

test('OR filter matches either branch', async () => {
await testDb.insert(userTable, { id: 'u1', email: 'a@x', name: 'Alice' });

const result = await db.getFirst(userTable, {
filter: { OR: [{ email: 'a@x' }, { email: 'nonexistent@x' }] },
});
expect(result?.email).toBe('a@x');
});

test('comparison operator >', async () => {
await testDb.insert(userTable, {
id: 'u1', email: 'a@x', name: 'A', autoNumberId: 1,
});
await testDb.insert(userTable, {
id: 'u2', email: 'a@x', name: 'B', autoNumberId: 5,
});
await testDb.insert(userTable, {
id: 'u3', email: 'a@x', name: 'C', autoNumberId: 10,
});

// Default sort is autoNumberId DESC, so we get the highest match.
const result = await db.getFirst(userTable, { filter: { autoNumberId: { '>': 3 } } });
expect(result?.autoNumberId).toBe(10);
});

test('comparison operator !=', async () => {
await testDb.insert(userTable, {
id: 'u1', email: 'a@x', name: 'A', autoNumberId: 1,
});
await testDb.insert(userTable, {
id: 'u2', email: 'b@x', name: 'B', autoNumberId: 2,
});

const result = await db.getFirst(userTable, { filter: { email: { '!=': 'a@x' } } });
expect(result?.email).toBe('b@x');
});

test('empty AND defensively returns no rows (matches no records)', async () => {
await testDb.insert(userTable, { id: 'u1', email: 'a@x', name: 'A' });
const result = await db.getFirst(userTable, { filter: { AND: [] } });
expect(result).toBeNull();
});

test('empty OR returns no rows', async () => {
await testDb.insert(userTable, { id: 'u1', email: 'a@x', name: 'A' });
const result = await db.getFirst(userTable, { filter: { OR: [] } });
expect(result).toBeNull();
});

test('throws on unknown field in filter', async () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const call = db.getFirst(userTable, { filter: { nonExistent: 'x' } as any });
await expect(call).rejects.toThrow(/Unknown field/);
});
});

describe('no-options invocation', () => {
test('with autoNumberId table, no options at all returns newest row', async () => {
await testDb.insert(exerciseResponseTable, {
id: 'r1', email: 'a@x', exerciseId: 'e1', response: 'first', autoNumberId: 1,
});
await testDb.insert(exerciseResponseTable, {
id: 'r2', email: 'a@x', exerciseId: 'e2', response: 'second', autoNumberId: 2,
});

const result = await db.getFirst(exerciseResponseTable);
expect(result?.id).toBe('r2');
});
});
});

describe('getFirstFromPg (direct)', () => {
test('returns the matching record with a raw pgClient and PgTable', async () => {
await testDb.insert(userTable, { id: 'u1', email: 'direct@x', name: 'Direct' });

const result = await getFirstFromPg(db.pg, userTable.pg, {
filter: { email: 'direct@x' },
});
expect(result?.id).toBe('u1');
expect(result?.name).toBe('Direct');
});

test('returns null when no record matches', async () => {
const result = await getFirstFromPg(db.pg, userTable.pg, {
filter: { email: 'nobody@x' },
});
expect(result).toBeNull();
});

// TODO
describe('dummy test', () => {
test('should pass', () => {
expect(true).toBe(true);
test('throws when table has no autoNumberId and no sortBy is provided', async () => {
await expect(getFirstFromPg(db.pg, personTable.pg)).rejects.toThrow(/autoNumberId for default sorting/);
});
});
Loading
Loading