Skip to content

Commit

Permalink
feat: basic assignment scheduler database functions unit tests (#100)
Browse files Browse the repository at this point in the history
* feat: add inferred `insert` and `select` types for all tables

* feat: basic testing setup for database functions

* fix: don't hardcode testingDb in db function

* fix: no seperate "db implementation"

* fix: move mock data

* wip

* fix: cast time string to timestamp

* feat: check for actual task, not just length of array

* fix: run tests in ci

* fix: use .env in CI

* fix: env var db test

* ci: test

---------

Co-authored-by: julian-wasmeier-titanom <[email protected]>
  • Loading branch information
invertedEcho and julian-wasmeier-titanom authored Aug 3, 2024
1 parent 9264510 commit 810780b
Show file tree
Hide file tree
Showing 13 changed files with 291 additions and 31 deletions.
6 changes: 6 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -67,3 +67,9 @@ jobs:
- name: Check Types
run: pnpm types
working-directory: ./backend

- name: Run tests
run: |
echo DATABASE_URL=${{ secrets.DATABASE_URL }} >> .env.test
pnpm test
working-directory: ./backend
24 changes: 23 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,11 +32,33 @@ cd backend && pnpm dev
cd frontend && flutter run
```

## tests

the tests are currently mainly focused around the database functions used in the assignment scheduler.

as you probably dont want to run the tests against your main database, the tests are setup to run via a different .env file, e.g. `.env.test`

- setup .env.test:

```bash
touch .env.test

# content
DB_PASSWORD=postgres://***
```

- run the tests:

```bash
pnpm test
# or pnpm test:watch for "hot-reloaded" tests
```

## The Stack:

- Backend:
- NestJS
- drizzle
- drizzle (ORM)

- Frontend:
- flutter
5 changes: 1 addition & 4 deletions backend/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,7 @@ lerna-debug.log*

# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
.env.*

# temp directory
.temp
Expand Down
3 changes: 0 additions & 3 deletions backend/drizzle.config.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,5 @@
import type { Config } from 'drizzle-kit';

// TODO: Replace with t3-oss/env
import 'dotenv/config';

const databaseUrl = process.env.DATABASE_URL;

if (databaseUrl === undefined) {
Expand Down
11 changes: 4 additions & 7 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,16 +13,13 @@
"format": "prettier -w \"src/**/*.ts\"",
"format:check": "prettier -c \"src/**/*.ts\"",
"start": "nest start",
"dev": "nest start --watch",
"dev": "dotenv -e .env nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "pm2 restart dist/src/main.js",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\"",
"types": "tsc --noEmit",
"test": "jest",
"test": "dotenv -e .env.test jest",
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json",
"db:generate": "drizzle-kit generate",
"db:migrate": "tsx ./src/db/migrate.ts",
"db:studio": "drizzle-kit studio"
Expand All @@ -44,15 +41,15 @@
"devDependencies": {
"@nestjs/cli": "^10.0.0",
"@nestjs/schematics": "^10.0.0",
"@nestjs/testing": "^10.0.0",
"@nestjs/testing": "^10.3.9",
"@types/bcrypt": "^5.0.2",
"@types/express": "^4.17.17",
"@types/jest": "^29.5.2",
"@types/node": "^20.3.1",
"@types/supertest": "^6.0.0",
"@typescript-eslint/eslint-plugin": "^6.0.0",
"@typescript-eslint/parser": "^6.0.0",
"dotenv": "^16.4.5",
"dotenv-cli": "^7.4.2",
"drizzle-kit": "^0.21.1",
"eslint": "^8.42.0",
"eslint-config-prettier": "^9.0.0",
Expand Down
23 changes: 19 additions & 4 deletions backend/pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion backend/src/assignment/assignment-scheduler.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export class AssignmentSchedulerService {
async handleCron() {
if (process.env.NODE_ENV !== 'production') return;
const tasksToCreateAssignmentsFor =
await dbGetTasksToAssignForCurrentInterval();
await dbGetTasksToAssignForCurrentInterval({});

// TODO: create a arrayGroupBy util function
const tasksByGroup = tasksToCreateAssignmentsFor.reduce<
Expand Down
124 changes: 124 additions & 0 deletions backend/src/db/functions/assignment.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import {
dbGetTasksToAssignForCurrentInterval,
TaskToAssign,
} from '../functions/assignment';
import {
assignmentTable,
recurringTaskGroupTable,
recurringTaskGroupUserTable,
taskTable,
taskUserGroupTable,
} from '../schema';
import { client, db } from '..';
import {
recurringTaskGroupWeekly,
userJulian,
taskVacuuming,
userGroupWG1,
} from '../tests/mock-data';
import { truncateAllTables, seedDatabase } from '../tests/util';

describe('dbGetTasksToAssignForCurrentInterval', () => {
beforeEach(async () => {
await truncateAllTables();
await seedDatabase();
});

afterAll(async () => {
await client.end();
});

async function setup() {
await db.insert(recurringTaskGroupTable).values(recurringTaskGroupWeekly);
await db.insert(recurringTaskGroupUserTable).values({
recurringTaskGroupId: recurringTaskGroupWeekly.id,
userId: userJulian.id,
});

await db.insert(taskTable).values(taskVacuuming);
await db
.insert(taskUserGroupTable)
.values({ groupId: userGroupWG1.id, taskId: taskVacuuming.id });
}

it('does return tasks where initial start date is in the past', async () => {
await setup();

const result = await dbGetTasksToAssignForCurrentInterval({
currentTime: new Date('2024-07-29 13:00:00Z'),
});

const expectedTask = {
taskId: taskVacuuming.id,
taskGroupId: recurringTaskGroupWeekly.id,
isInFirstInterval: true,
taskGroupInitialStartDate: recurringTaskGroupWeekly.initialStartDate,
} satisfies TaskToAssign;

expect(result).toHaveLength(1);
const firstTask = result[0];
expect(firstTask).toStrictEqual(expectedTask);
});

it('does not return tasks where the initalStartDate is in the future', async () => {
await setup();

const result = await dbGetTasksToAssignForCurrentInterval({
currentTime: new Date('2024-07-28 21:59:59Z'),
});

expect(result).toHaveLength(0);
});

it('returns a task where there are no assignments yet', async () => {
await setup();

const expectedTask = {
taskId: taskVacuuming.id,
taskGroupId: recurringTaskGroupWeekly.id,
isInFirstInterval: true,
taskGroupInitialStartDate: recurringTaskGroupWeekly.initialStartDate,
} satisfies TaskToAssign;

const result = await dbGetTasksToAssignForCurrentInterval({
currentTime: new Date('2024-07-31 22:00:00Z'),
});
expect(result).toHaveLength(1);
expect(result[0]).toStrictEqual(expectedTask);
});

it('returns no tasks where there is already an assignment for the current period', async () => {
await setup();
await db.insert(assignmentTable).values({
taskId: taskVacuuming.id,
userId: userJulian.id,
createdAt: new Date('2024-07-28 22:00:00Z'),
});
const result = await dbGetTasksToAssignForCurrentInterval({
currentTime: new Date('2024-08-04 21:59:00Z'),
});
expect(result).toHaveLength(0);
});

it('returns a task for which there exists an assignment in the previous period', async () => {
await setup();
const expectedTask = {
taskId: taskVacuuming.id,
taskGroupId: recurringTaskGroupWeekly.id,
isInFirstInterval: false,
taskGroupInitialStartDate: recurringTaskGroupWeekly.initialStartDate,
} satisfies TaskToAssign;

await db.insert(assignmentTable).values({
taskId: taskVacuuming.id,
userId: userJulian.id,
createdAt: new Date('2024-07-28 22:00:00Z'),
});
const result = await dbGetTasksToAssignForCurrentInterval({
currentTime: new Date('2024-08-04 22:00:00Z'),
});

expect(result).toHaveLength(1);
expect(result[0]).toStrictEqual(expectedTask);
});
});
33 changes: 25 additions & 8 deletions backend/src/db/functions/assignment.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import { count, desc, eq, isNull, or, sql, and } from 'drizzle-orm';
import { and, count, desc, eq, isNull, or, sql } from 'drizzle-orm';
import { AssignmentResponse } from 'src/types';
import { db } from '..';
import {
AssignmentState,
CreateAssignment,
InsertAssignment,
assignmentTable,
recurringTaskGroupTable,
taskTable,
userUserGroupTable,
userTable,
userUserGroupTable,
} from '../schema';

export async function dbGetAssignmentsFromCurrentInterval(
Expand Down Expand Up @@ -120,23 +120,40 @@ export async function dbGetCurrentAssignmentsForTaskGroup(taskGroupId: number) {
return currentAssignments;
}

export async function dbGetTasksToAssignForCurrentInterval() {
export type TaskToAssign = {
taskId: number;
taskGroupId: number;
taskGroupInitialStartDate: Date;
isInFirstInterval: boolean;
};

// TODO: I haven't found a better way to mock the date than just passing it in here as a JS date instead of using `NOW()` in the queries.
// Research if there is a better way.
export async function dbGetTasksToAssignForCurrentInterval({
currentTime = new Date(),
}: {
currentTime?: Date;
}): Promise<TaskToAssign[]> {
try {
const currentTimeString = currentTime.toISOString();

// Get all tasks that either have no assignments yet or don't have an assignment in the current period
const taskIdsToCreateAssignmentsFor = await db
.select({
taskId: taskTable.id,
taskGroupId: recurringTaskGroupTable.id,
taskGroupInitialStartDate: recurringTaskGroupTable.initialStartDate,
isInFirstInterval: sql<boolean>`NOW() < (${recurringTaskGroupTable.initialStartDate} + ${recurringTaskGroupTable.interval})`,
isInFirstInterval: sql<boolean>`CAST(${currentTimeString} AS timestamp) < (${recurringTaskGroupTable.initialStartDate} + ${recurringTaskGroupTable.interval})`,
})
.from(recurringTaskGroupTable)
.innerJoin(
taskTable,
eq(recurringTaskGroupTable.id, taskTable.recurringTaskGroupId),
)
.leftJoin(assignmentTable, eq(taskTable.id, assignmentTable.taskId))
.where(sql`${recurringTaskGroupTable.initialStartDate} <= NOW()`)
.where(
sql`${recurringTaskGroupTable.initialStartDate} <= CAST(${currentTimeString} AS timestamp)`,
)
.groupBy(recurringTaskGroupTable.id, taskTable.id)
.having(
or(
Expand All @@ -146,7 +163,7 @@ export async function dbGetTasksToAssignForCurrentInterval() {
this date plus one month would be 2024-07-30 22:00:00 (in UTC, which would be 2024-07-31 00:00:00 in CEST).
But actually, we want the resulting date that we compare the current time with to be 2024-08-01 00:00:00,
so we need to convert the timestamps to the local time zone first. */
sql`NOW() AT TIME ZONE 'UTC' AT TIME ZONE 'Europe/Berlin' >= MAX(${assignmentTable.createdAt} AT TIME ZONE 'UTC' AT TIME ZONE 'Europe/Berlin' + ${recurringTaskGroupTable.interval})`,
sql`CAST(${currentTimeString} AS timestamp) AT TIME ZONE 'UTC' AT TIME ZONE 'Europe/Berlin' >= MAX(${assignmentTable.createdAt} AT TIME ZONE 'UTC' AT TIME ZONE 'Europe/Berlin' + ${recurringTaskGroupTable.interval})`,
),
);

Expand All @@ -160,7 +177,7 @@ export async function dbGetTasksToAssignForCurrentInterval() {
export async function dbAddAssignments({
assignments,
}: {
assignments: CreateAssignment[];
assignments: InsertAssignment[];
}) {
await db.insert(assignmentTable).values(assignments);
}
3 changes: 1 addition & 2 deletions backend/src/db/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { drizzle } from 'drizzle-orm/postgres-js';
import 'dotenv/config';
import * as postgres from 'postgres';

// TODO: Switch to t3-oss/env
Expand All @@ -9,6 +8,6 @@ if (connectionString === undefined) {
throw new Error('DATABASE_URL is undefined');
}

const client = postgres(connectionString);
export const client = postgres(connectionString);

export const db = drizzle(client);
Loading

0 comments on commit 810780b

Please sign in to comment.