├── .eslintrc.json ├── .github └── workflows │ └── tests.yml ├── .gitignore ├── .gitlab-ci.yml ├── .prettierrc.js ├── README.md ├── docs └── images │ └── overview.png ├── jest.config.js ├── migrations └── 1609016983156_initial.js ├── package-lock.json ├── package.json ├── src ├── advertisement │ └── advertisement.repository.ts ├── index.ts ├── postgresql │ ├── postgresql.adapter.ts │ └── postgresql.config.ts └── product-intersection │ └── product-intersection.repository.ts ├── test ├── integration │ ├── advertisement │ │ └── advertisement.repository.test.ts │ ├── jest-testcontainers-config.js │ ├── jest.config.js │ ├── postgresql.environment.ts │ ├── postgresql │ │ └── postgresql.adapter.test.ts │ ├── preset.js │ └── product-intersection │ │ └── product-intersection.repository.test.ts └── unit │ ├── hello │ └── hello.test.ts │ └── index.test.ts └── tsconfig.json /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./node_modules/gts/", 3 | "ignorePatterns": ["build/"], 4 | "overrides": [ 5 | { 6 | "files": [ 7 | "**/*.test.ts" 8 | ], 9 | "env": { 10 | "jest": true 11 | }, 12 | "plugins": [ 13 | "jest" 14 | ] 15 | } 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: 'Lint and Tests' 2 | 3 | on: push 4 | 5 | jobs: 6 | test: 7 | name: Lint and Test Code Base 8 | runs-on: ubuntu-latest 9 | 10 | strategy: 11 | matrix: 12 | node-version: [14.x] 13 | 14 | steps: 15 | - uses: actions/checkout@v2 16 | - name: Use Node.js ${{ matrix.node-version }} 17 | uses: actions/setup-node@v1 18 | with: 19 | node-version: ${{ matrix.node-version }} 20 | 21 | - uses: actions/cache@v2 22 | with: 23 | path: '**/node_modules' 24 | key: ${{ runner.os }}-${{ matrix.node-version }}-modules-${{ hashFiles('**/package-lock.json') }} 25 | 26 | - run: npm ci 27 | - run: npm test 28 | env: {CI: 'true'} 29 | - run: npm run test:integration 30 | env: {CI: 'true'} 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | *.iml 3 | build/ 4 | 5 | # Created by https://www.toptal.com/developers/gitignore/api/node,vim,macos 6 | # Edit at https://www.toptal.com/developers/gitignore?templates=node,vim,macos 7 | 8 | ### macOS ### 9 | # General 10 | .DS_Store 11 | .AppleDouble 12 | .LSOverride 13 | 14 | # Icon must end with two \r 15 | Icon 16 | 17 | 18 | # Thumbnails 19 | ._* 20 | 21 | # Files that might appear in the root of a volume 22 | .DocumentRevisions-V100 23 | .fseventsd 24 | .Spotlight-V100 25 | .TemporaryItems 26 | .Trashes 27 | .VolumeIcon.icns 28 | .com.apple.timemachine.donotpresent 29 | 30 | # Directories potentially created on remote AFP share 31 | .AppleDB 32 | .AppleDesktop 33 | Network Trash Folder 34 | Temporary Items 35 | .apdisk 36 | 37 | ### Node ### 38 | # Logs 39 | logs 40 | *.log 41 | npm-debug.log* 42 | yarn-debug.log* 43 | yarn-error.log* 44 | lerna-debug.log* 45 | 46 | # Diagnostic reports (https://nodejs.org/api/report.html) 47 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 48 | 49 | # Runtime data 50 | pids 51 | *.pid 52 | *.seed 53 | *.pid.lock 54 | 55 | # Directory for instrumented libs generated by jscoverage/JSCover 56 | lib-cov 57 | 58 | # Coverage directory used by tools like istanbul 59 | coverage 60 | *.lcov 61 | 62 | # nyc test coverage 63 | .nyc_output 64 | 65 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 66 | .grunt 67 | 68 | # Bower dependency directory (https://bower.io/) 69 | bower_components 70 | 71 | # node-waf configuration 72 | .lock-wscript 73 | 74 | # Compiled binary addons (https://nodejs.org/api/addons.html) 75 | build/Release 76 | 77 | # Dependency directories 78 | node_modules/ 79 | jspm_packages/ 80 | 81 | # TypeScript v1 declaration files 82 | typings/ 83 | 84 | # TypeScript cache 85 | *.tsbuildinfo 86 | 87 | # Optional npm cache directory 88 | .npm 89 | 90 | # Optional eslint cache 91 | .eslintcache 92 | 93 | # Microbundle cache 94 | .rpt2_cache/ 95 | .rts2_cache_cjs/ 96 | .rts2_cache_es/ 97 | .rts2_cache_umd/ 98 | 99 | # Optional REPL history 100 | .node_repl_history 101 | 102 | # Output of 'npm pack' 103 | *.tgz 104 | 105 | # Yarn Integrity file 106 | .yarn-integrity 107 | 108 | # dotenv environment variables file 109 | .env 110 | .env.test 111 | .env*.local 112 | 113 | # parcel-bundler cache (https://parceljs.org/) 114 | .cache 115 | .parcel-cache 116 | 117 | # Next.js build output 118 | .next 119 | 120 | # Nuxt.js build / generate output 121 | .nuxt 122 | dist 123 | 124 | # Gatsby files 125 | .cache/ 126 | # Comment in the public line in if your project uses Gatsby and not Next.js 127 | # https://nextjs.org/blog/next-9-1#public-directory-support 128 | # public 129 | 130 | # vuepress build output 131 | .vuepress/dist 132 | 133 | # Serverless directories 134 | .serverless/ 135 | 136 | # FuseBox cache 137 | .fusebox/ 138 | 139 | # DynamoDB Local files 140 | .dynamodb/ 141 | 142 | # TernJS port file 143 | .tern-port 144 | 145 | # Stores VSCode versions used for testing VSCode extensions 146 | .vscode-test 147 | 148 | ### Vim ### 149 | # Swap 150 | [._]*.s[a-v][a-z] 151 | !*.svg # comment out if you don't need vector files 152 | [._]*.sw[a-p] 153 | [._]s[a-rt-v][a-z] 154 | [._]ss[a-gi-z] 155 | [._]sw[a-p] 156 | 157 | # Session 158 | Session.vim 159 | Sessionx.vim 160 | 161 | # Temporary 162 | .netrwhist 163 | *~ 164 | # Auto-generated tag files 165 | tags 166 | # Persistent undo 167 | [._]*.un~ 168 | 169 | # End of https://www.toptal.com/developers/gitignore/api/node,vim,macos 170 | -------------------------------------------------------------------------------- /.gitlab-ci.yml: -------------------------------------------------------------------------------- 1 | stages: 2 | - test 3 | 4 | integration-test: 5 | extends: .node-cache 6 | stage: test 7 | image: node:14-alpine 8 | 9 | services: 10 | - name: docker:20.10.1-dind 11 | command: ['--tls=false', '--host=tcp://0.0.0.0:2376'] 12 | 13 | variables: 14 | DOCKER_HOST: "tcp://docker:2375" 15 | DOCKER_TLS_CERTDIR: "" 16 | DOCKER_DRIVER: "overlay2" 17 | 18 | script: 19 | - export CI=true 20 | - npm ci 21 | - npm test 22 | - npm run test:integration 23 | 24 | .node-cache: 25 | cache: 26 | key: 27 | files: 28 | - package.json 29 | - package-lock.json 30 | paths: 31 | - node_modules 32 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | ...require('gts/.prettierrc.json') 3 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # nodejs-postgresql-testcontainers 2 | An example project showcasing how to use [@trendyol/jest-testcontainers](https://github.com/Trendyol/jest-testcontainers) to write integration tests for your PostgreSQL queries. 3 | 4 | Read the article describing this project in detail [on Medium](https://medium.com/trendyol-tech/how-to-test-database-queries-and-more-with-node-js-2f02b08707a7). 5 | 6 | ### Project Structure 7 | ``` 8 | . 9 | ├── migrations # database migratioons 10 | ├── src # source code of your application 11 | └── test 12 | ├── integration # integration tests that contain IO operations(e.g. DB, Queue) 13 | └── unit # unit tests for pure business logic / interaction witout IO 14 | ``` 15 | 16 | ### Overview 17 | ![implementation overview](docs/images/overview.png) 18 | 19 | 1. [test/integration/](test/integration) includes various files like [jest.config.js](test/integration/jest.config.js), [jest-testcontainers-config.js](test/integration/jest-testcontainers-config.js) and [preset.js](test/integration/preset.js) for configuring jest-testcontainers to start a PostgreSQL instance. 20 | 2. [jest-testcontainers](https://github.com/Trendyol/jest-testcontainers) is used to start a PostgreSQL instance and wait for t to start. 21 | 3. [migrations/](migrations) folder stores database schema migrations that will be applied to the started PostgreSQL instance. 22 | 4. [postgresql.environment.ts](test/integration/postgresql.environment.ts) uses [node-pg-migrate](https://github.com/salsita/node-pg-migrate) to migrate PostgreSQL instance started by the jest-testcontainers. 23 | 5. [test/integration/](test/integration) folder is scanned for files with `*.test.ts` prefix and tests are ran. 24 | 25 | Watch mode is supported so you can start your integration tests in watch mode and refactor your code whilst your PostgreSQL Docker instance is up. 26 | 27 | -------------------------------------------------------------------------------- /docs/images/overview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Yengas/nodejs-postgresql-testcontainers/b1180219547c6911b20eb35da2860e3fc312bc74/docs/images/overview.png -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | moduleFileExtensions: ['js', 'json', 'ts'], 3 | rootDir: './', 4 | testMatch: ['/test/unit/**/*.test.ts'], 5 | transform: { 6 | '^.+\\.(t|j)s$': 'ts-jest', 7 | }, 8 | collectCoverageFrom: [ 9 | 'src/**/*.ts', 10 | '!src/main.ts', 11 | '!src/**/*.config.ts', 12 | '!src/**/*.repository.ts', 13 | '!src/**/model/**', 14 | '!src/**/error/**', 15 | ], 16 | coverageDirectory: 'coverage', 17 | testEnvironment: 'node', 18 | }; 19 | -------------------------------------------------------------------------------- /migrations/1609016983156_initial.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable camelcase */ 2 | 3 | exports.shorthands = undefined; 4 | 5 | exports.up = pgm => { 6 | pgm.createTable('advertisements', { 7 | id: {type: 'uuid', primaryKey: true}, 8 | seller_id: {type: 'int', notNull: true}, 9 | is_active: {type: 'boolean', notNull: true}, 10 | start_date_time: {type: 'timestamp with time zone', notNull: true}, 11 | end_date_time: {type: 'timestamp with time zone', notNull: true}, 12 | }); 13 | 14 | pgm.createTable('advertisement_products', { 15 | advertisement_id: {type: 'uuid', notNull: true}, 16 | product_id: {type: 'int', notNull: true}, 17 | }); 18 | 19 | pgm.sql(` 20 | CREATE INDEX "idx_advertisement_products_advertisement_id_and_product_id" ON "advertisement_products" ("advertisement_id", "product_id"); 21 | `); 22 | }; 23 | 24 | exports.down = pgm => { 25 | pgm.sql(` 26 | DROP INDEX "idx_advertisement_products_advertisement_id_and_product_id" 27 | `); 28 | }; 29 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nodejs-postgresql-testcontainers", 3 | "version": "1.0.0-rc0", 4 | "description": "", 5 | "main": "build/src/index.js", 6 | "types": "build/src/index.d.ts", 7 | "files": [ 8 | "build/src" 9 | ], 10 | "license": "Apache-2.0", 11 | "keywords": [], 12 | "scripts": { 13 | "test": "jest", 14 | "test:integration": "cd test/integration && jest --maxConcurrency=1 --maxWorkers=1", 15 | "check": "gts check", 16 | "clean": "gts clean", 17 | "compile": "tsc", 18 | "fix": "gts fix", 19 | "prepare": "npm run compile", 20 | "pretest": "npm run compile", 21 | "posttest": "npm run check", 22 | "migrate": "node-pg-migrate" 23 | }, 24 | "devDependencies": { 25 | "@trendyol/jest-testcontainers": "^2.0.0", 26 | "@types/node": "^13.11.1", 27 | "@types/pg": "^7.14.7", 28 | "eslint-plugin-jest": "^24.1.3", 29 | "gts": "^2.0.2", 30 | "husky": "^4.3.6", 31 | "jest": "^26.6.3", 32 | "lint-staged": "^10.5.3", 33 | "node-pg-migrate": "^5.9.0", 34 | "ts-jest": "^26.4.4", 35 | "ts-node": "^9.1.1", 36 | "typescript": "^3.8.3" 37 | }, 38 | "husky": { 39 | "hooks": { 40 | "pre-commit": "lint-staged && npm run test" 41 | } 42 | }, 43 | "lint-staged": { 44 | "src/**/*.ts": [ 45 | "npm run fix" 46 | ] 47 | }, 48 | "dependencies": { 49 | "pg": "^8.5.1" 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/advertisement/advertisement.repository.ts: -------------------------------------------------------------------------------- 1 | import {PostgreSQLAdapter, Query} from '../postgresql/postgresql.adapter'; 2 | 3 | // dummy repository for advertisement CRUD 4 | export class AdvertisementRepository { 5 | constructor(private readonly postgreSQLAdapter: PostgreSQLAdapter) {} 6 | 7 | async getAllAdvertisementsWithProducts(): Promise< 8 | AdvertisementWithProducts[] 9 | > { 10 | const {rows} = await this.postgreSQLAdapter 11 | .query(` 12 | SELECT 13 | a.id as "id", a.seller_id as "sellerID", a.is_active as "isActive", a.start_date_time as "startDateTime", a.end_date_time as "endDateTime", 14 | json_agg(ap.product_id) as products 15 | FROM advertisements as a 16 | INNER JOIN advertisement_products ap ON ap.advertisement_id = a.id 17 | GROUP BY a.id 18 | `); 19 | 20 | return rows; 21 | } 22 | 23 | async insertAdvertisementsWithProducts( 24 | advertisementsWithProducts: AdvertisementWithProducts[] 25 | ): Promise { 26 | const queries = advertisementsWithProducts.reduce( 27 | (allQueries, advertisement) => [ 28 | ...allQueries, 29 | AdvertisementRepository.advertisementInsertQuery(advertisement), 30 | ...advertisement.products.map(productID => 31 | AdvertisementRepository.productInsertQuery( 32 | advertisement.id, 33 | productID 34 | ) 35 | ), 36 | ], 37 | [] 38 | ); 39 | 40 | return this.postgreSQLAdapter.multipleQueryInTransaction(queries); 41 | } 42 | 43 | private static advertisementInsertQuery( 44 | advertisement: AdvertisementWithProducts 45 | ): Query { 46 | const {id, sellerID, isActive, startDateTime, endDateTime} = advertisement; 47 | const params = [id, sellerID, isActive, startDateTime, endDateTime]; 48 | 49 | return { 50 | sql: 51 | 'INSERT INTO advertisements (id, seller_id, is_active, start_date_time, end_date_time) VALUES ($1, $2, $3, $4, $5)', 52 | params, 53 | }; 54 | } 55 | 56 | private static productInsertQuery( 57 | advertisementID: string, 58 | productID: number 59 | ): Query { 60 | return { 61 | sql: 62 | 'INSERT INTO advertisement_products (advertisement_id, product_id) VALUES ($1, $2)', 63 | params: [advertisementID, productID], 64 | }; 65 | } 66 | } 67 | 68 | export type Advertisement = { 69 | id: string; 70 | sellerID: number; 71 | isActive: boolean; 72 | startDateTime: Date; 73 | endDateTime: Date; 74 | }; 75 | 76 | export type AdvertisementWithProducts = Advertisement & { 77 | products: number[]; 78 | }; 79 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export {ProductIntersectionRepository} from './product-intersection/product-intersection.repository'; 2 | -------------------------------------------------------------------------------- /src/postgresql/postgresql.adapter.ts: -------------------------------------------------------------------------------- 1 | import {Pool, QueryResult} from 'pg'; 2 | import {PostgreSQLConfig} from './postgresql.config'; 3 | 4 | export class PostgreSQLAdapter { 5 | private pool: Pool | undefined; 6 | 7 | constructor(private readonly config: PostgreSQLConfig) {} 8 | 9 | public async query( 10 | query: string, 11 | params: unknown[] = [] 12 | ): Promise> { 13 | return this.pool!.query(query, params); 14 | } 15 | 16 | public async multipleQueryInTransaction(queries: Query[]): Promise { 17 | const client = await this.pool!.connect(); 18 | 19 | try { 20 | await client.query('BEGIN'); 21 | 22 | for (const query of queries) { 23 | await client.query(query.sql, query.params); 24 | } 25 | 26 | await client.query('COMMIT'); 27 | } catch (err) { 28 | await client.query('ROLLBACK'); 29 | throw err; 30 | } finally { 31 | client.release(); 32 | } 33 | } 34 | 35 | public async connect(): Promise { 36 | const pool = new Pool({ 37 | connectionString: this.config.uri, 38 | }); 39 | 40 | try { 41 | await pool.query('SELECT NOW();'); 42 | } catch (err) { 43 | throw new Error( 44 | `PostgreSQL could not execute dummy query. Error: ${err.message}` 45 | ); 46 | } 47 | 48 | this.pool = pool; 49 | } 50 | 51 | public async close(): Promise { 52 | await this.pool!.end(); 53 | } 54 | } 55 | 56 | export type Query = { 57 | sql: string; 58 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 59 | params: any[]; 60 | }; 61 | -------------------------------------------------------------------------------- /src/postgresql/postgresql.config.ts: -------------------------------------------------------------------------------- 1 | export type PostgreSQLConfig = { 2 | uri: string; 3 | }; 4 | -------------------------------------------------------------------------------- /src/product-intersection/product-intersection.repository.ts: -------------------------------------------------------------------------------- 1 | import {PostgreSQLAdapter} from '../postgresql/postgresql.adapter'; 2 | import {AdvertisementWithProducts} from '../advertisement/advertisement.repository'; 3 | 4 | export class ProductIntersectionRepository { 5 | constructor(private readonly postgreSQLAdapter: PostgreSQLAdapter) {} 6 | 7 | async getActiveAdvertisementProducts( 8 | query: ProductIntersectionQuery 9 | ): Promise { 10 | const sql = ` 11 | SELECT 12 | a.id as "id", a.seller_id as "sellerID", a.is_active as "isActive", a.start_date_time as "startDateTime", a.end_date_time as "endDateTime", 13 | json_agg(ap.product_id) as products 14 | FROM advertisements as a 15 | INNER JOIN advertisement_products ap ON ap.advertisement_id = a.id 16 | WHERE 17 | a.seller_id = $1 18 | AND a.is_active = true 19 | AND $2 <= end_date_time 20 | AND $3 >= start_date_time 21 | GROUP BY a.id 22 | `; 23 | 24 | const {sellerID, startDateTime, endDateTime} = query; 25 | const params = [sellerID, startDateTime, endDateTime]; 26 | 27 | const { 28 | rows, 29 | } = await this.postgreSQLAdapter.query( 30 | sql, 31 | params 32 | ); 33 | 34 | return rows; 35 | } 36 | } 37 | 38 | type ProductIntersectionQuery = { 39 | sellerID: number; 40 | startDateTime: Date; 41 | endDateTime: Date; 42 | }; 43 | -------------------------------------------------------------------------------- /test/integration/advertisement/advertisement.repository.test.ts: -------------------------------------------------------------------------------- 1 | import {PostgreSQLAdapter} from '../../../src/postgresql/postgresql.adapter'; 2 | import { 3 | AdvertisementRepository, 4 | AdvertisementWithProducts, 5 | } from '../../../src/advertisement/advertisement.repository'; 6 | 7 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 8 | const postgreSQLAdapter: PostgreSQLAdapter = (global as any).postgreSQLAdapter; 9 | const repository = new AdvertisementRepository(postgreSQLAdapter); 10 | 11 | describe('AdvertisementRepository', () => { 12 | beforeEach(async () => { 13 | await postgreSQLAdapter.query('DELETE FROM advertisements'); 14 | await postgreSQLAdapter.query('DELETE FROM advertisement_products'); 15 | }); 16 | 17 | const advertisementsWithProducts: AdvertisementWithProducts[] = [ 18 | { 19 | id: '78a13697-bfdf-4f5f-96a4-c4a16f578c1d', 20 | sellerID: 968, 21 | isActive: true, 22 | startDateTime: new Date(10), 23 | endDateTime: new Date(20), 24 | products: [1, 2, 3], 25 | }, 26 | { 27 | id: '886520a6-eb3b-4deb-9819-42f66e24f2d3', 28 | sellerID: 73, 29 | isActive: false, 30 | startDateTime: new Date(30), 31 | endDateTime: new Date(40), 32 | products: [4, 5, 6], 33 | }, 34 | ]; 35 | 36 | it('should do CRUD', async () => { 37 | await repository.insertAdvertisementsWithProducts( 38 | advertisementsWithProducts 39 | ); 40 | 41 | const result = await repository.getAllAdvertisementsWithProducts(); 42 | 43 | expect(result).toEqual(expect.arrayContaining(advertisementsWithProducts)); 44 | }); 45 | }); 46 | -------------------------------------------------------------------------------- /test/integration/jest-testcontainers-config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | postgre: { 3 | image: 'postgres', 4 | tag: '13.1-alpine', 5 | ports: [5432], 6 | env: { 7 | POSTGRES_PASSWORD: 'integration-pass', 8 | }, 9 | wait: { 10 | type: 'text', 11 | text: 'server started', 12 | }, 13 | }, 14 | }; 15 | -------------------------------------------------------------------------------- /test/integration/jest.config.js: -------------------------------------------------------------------------------- 1 | const {join} = require('path'); 2 | 3 | module.exports = { 4 | preset: './test/integration/preset.js', 5 | testMatch: ['/test/integration/**/*.test.ts'], 6 | rootDir: join(__dirname, '../../'), 7 | }; 8 | -------------------------------------------------------------------------------- /test/integration/postgresql.environment.ts: -------------------------------------------------------------------------------- 1 | import {join} from 'path'; 2 | import {TestcontainersEnvironment} from '@trendyol/jest-testcontainers'; 3 | import migration from 'node-pg-migrate'; 4 | import {PostgreSQLAdapter} from '../../src/postgresql/postgresql.adapter'; 5 | import {PostgreSQLConfig} from '../../src/postgresql/postgresql.config'; 6 | 7 | class PostgreSQLEnvironment extends TestcontainersEnvironment { 8 | private static readonly MIGRATION_DIR = join(__dirname, '../../migrations'); 9 | private static readonly MIGRATION_TABLE = 'pgmirations'; 10 | 11 | private static readonly POSTGRESQL_DB = 'postgres'; 12 | private static readonly POSTGRESQL_AUTH = 'postgres:integration-pass'; 13 | 14 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 15 | private postgreSQLAdapter: PostgreSQLAdapter = undefined as any; 16 | 17 | async setup() { 18 | await super.setup(); 19 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 20 | const globals: any = (this as any).global; 21 | 22 | const uri = `postgresql://${PostgreSQLEnvironment.POSTGRESQL_AUTH}@${globals.__TESTCONTAINERS_POSTGRE_IP__}:${globals.__TESTCONTAINERS_POSTGRE_PORT_5432__}/${PostgreSQLEnvironment.POSTGRESQL_DB}`; 23 | const postgreSQLConfig: PostgreSQLConfig = {uri}; 24 | const postgreSQLAdapter = new PostgreSQLAdapter(postgreSQLConfig); 25 | 26 | await postgreSQLAdapter.connect(); 27 | await migration({ 28 | databaseUrl: uri, 29 | dir: PostgreSQLEnvironment.MIGRATION_DIR, 30 | migrationsTable: PostgreSQLEnvironment.MIGRATION_TABLE, 31 | direction: 'up', 32 | count: 999, 33 | }); 34 | 35 | globals.postgreSQLAdapter = postgreSQLAdapter; 36 | this.postgreSQLAdapter = postgreSQLAdapter; 37 | } 38 | 39 | async teardown() { 40 | await super.teardown(); 41 | this.postgreSQLAdapter && (await this.postgreSQLAdapter.close()); 42 | } 43 | } 44 | 45 | module.exports = PostgreSQLEnvironment; 46 | -------------------------------------------------------------------------------- /test/integration/postgresql/postgresql.adapter.test.ts: -------------------------------------------------------------------------------- 1 | import {PostgreSQLAdapter} from '../../../src/postgresql/postgresql.adapter'; 2 | 3 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 4 | const postgreSQLAdapter: PostgreSQLAdapter = (global as any).postgreSQLAdapter; 5 | 6 | describe('PostgreSQLAdapter', () => { 7 | describe('.query()', () => { 8 | it('should query with parameters', async () => { 9 | // Arrange 10 | const query = 'SELECT ($1 + 5) as res'; 11 | const params = [73]; 12 | 13 | const expectedResult = 78; 14 | 15 | // Act 16 | const result = await postgreSQLAdapter.query<{res: number}>( 17 | query, 18 | params 19 | ); 20 | 21 | // Assert 22 | expect(result.rows[0]?.res).toBe(expectedResult); 23 | }); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /test/integration/preset.js: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line node/no-missing-require 2 | require('ts-node/register'); 3 | const {resolve} = require('path'); 4 | const ts_preset = require('ts-jest/jest-preset'); 5 | const testcontainers_preset = require('@trendyol/jest-testcontainers/jest-preset'); 6 | 7 | module.exports = Object.assign(ts_preset, testcontainers_preset, { 8 | testEnvironment: resolve(__dirname, './postgresql.environment.ts'), 9 | }); 10 | -------------------------------------------------------------------------------- /test/integration/product-intersection/product-intersection.repository.test.ts: -------------------------------------------------------------------------------- 1 | import {PostgreSQLAdapter} from '../../../src/postgresql/postgresql.adapter'; 2 | import { 3 | AdvertisementRepository, 4 | AdvertisementWithProducts, 5 | } from '../../../src/advertisement/advertisement.repository'; 6 | import {ProductIntersectionRepository} from '../../../src/product-intersection/product-intersection.repository'; 7 | 8 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 9 | const postgreSQLAdapter: PostgreSQLAdapter = (global as any).postgreSQLAdapter; 10 | const adRepository = new AdvertisementRepository(postgreSQLAdapter); 11 | const repository = new ProductIntersectionRepository(postgreSQLAdapter); 12 | 13 | describe('ProductIntersectionRepository', () => { 14 | const UUID_PREFIX = 'f4f4c9e3-a077-4f3c-bf73-9c54cb57ffa'; 15 | const UUID_PREFIX_2 = 'f4f4c9e3-a077-4f3c-bf73-9c54cb57ffb'; 16 | 17 | beforeEach(async () => { 18 | await postgreSQLAdapter.query('DELETE FROM advertisements'); 19 | await postgreSQLAdapter.query('DELETE FROM advertisement_products'); 20 | }); 21 | 22 | describe('.getActiveAdvertisementProducts()', () => { 23 | const SELLER_ID = 968; 24 | const OTHER_SELLER_ID = 73; 25 | 26 | const BASE_ADVERTISEMENT: AdvertisementWithProducts = { 27 | id: UUID_PREFIX + '0', 28 | sellerID: OTHER_SELLER_ID, 29 | isActive: false, 30 | startDateTime: new Date(0), 31 | endDateTime: new Date(0), 32 | products: [], 33 | }; 34 | 35 | it('should only return products for given sellers active advertisements', async () => { 36 | const advertisements: AdvertisementWithProducts[] = [ 37 | { 38 | ...BASE_ADVERTISEMENT, 39 | id: UUID_PREFIX + '1', 40 | sellerID: SELLER_ID, 41 | isActive: true, 42 | products: [1, 2, 3], 43 | }, 44 | { 45 | ...BASE_ADVERTISEMENT, 46 | id: UUID_PREFIX + '2', 47 | sellerID: SELLER_ID, 48 | isActive: false, 49 | products: [4, 5, 6], 50 | }, 51 | { 52 | ...BASE_ADVERTISEMENT, 53 | id: UUID_PREFIX + '3', 54 | sellerID: OTHER_SELLER_ID, 55 | isActive: true, 56 | products: [7, 8, 9], 57 | }, 58 | ]; 59 | 60 | const expectedResult = [advertisements[0]]; 61 | 62 | await adRepository.insertAdvertisementsWithProducts(advertisements); 63 | 64 | const result = await repository.getActiveAdvertisementProducts({ 65 | sellerID: SELLER_ID, 66 | startDateTime: BASE_ADVERTISEMENT.startDateTime, 67 | endDateTime: BASE_ADVERTISEMENT.endDateTime, 68 | }); 69 | 70 | expect(result).toEqual(expectedResult); 71 | }); 72 | 73 | it('should only return products for advertisements in intersecting times', async () => { 74 | const startDateTime = new Date(5); 75 | const endDateTime = new Date(10); 76 | 77 | // all advertisements are active and for are owned by our seller 78 | const baseAdvertisement = { 79 | ...BASE_ADVERTISEMENT, 80 | sellerID: SELLER_ID, 81 | isActive: true, 82 | }; 83 | 84 | const intersectingAdvertisements: AdvertisementWithProducts[] = [ 85 | { 86 | ...baseAdvertisement, 87 | id: UUID_PREFIX + '1', 88 | startDateTime: new Date(4), 89 | endDateTime: new Date(5), 90 | products: [1, 2, 3], 91 | }, 92 | { 93 | ...baseAdvertisement, 94 | id: UUID_PREFIX + '2', 95 | startDateTime: new Date(6), 96 | endDateTime: new Date(7), 97 | products: [2, 3, 4], 98 | }, 99 | { 100 | ...baseAdvertisement, 101 | id: UUID_PREFIX + '3', 102 | startDateTime: new Date(10), 103 | endDateTime: new Date(11), 104 | products: [5, 6, 7], 105 | }, 106 | { 107 | ...baseAdvertisement, 108 | id: UUID_PREFIX + '4', 109 | startDateTime: new Date(4), 110 | endDateTime: new Date(11), 111 | products: [7, 8, 9], 112 | }, 113 | ]; 114 | 115 | const nonIntersectingAdvertisements: AdvertisementWithProducts[] = [ 116 | { 117 | ...baseAdvertisement, 118 | id: UUID_PREFIX_2 + '1', 119 | startDateTime: new Date(4), 120 | endDateTime: new Date(4), 121 | products: [10, 11], 122 | }, 123 | { 124 | ...baseAdvertisement, 125 | id: UUID_PREFIX_2 + '2', 126 | startDateTime: new Date(11), 127 | endDateTime: new Date(11), 128 | products: [12, 13], 129 | }, 130 | ]; 131 | 132 | await adRepository.insertAdvertisementsWithProducts([ 133 | ...intersectingAdvertisements, 134 | ...nonIntersectingAdvertisements, 135 | ]); 136 | 137 | const result = await repository.getActiveAdvertisementProducts({ 138 | sellerID: SELLER_ID, 139 | startDateTime, 140 | endDateTime, 141 | }); 142 | 143 | expect(result).toEqual( 144 | expect.arrayContaining(intersectingAdvertisements) 145 | ); 146 | }); 147 | }); 148 | }); 149 | -------------------------------------------------------------------------------- /test/unit/hello/hello.test.ts: -------------------------------------------------------------------------------- 1 | describe('hello', () => { 2 | it('should hello', () => { 3 | expect(true).toBe(true); 4 | }); 5 | }); 6 | -------------------------------------------------------------------------------- /test/unit/index.test.ts: -------------------------------------------------------------------------------- 1 | describe('index', () => { 2 | it('should be true', () => { 3 | expect(true).toBe(true); 4 | }); 5 | }); 6 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./node_modules/gts/tsconfig-google.json", 3 | "compilerOptions": { 4 | "rootDir": ".", 5 | "outDir": "build" 6 | }, 7 | "include": ["src/**/*.ts"] 8 | } 9 | --------------------------------------------------------------------------------