Skip to content

Commit

Permalink
fix: ensure multiple integration test suites can run πŸƒβ€β™€οΈ (#85)
Browse files Browse the repository at this point in the history
  • Loading branch information
ramiAbdou authored Apr 3, 2024
1 parent 777e88c commit d917df6
Show file tree
Hide file tree
Showing 17 changed files with 157 additions and 85 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ node_modules
# Environment Variables

.env
.env.test

# Turborepo

Expand Down
1 change: 1 addition & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ Set up your environment variable files by doing the following:
- In `/apps/api`, duplicate the `.env.example` to `.env`.
- In `/apps/member-profile`, duplicate the `.env.example` to `.env`.
- In `/packages/core`, duplicate the `.env.example` to `.env`.
- In `/packages/core`, duplicate the `.env.test.example` to `.env.test`.

You'll notice that a lot of environment variables are empty. Most of these empty
variables are tied to the 3rd party integrations we have with platforms such as
Expand Down
2 changes: 2 additions & 0 deletions packages/core/.env.test.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
DATABASE_URL=postgresql://colorstack:colorstack@localhost:5432/colorstack_test
ENVIRONMENT=test
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,4 @@ program.parse();
// Read the value of the "--down" flag.
const { down } = program.opts();

migrate(!!down);
migrate({ down: !!down });
6 changes: 3 additions & 3 deletions packages/core/src/infrastructure/database/scripts/seed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@ import { sql } from 'kysely';
import readline from 'readline';
import { z } from 'zod';

import { ENV } from '@/shared/env';
import { ENVIRONMENT } from '@/shared/env';
import { createDatabaseConnection } from '../shared/create-database-connection';
import { migrate } from '../shared/migrate';

if (ENV.ENVIRONMENT !== 'development') {
if (ENVIRONMENT !== 'development') {
throw new Error('Cannot seed database in non-development environment.');
}

Expand All @@ -22,7 +22,7 @@ async function main() {
await clean();
console.log('(2/4) Dropped and recreated the public schema. βœ…');

await migrate();
await migrate({ db });
console.log('(3/4) Ran migrations and initialized tables. βœ…');

await seed();
Expand Down
11 changes: 8 additions & 3 deletions packages/core/src/infrastructure/database/scripts/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@ import { exec } from 'child_process';
import path from 'path';
import { fileURLToPath } from 'url';

import { ENV } from '@/shared/env';
import { ENVIRONMENT } from '@/shared/env';

if (ENV.ENVIRONMENT !== 'development') {
if (ENVIRONMENT !== 'development') {
throw new Error('Cannot setup database in non-development environment.');
}

Expand All @@ -14,7 +14,12 @@ const __dirname = path.dirname(__filename);
// This is the full path to the `setup.sql` file.
const pathToInitFile = path.join(__dirname, 'setup.sql');

exec(`psql -U postgres -h localhost -d postgres -f ${pathToInitFile}`,
exec(
// By default, everyone will have the role "postgres" and the database
// "postgres", so we use that to do our initial connection to the Postgres
// shell. We also have to specify the host, which satisfies the "peer"
// authentication requirement (if that is set in pg_hba.conf).
`psql -U postgres -h localhost -d postgres -f ${pathToInitFile}`,
(error, stdout, stderr) => {
if (stdout) {
console.log(stdout);
Expand Down
32 changes: 26 additions & 6 deletions packages/core/src/infrastructure/database/shared/migrate.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,21 @@
import { promises as fs } from 'fs';
import { FileMigrationProvider, Migrator } from 'kysely';
import { FileMigrationProvider, Kysely, Migrator } from 'kysely';
import { DB } from 'kysely-codegen/dist/db';
import path from 'path';
import { fileURLToPath } from 'url';

import { createDatabaseConnection } from './create-database-connection';

type MigrateOptions = {
db?: Kysely<DB>;
down?: boolean;
};

const defaultOptions: MigrateOptions = {
db: undefined,
down: false,
};

/**
* Migrates the database to the latest version by executing all migrations.
*
Expand All @@ -14,12 +25,17 @@ import { createDatabaseConnection } from './create-database-connection';
* we want to ensure that the database is migrated to the latest version before
* we seed it.
*/
export async function migrate(down: boolean = false) {
export async function migrate(options: MigrateOptions = defaultOptions) {
options = {
...defaultOptions,
...options,
};

const db = options.db || createDatabaseConnection();

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);

const db = createDatabaseConnection();

const migrator = new Migrator({
db,
provider: new FileMigrationProvider({
Expand All @@ -31,7 +47,7 @@ export async function migrate(down: boolean = false) {
migrationLockTableName: 'kysely_migrations_lock',
});

const { error, results } = !!down
const { error, results } = !!options.down
? await migrator.migrateDown()
: await migrator.migrateToLatest();

Expand All @@ -56,5 +72,9 @@ export async function migrate(down: boolean = false) {
process.exit(1);
}

await db.destroy();
// If a database instance was passed in, we'll yield the responsibility of
// destroying it to the caller. Otherwise, we'll destroy it here.
if (!options.db) {
await db.destroy();
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Insertable, Transaction } from 'kysely';
import { Insertable } from 'kysely';
import { DB } from 'kysely-codegen/dist/db';

// Constants
Expand Down Expand Up @@ -30,12 +30,3 @@ export const TEST_COMPANY_4: Insertable<DB['companies']> = {
name: 'Stripe',
stockSymbol: '...',
};

// Helpers

export async function seedTestDatabase(trx: Transaction<DB>) {
await trx
.insertInto('companies')
.values([TEST_COMPANY_1, TEST_COMPANY_2, TEST_COMPANY_3])
.execute();
}
10 changes: 10 additions & 0 deletions packages/core/src/infrastructure/database/test/setup.global.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { db } from '@/infrastructure/database';
import { migrate } from '../shared/migrate';

export async function setup() {
await migrate({ db });
}

export async function teardown() {
await db.destroy();
}
45 changes: 45 additions & 0 deletions packages/core/src/infrastructure/database/test/setup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { Transaction, sql } from 'kysely';

import { db } from '@/infrastructure/database';

import { DB } from 'kysely-codegen/dist/db';
import { TEST_COMPANY_1, TEST_COMPANY_2, TEST_COMPANY_3 } from './constants';

beforeEach(async () => {
await db.transaction().execute(async (trx) => {
await truncate(trx);
await seed(trx);
});
});

// Helpers

/**
* Truncates all tables in the database - wiping all rows, but does not affect
* the schema itself.
*
* @see https://www.postgresql.org/docs/current/sql-truncate.html
*/
async function truncate(trx: Transaction<DB>) {
const tables = await db.introspection.getTables();

const names = tables
.filter((table) => {
// We don't want to wipe the kysely tables, which track migrations b/c
// migrations should only be run once.
return !table.name.includes('kysely_');
})
.map((table) => {
return table.name;
})
.join(', ');

await sql`truncate table ${sql.raw(names)} cascade;`.execute(trx);
}

async function seed(trx: Transaction<DB>) {
await trx
.insertInto('companies')
.values([TEST_COMPANY_1, TEST_COMPANY_2, TEST_COMPANY_3])
.execute();
}

This file was deleted.

Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { db } from '@/infrastructure/database';
import { TEST_COMPANY_1, TEST_COMPANY_4 } from '@/shared/utils/test.utils';
import {
TEST_COMPANY_1,
TEST_COMPANY_4,
} from '@/infrastructure/database/test/constants';
import * as module from '../queries/get-crunchbase-organization';
import { saveCompanyIfNecessary } from './save-company-if-necessary';

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import Statsig from 'statsig-node';

import { ENV } from '@/shared/env';
import { ENVIRONMENT } from '@/shared/env';
import { getStatsigKey } from '../feature-flag.shared';

/**
Expand All @@ -20,7 +20,7 @@ export async function initializeFeatureFlagServer() {

await Statsig.initialize(key, {
environment: {
tier: ENV.ENVIRONMENT,
tier: ENVIRONMENT,
},
});
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import {
StudentRemovedEmail,
} from '@oyster/email-templates';

import { ENV } from '@/shared/env';
import { ENVIRONMENT } from '@/shared/env';
import {
getNodemailerTransporter,
getPostmarkInstance,
Expand Down Expand Up @@ -43,7 +43,7 @@ const FROM_NOTIFICATIONS = 'ColorStack <[email protected]>';
* own email account and send emails from there.
*/
export async function sendEmail(input: EmailTemplate) {
return match(ENV.ENVIRONMENT)
return match(ENVIRONMENT)
.with('development', () => {
return sendEmailWithNodemailer(input);
})
Expand Down Expand Up @@ -157,7 +157,7 @@ function getSubject(input: EmailTemplate): string {
})
.exhaustive();

const subjectWithEnvironment = match(ENV.ENVIRONMENT)
const subjectWithEnvironment = match(ENVIRONMENT)
.with('development', () => `[Development] ${subject}`)
.with('production', 'test', () => subject)
.exhaustive();
Expand Down
13 changes: 9 additions & 4 deletions packages/core/src/shared/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,14 @@ import { config } from 'dotenv';

import { Environment } from './types';

// Loads the .env file into `process.env`.
// Loads the .env file into `process.env`. Note that if the config was already
// loaded (for example, in tests), this will not overwrite any existing values.
config();

export const ENV = {
AIRMEET_ACCESS_KEY: process.env.AIRMEET_ACCESS_KEY as string,
AIRMEET_SECRET_KEY: process.env.AIRMEET_SECRET_KEY as string,
API_URL: process.env.API_URL as string,
ENVIRONMENT: process.env.ENVIRONMENT as Environment,
GOOGLE_CLIENT_ID: process.env.GOOGLE_CLIENT_ID as string,
GOOGLE_CLIENT_SECRET: process.env.GOOGLE_CLIENT_SECRET as string,
INTERNAL_SLACK_BOT_TOKEN: process.env.INTERNAL_SLACK_BOT_TOKEN as string,
Expand All @@ -36,6 +36,11 @@ export const ENV = {
SWAG_UP_CLIENT_SECRET: process.env.SWAG_UP_CLIENT_SECRET as string,
};

// TODO: Below are the only variables that we need to process in the core,
// package and thus in this file after the dotenv has loaded the config.
// Everything else above should be colocated with its respective module.

export const DATABASE_URL = process.env.DATABASE_URL as string;
export const IS_PRODUCTION = ENV.ENVIRONMENT === 'production';
export const IS_TEST = ENV.ENVIRONMENT === 'test';
export const ENVIRONMENT = process.env.ENVIRONMENT as Environment;
export const IS_PRODUCTION = ENVIRONMENT === 'production';
export const IS_TEST = ENVIRONMENT === 'test';
49 changes: 35 additions & 14 deletions packages/core/vitest.config.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,46 @@
import { config } from 'dotenv';
import tsconfigPaths from 'vite-tsconfig-paths';
import { defineConfig } from 'vitest/config';

// When we run `vitest`, this is the first file that's loaded and thus `dotenv`
// hasn't processed any environment variables, so `process.env.DATABASE_URL` is
// empty. When we run this locally, that's expected, so we'll use the
// "colorstack_test" database. However, in our CI pipeline, we already inject
// the `DATABASE_URL` environment variable, so we'll use that instead of having
// to create a separate database (and thus connection) for testing.
const DATABASE_URL =
process.env.DATABASE_URL || 'postgresql://localhost:5432/colorstack_test';
const env = config({ path: './.env.test' }).parsed;

// If there is no ".env.test" file found, then we'll throw error, except in the
// CI environment because in CI we manually set the environment variables
// instead of using .env files.
if (!env && !process.env.CI) {
throw new Error(
'Please create an ".env.test" file and copy the contents from ".env.test.example" over to it.'
);
}

export default defineConfig({
plugins: [tsconfigPaths()],
test: {
clearMocks: true,
env: {
DATABASE_URL,
ENVIRONMENT: 'test',
},
environment: './src/infrastructure/database/vitest-environment-kysely.ts',
env,
globals: true,
poolOptions: {
threads: {
// This is important because we are using a database for integration
// tests, and we reset the database (data) before each test. If
// ran in parallel, the tests would interfere with each other.
// See: https://vitest.dev/config/#pooloptions-threads-singlethread
singleThread: true,
},
},

// Setup

// The global setup file is only run once before ALL test suites.
// See: https://vitest.dev/config/#globalsetup
globalSetup: ['./src/infrastructure/database/test/setup.global.ts'],

// The setup files are run before EACH test suite.
// See: https://vitest.dev/config/#setupfiles
setupFiles: ['./src/infrastructure/database/test/setup.ts'],

// Mocking

clearMocks: true,
mockReset: true,
restoreMocks: true,
},
Expand Down
Loading

0 comments on commit d917df6

Please sign in to comment.