Compare commits

...
Author SHA1 Message Date
Nick Novitski 448d75b402 Replace run-with-docker.sh with testcontainer
Each test starts its own service dependencies, transparently downloading
and/or re-using docker images.  This allows all tests to be run in one command.
2025-06-26 09:18:49 -07:00
42 changed files with 1050 additions and 397 deletions

No files matched your search

+8 -2
View File
@@ -20,12 +20,18 @@
{
"project": "packages/entity-cache-adapter-redis/tsconfig.json",
"output": "packages/entity-cache-adapter-redis/src",
"exclude": [ "**/__testfixtures__/**" ]
"exclude": [
"**/__testfixtures__/**",
"**/__integration-tests__/**"
]
},
{
"project": "packages/entity-database-adapter-knex/tsconfig.json",
"output": "packages/entity-database-adapter-knex/src",
"exclude": [ "**/__testfixtures__/**" ]
"exclude": [
"**/__testfixtures__/**",
"**/__integration-tests__/**"
]
},
{
"project": "packages/entity-ip-address-field/tsconfig.json",
+3 -10
View File
@@ -20,17 +20,10 @@ jobs:
- run: yarn lint --max-warnings=0
- name: Check if barrels are up to date
run: yarn ctix && [ -z "$(git status --porcelain)" ]
- run: yarn test --coverage
- run: yarn integration --coverage
- uses: SimenB/github-actions-cpu-cores@v2
id: cpu-cores
- run: yarn test:all ---maxWorkers ${{ steps.cpu-cores.outputs.count }}
- uses: codecov/codecov-action@v5
with:
token: ${{ secrets.CODECOV_TOKEN }}
directory: ./coverage
flags: unittest
fail_ci_if_error: true
- uses: codecov/codecov-action@v5
with:
token: ${{ secrets.CODECOV_TOKEN }}
directory: ./coverage-integration
flags: integration
fail_ci_if_error: true
+1 -6
View File
@@ -38,12 +38,7 @@ node_modules/
!**/.yarn/versions
**/.pnp.*
.nx/cache
.nx/workspace-data
# Entity-specific ignores
build/
coverage/
coverage-integration/
doc/
doc/
-7
View File
@@ -1,7 +0,0 @@
const unitConfig = require('./jest.config.js');
module.exports = {
...unitConfig,
coverageDirectory: 'coverage-integration',
testMatch: ['**/__integration-tests__/**/*-test.ts'],
};
+12 -1
View File
@@ -1,5 +1,6 @@
module.exports = {
transform: { '\\.[jt]sx?$': ['babel-jest', { rootMode: 'upward' }] },
collectCoverage: true,
collectCoverageFrom: [
'packages/*/src/**',
'!packages/*/src/index.ts',
@@ -9,5 +10,15 @@ module.exports = {
'!**/__*test*__/**',
],
coverageProvider: 'v8',
testMatch: ['**/__tests__/**/*-test.ts'],
randomize: true,
testEnvironmentOptions: { globalsCleanupMode: 'on' },
workerThreads: true,
projects: [
{ displayName: 'unit', testMatch: ['**/__tests__/**/*-test.ts'] },
{
displayName: 'integration',
globalSetup: '<rootDir>/setup.mjs',
testMatch: ['**/__integration-tests__/**/*-test.ts'],
},
],
};
-16
View File
@@ -1,16 +0,0 @@
{
"targetDefaults": {
"tsc": {
"dependsOn": ["^clean", "clean", "^tsc"]
},
"build": {
"dependsOn": ["^clean", "clean", "^build"]
},
"test": {
"dependsOn": ["^tsc"]
},
"integration": {
"dependsOn": ["^tsc"]
}
}
}
+6 -4
View File
@@ -10,12 +10,13 @@
"clean": "yarn build --clean",
"lint": "eslint .",
"lint-fix": "yarn lint --fix",
"test": "yarn build --noCheck && jest",
"test:all": "yarn test",
"integration": "./resources/run-with-docker yarn test --config jest-integration.config.js --runInBand",
"test": "yarn test:unit",
"test:unit": "yarn test:all --selectProjects unit",
"test:all": "yarn build --noCheck && DEBUG=testcontainers:pull jest",
"integration": "yarn test:all --selectProjects integration",
"integration:all": "yarn integration",
"prepack": "yarn build",
"ctix": "ctix build && resources/prepend-barrels.sh",
"ctix": "ctix build && ./prepend-barrels.sh",
"typedoc": "yarn build && typedoc"
},
"devDependencies": {
@@ -35,6 +36,7 @@
"lerna": "^8.2.2",
"prettier": "^3.5.3",
"prettier-plugin-organize-imports": "^4.1.0",
"testcontainers": "^11.0.3",
"typedoc": "^0.28.4",
"typescript": "^5.8.3"
},
@@ -13,8 +13,7 @@
"clean": "yarn build --clean",
"lint": "yarn run --top-level eslint src",
"lint-fix": "yarn lint --fix",
"test": "yarn test:all --rootDir $(pwd)",
"integration": "yarn integration:all --rootDir $(pwd)"
"test": "yarn test:unit --roots $(pwd)"
},
"engines": {
"node": ">=16"
@@ -13,8 +13,8 @@
"clean": "yarn build --clean",
"lint": "yarn run --top-level eslint src",
"lint-fix": "yarn lint --fix",
"test": "yarn test:all --rootDir $(pwd)",
"integration": "yarn integration:all --rootDir $(pwd)"
"test": "yarn test:unit --roots $(pwd)",
"integration": "yarn test:integration --roots $(pwd)"
},
"engines": {
"node": ">=16"
@@ -34,6 +34,7 @@
"@expo/batcher": "^1.0.0",
"@expo/entity-testing-utils": "workspace:^",
"@jest/globals": "^30.0.0",
"@testcontainers/redis": "^11.0.3",
"ioredis": "^5.6.0",
"ts-mockito": "^2.6.1",
"typescript": "^5.8.3"
@@ -8,9 +8,7 @@ import {
} from '@expo/entity';
import { afterAll, beforeAll, beforeEach, describe, expect, it, jest } from '@jest/globals';
import invariant from 'invariant';
import Redis from 'ioredis';
import nullthrows from 'nullthrows';
import { URL } from 'url';
import { v4 as uuidv4 } from 'uuid';
import {
@@ -20,6 +18,7 @@ import {
IRedisTransaction,
RedisCacheInvalidationStrategy,
} from '../GenericRedisCacher';
import { Redis, StartedRedisContainer, startRedisAsync } from './testcontainer';
import { RedisTestEntity, RedisTestEntityFields } from '../__testfixtures__/RedisTestEntity';
import { createRedisIntegrationTestEntityCompanionProvider } from '../__testfixtures__/createRedisIntegrationTestEntityCompanionProvider';
@@ -69,14 +68,14 @@ class BatchedRedis implements IRedis {
}
describe(GenericRedisCacher, () => {
const redis = new Redis(new URL(process.env['REDIS_URL']!).toString());
const redisClient = new BatchedRedis(redis);
let container: StartedRedisContainer;
let redisClient: Redis;
let genericRedisCacheContext: GenericRedisCacheContext;
beforeAll(() => {
beforeAll(async () => {
({ container, redisClient } = await startRedisAsync());
genericRedisCacheContext = {
redisClient,
redisClient: new BatchedRedis(redisClient),
makeKeyFn(...parts: string[]): string {
const delimiter = ':';
const escapedParts = parts.map((part) =>
@@ -94,11 +93,12 @@ describe(GenericRedisCacher, () => {
});
beforeEach(async () => {
await redis.flushdb();
await redisClient.flushdb();
});
afterAll(async () => {
redis.disconnect();
redisClient.disconnect();
await container.stop();
});
it('has correct caching behavior', async () => {
@@ -107,7 +107,7 @@ describe(GenericRedisCacher, () => {
createRedisIntegrationTestEntityCompanionProvider(genericRedisCacheContext),
);
const mgetSpy = jest.spyOn(redis, 'mget');
const mgetSpy = jest.spyOn(redisClient, 'mget');
const genericCacher = viewerContext.entityCompanionProvider.getCompanionForEntity(
RedisTestEntity,
@@ -149,7 +149,7 @@ describe(GenericRedisCacher, () => {
new SingleFieldHolder('id'),
new SingleFieldValueHolder(entity1Created.getID()),
);
const cachedJSON = await redis.get(cacheKeyEntity1);
const cachedJSON = await redisClient.get(cacheKeyEntity1);
const cachedValue = JSON.parse(cachedJSON!);
expect(cachedValue).toMatchObject({
id: entity1.getID(),
@@ -164,7 +164,7 @@ describe(GenericRedisCacher, () => {
'name',
entity1Created.getField('name'),
);
await expect(redis.get(cacheKeyEntity1NameField)).resolves.toEqual(cachedJSON);
await expect(redisClient.get(cacheKeyEntity1NameField)).resolves.toEqual(cachedJSON);
// simulate non existent db fetch, should write negative result ('') to cache
const nonExistentId = uuidv4();
@@ -177,7 +177,7 @@ describe(GenericRedisCacher, () => {
new SingleFieldHolder('id'),
new SingleFieldValueHolder(nonExistentId),
);
const nonExistentCachedValue = await redis.get(cacheKeyNonExistent);
const nonExistentCachedValue = await redisClient.get(cacheKeyNonExistent);
expect(nonExistentCachedValue).toEqual('');
// load again through entities framework to ensure it reads negative result
const entityNonExistentResult2 =
@@ -188,7 +188,7 @@ describe(GenericRedisCacher, () => {
// invalidate from cache to ensure it invalidates correctly in both caches
await RedisTestEntity.loaderUtils(viewerContext).invalidateFieldsAsync(entity1.getAllFields());
await expect(redis.get(cacheKeyEntity1)).resolves.toBeNull();
await expect(redis.get(cacheKeyEntity1NameField)).resolves.toBeNull();
await expect(redisClient.get(cacheKeyEntity1)).resolves.toBeNull();
await expect(redisClient.get(cacheKeyEntity1NameField)).resolves.toBeNull();
});
});
@@ -8,8 +8,6 @@ import {
} from '@expo/entity';
import { enforceAsyncResult } from '@expo/results';
import { afterAll, beforeAll, beforeEach, describe, expect, it } from '@jest/globals';
import Redis from 'ioredis';
import { URL } from 'url';
import { v4 as uuidv4 } from 'uuid';
import {
@@ -17,17 +15,21 @@ import {
GenericRedisCacher,
RedisCacheInvalidationStrategy,
} from '../GenericRedisCacher';
import { Redis, StartedRedisContainer, startRedisAsync } from './testcontainer';
import { RedisTestEntity, RedisTestEntityFields } from '../__testfixtures__/RedisTestEntity';
import { createRedisIntegrationTestEntityCompanionProvider } from '../__testfixtures__/createRedisIntegrationTestEntityCompanionProvider';
class TestViewerContext extends ViewerContext {}
describe(GenericRedisCacher, () => {
let container: StartedRedisContainer;
let redisClient: Redis;
let genericRedisCacheContext: GenericRedisCacheContext;
beforeAll(() => {
beforeAll(async () => {
({ container, redisClient } = await startRedisAsync());
genericRedisCacheContext = {
redisClient: new Redis(new URL(process.env['REDIS_URL']!).toString()),
redisClient,
makeKeyFn(...parts: string[]): string {
const delimiter = ':';
const escapedParts = parts.map((part) =>
@@ -45,10 +47,12 @@ describe(GenericRedisCacher, () => {
});
beforeEach(async () => {
await (genericRedisCacheContext.redisClient as Redis).flushdb();
await redisClient.flushdb();
});
afterAll(async () => {
(genericRedisCacheContext.redisClient as Redis).disconnect();
redisClient.disconnect();
await container.stop();
});
it('has correct caching behavior', async () => {
@@ -1,13 +1,12 @@
import { CacheStatus, ViewerContext } from '@expo/entity';
import { afterAll, beforeAll, beforeEach, describe, expect, it } from '@jest/globals';
import Redis from 'ioredis';
import { URL } from 'url';
import {
GenericRedisCacheContext,
GenericRedisCacher,
RedisCacheInvalidationStrategy,
} from '../GenericRedisCacher';
import { Redis, StartedRedisContainer, startRedisAsync } from './testcontainer';
import {
RedisTestEntity,
redisTestEntityConfiguration,
@@ -19,10 +18,13 @@ class TestViewerContext extends ViewerContext {}
describe(GenericRedisCacher, () => {
let genericRedisCacheContext: GenericRedisCacheContext;
let redisClient: Redis;
let container: StartedRedisContainer;
beforeAll(() => {
beforeAll(async () => {
({ redisClient, container } = await startRedisAsync());
genericRedisCacheContext = {
redisClient: new Redis(new URL(process.env['REDIS_URL']!).toString()),
redisClient,
makeKeyFn(...parts: string[]): string {
const delimiter = ':';
const escapedParts = parts.map((part) =>
@@ -40,10 +42,12 @@ describe(GenericRedisCacher, () => {
});
beforeEach(async () => {
await (genericRedisCacheContext.redisClient as Redis).flushdb();
await redisClient.flushdb();
});
afterAll(async () => {
(genericRedisCacheContext.redisClient as Redis).disconnect();
redisClient.disconnect();
await container.stop();
});
it('has correct caching and loading behavior', async () => {
@@ -1,23 +1,24 @@
import { EntityCacheAdapterTransientError, ViewerContext } from '@expo/entity';
import { beforeAll, beforeEach, describe, expect, it } from '@jest/globals';
import Redis from 'ioredis';
import { URL } from 'url';
import { afterAll, beforeAll, beforeEach, describe, expect, it } from '@jest/globals';
import {
GenericRedisCacheContext,
GenericRedisCacher,
RedisCacheInvalidationStrategy,
} from '../GenericRedisCacher';
import { Redis, StartedRedisContainer, startRedisAsync } from './testcontainer';
import { RedisTestEntity } from '../__testfixtures__/RedisTestEntity';
import { createRedisIntegrationTestEntityCompanionProvider } from '../__testfixtures__/createRedisIntegrationTestEntityCompanionProvider';
class TestViewerContext extends ViewerContext {}
describe(GenericRedisCacher, () => {
const redisClient = new Redis(new URL(process.env['REDIS_URL']!).toString());
let container: StartedRedisContainer;
let redisClient: Redis;
let genericRedisCacheContext: GenericRedisCacheContext;
beforeAll(() => {
beforeAll(async () => {
({ container, redisClient } = await startRedisAsync());
genericRedisCacheContext = {
redisClient,
makeKeyFn(...parts: string[]): string {
@@ -40,6 +41,10 @@ describe(GenericRedisCacher, () => {
await redisClient.flushdb();
});
afterAll(async () => {
await container.stop();
});
it('throws when redis is disconnected', async () => {
redisClient.disconnect();
@@ -0,0 +1,14 @@
import { RedisContainer, type StartedRedisContainer } from '@testcontainers/redis';
import { Redis } from 'ioredis';
export type { StartedRedisContainer } from '@testcontainers/redis';
export type { Redis } from 'ioredis';
export async function startRedisAsync(): Promise<{
redisClient: Redis;
container: StartedRedisContainer;
}> {
const container = await new RedisContainer('redis').start();
const redisClient = new Redis(container.getConnectionUrl());
return { redisClient, container };
}
+1 -1
View File
@@ -13,7 +13,7 @@
"clean": "yarn build --clean",
"lint": "yarn run --top-level eslint src",
"lint-fix": "yarn lint --fix",
"test": "yarn test:all --rootDir $(pwd)"
"test": "yarn test:unit --roots $(pwd)"
},
"engines": {
"node": ">=16"
@@ -13,8 +13,8 @@
"clean": "yarn build --clean",
"lint": "yarn run --top-level eslint src",
"lint-fix": "yarn lint --fix",
"test": "yarn test:all --rootDir $(pwd)",
"integration": "yarn integration:all --rootDir $(pwd)"
"test": "yarn test:unit --roots $(pwd)",
"integration": "yarn test:integration --roots $(pwd)"
},
"engines": {
"node": ">=16"
@@ -31,6 +31,7 @@
"devDependencies": {
"@expo/entity-testing-utils": "workspace:^",
"@jest/globals": "^30.0.0",
"@testcontainers/postgresql": "^11.0.3",
"pg": "8.14.1",
"typescript": "^5.8.3"
}
@@ -1,25 +1,17 @@
import { createWithUniqueConstraintRecoveryAsync, ViewerContext } from '@expo/entity';
import { afterAll, beforeAll, beforeEach, describe, expect, it } from '@jest/globals';
import knex, { Knex } from 'knex';
import nullthrows from 'nullthrows';
import { type Knex, type StartedPostgreSqlContainer, startPostgresAsync } from './testcontainer';
import { PostgresUniqueTestEntity } from '../__testfixtures__/PostgresUniqueTestEntity';
import { createKnexIntegrationTestEntityCompanionProvider } from '../__testfixtures__/createKnexIntegrationTestEntityCompanionProvider';
describe(createWithUniqueConstraintRecoveryAsync, () => {
let container: StartedPostgreSqlContainer;
let knexInstance: Knex;
beforeAll(() => {
knexInstance = knex({
client: 'pg',
connection: {
user: nullthrows(process.env['PGUSER']),
password: nullthrows(process.env['PGPASSWORD']),
host: 'localhost',
port: parseInt(nullthrows(process.env['PGPORT']), 10),
database: nullthrows(process.env['PGDATABASE']),
},
});
beforeAll(async () => {
({ container, knexInstance } = await startPostgresAsync());
});
beforeEach(async () => {
@@ -27,8 +19,8 @@ describe(createWithUniqueConstraintRecoveryAsync, () => {
});
afterAll(async () => {
await PostgresUniqueTestEntity.dropPostgresTableAsync(knexInstance);
await knexInstance.destroy();
await container.stop();
});
describe.each([true, false])('is parallel creations %p', (parallel) => {
@@ -2,29 +2,20 @@ import { OrderByOrdering, TransactionIsolationLevel, ViewerContext } from '@expo
import { createUnitTestEntityCompanionProvider } from '@expo/entity-testing-utils';
import { enforceAsyncResult } from '@expo/results';
import { afterAll, beforeAll, beforeEach, describe, expect, it, test } from '@jest/globals';
import { knex, Knex } from 'knex';
import nullthrows from 'nullthrows';
import { setTimeout } from 'timers/promises';
import { type Knex, type StartedPostgreSqlContainer, startPostgresAsync } from './testcontainer';
import { PostgresTestEntity } from '../__testfixtures__/PostgresTestEntity';
import { PostgresTriggerTestEntity } from '../__testfixtures__/PostgresTriggerTestEntity';
import { PostgresValidatorTestEntity } from '../__testfixtures__/PostgresValidatorTestEntity';
import { createKnexIntegrationTestEntityCompanionProvider } from '../__testfixtures__/createKnexIntegrationTestEntityCompanionProvider';
describe('postgres entity integration', () => {
let container: StartedPostgreSqlContainer;
let knexInstance: Knex;
beforeAll(() => {
knexInstance = knex({
client: 'pg',
connection: {
user: nullthrows(process.env['PGUSER']),
password: nullthrows(process.env['PGPASSWORD']),
host: 'localhost',
port: parseInt(nullthrows(process.env['PGPORT']), 10),
database: nullthrows(process.env['PGDATABASE']),
},
});
beforeAll(async () => {
({ container, knexInstance } = await startPostgresAsync());
});
beforeEach(async () => {
@@ -32,8 +23,8 @@ describe('postgres entity integration', () => {
});
afterAll(async () => {
await PostgresTestEntity.dropPostgresTableAsync(knexInstance);
await knexInstance.destroy();
await container.stop();
});
it('supports parallel partial updates', async () => {
@@ -1,26 +1,17 @@
import { TransactionalDataLoaderMode, ViewerContext } from '@expo/entity';
import { afterAll, beforeAll, beforeEach, describe, expect, it, test } from '@jest/globals';
import { knex, Knex } from 'knex';
import nullthrows from 'nullthrows';
import { PostgresEntityQueryContextProvider } from '../PostgresEntityQueryContextProvider';
import { type Knex, type StartedPostgreSqlContainer, startPostgresAsync } from './testcontainer';
import { PostgresUniqueTestEntity } from '../__testfixtures__/PostgresUniqueTestEntity';
import { createKnexIntegrationTestEntityCompanionProvider } from '../__testfixtures__/createKnexIntegrationTestEntityCompanionProvider';
describe(PostgresEntityQueryContextProvider, () => {
let container: StartedPostgreSqlContainer;
let knexInstance: Knex;
beforeAll(() => {
knexInstance = knex({
client: 'pg',
connection: {
user: nullthrows(process.env['PGUSER']),
password: nullthrows(process.env['PGPASSWORD']),
host: 'localhost',
port: parseInt(nullthrows(process.env['PGPORT']), 10),
database: nullthrows(process.env['PGDATABASE']),
},
});
beforeAll(async () => {
({ container, knexInstance } = await startPostgresAsync());
});
beforeEach(async () => {
@@ -28,8 +19,8 @@ describe(PostgresEntityQueryContextProvider, () => {
});
afterAll(async () => {
await PostgresUniqueTestEntity.dropPostgresTableAsync(knexInstance);
await knexInstance.destroy();
await container.stop();
});
it('supports nested transactions', async () => {
@@ -1,26 +1,17 @@
import { ViewerContext } from '@expo/entity';
import { enforceAsyncResult } from '@expo/results';
import { afterAll, beforeAll, beforeEach, describe, expect, it } from '@jest/globals';
import { knex, Knex } from 'knex';
import nullthrows from 'nullthrows';
import { type Knex, type StartedPostgreSqlContainer, startPostgresAsync } from './testcontainer';
import { InvalidTestEntity } from '../__testfixtures__/InvalidTestEntity';
import { createKnexIntegrationTestEntityCompanionProvider } from '../__testfixtures__/createKnexIntegrationTestEntityCompanionProvider';
describe('postgres entity integration', () => {
let container: StartedPostgreSqlContainer;
let knexInstance: Knex;
beforeAll(() => {
knexInstance = knex({
client: 'pg',
connection: {
user: nullthrows(process.env['PGUSER']),
password: nullthrows(process.env['PGPASSWORD']),
host: 'localhost',
port: parseInt(nullthrows(process.env['PGPORT']), 10),
database: nullthrows(process.env['PGDATABASE']),
},
});
beforeAll(async () => {
({ container, knexInstance } = await startPostgresAsync());
});
beforeEach(async () => {
@@ -28,8 +19,8 @@ describe('postgres entity integration', () => {
});
afterAll(async () => {
await InvalidTestEntity.dropPostgresTableAsync(knexInstance);
await knexInstance.destroy();
await container.stop();
});
describe('invalid operation result counts', () => {
@@ -9,26 +9,22 @@ import {
ViewerContext,
} from '@expo/entity';
import { afterAll, beforeAll, beforeEach, describe, expect, it } from '@jest/globals';
import { knex, Knex } from 'knex';
import nullthrows from 'nullthrows';
import {
type Knex,
type StartedPostgreSqlContainer,
containerKnex,
startPostgresAsync,
} from './testcontainer';
import { ErrorsTestEntity } from '../__testfixtures__/ErrorsTestEntity';
import { createKnexIntegrationTestEntityCompanionProvider } from '../__testfixtures__/createKnexIntegrationTestEntityCompanionProvider';
describe('postgres errors', () => {
let container: StartedPostgreSqlContainer;
let knexInstance: Knex;
beforeAll(() => {
knexInstance = knex({
client: 'pg',
connection: {
user: nullthrows(process.env['PGUSER']),
password: nullthrows(process.env['PGPASSWORD']),
host: 'localhost',
port: parseInt(nullthrows(process.env['PGPORT']), 10),
database: nullthrows(process.env['PGDATABASE']),
},
});
beforeAll(async () => {
({ container, knexInstance } = await startPostgresAsync());
});
beforeEach(async () => {
@@ -36,8 +32,8 @@ describe('postgres errors', () => {
});
afterAll(async () => {
await ErrorsTestEntity.dropPostgresTableAsync(knexInstance);
await knexInstance.destroy();
await container.stop();
});
it('throws EntityDatabaseAdapterTransientError on Knex timeout', async () => {
@@ -47,15 +43,7 @@ describe('postgres errors', () => {
.setField('fieldNonNull', 'hello')
.createAsync();
const shortTimeoutKnexInstance = knex({
client: 'pg',
connection: {
user: nullthrows(process.env['PGUSER']),
password: nullthrows(process.env['PGPASSWORD']),
host: 'localhost',
port: parseInt(nullthrows(process.env['PGPORT']), 10),
database: nullthrows(process.env['PGDATABASE']),
},
const shortTimeoutKnexInstance = containerKnex(container, {
acquireConnectionTimeout: 1,
});
const vc2 = new ViewerContext(
@@ -0,0 +1,24 @@
import { PostgreSqlContainer, StartedPostgreSqlContainer } from '@testcontainers/postgresql';
import { knex, Knex } from 'knex';
export type { StartedPostgreSqlContainer } from '@testcontainers/postgresql';
export type { Knex } from 'knex';
export function containerKnex(container: StartedPostgreSqlContainer, options?: Knex.Config): Knex {
return knex({
client: 'pg',
connection: {
connectionString: container.getConnectionUri(),
},
...options,
});
}
export async function startPostgresAsync(): Promise<{
knexInstance: Knex;
container: StartedPostgreSqlContainer;
}> {
const container = await new PostgreSqlContainer('postgres:14').start();
const knexInstance = containerKnex(container);
return { container, knexInstance };
}
+1 -2
View File
@@ -9,8 +9,7 @@
"clean": "yarn build --clean",
"lint": "yarn run --top-level eslint src",
"lint-fix": "yarn lint --fix",
"test": "yarn test:all --rootDir $(pwd)",
"integration": "yarn integration:all --rootDir $(pwd)",
"test": "yarn test:unit --roots $(pwd)",
"start": "yarn build --noCheck && node --watch --experimental-transform-types --inspect --async-stack-traces --throw-deprecation src/index.ts"
},
"engines": {
@@ -8,7 +8,7 @@
"clean": "yarn build --clean",
"lint": "yarn run --top-level eslint src",
"lint-fix": "yarn lint --fix",
"integration": "yarn integration:all --rootDir $(pwd)"
"integration": "yarn test:integration --roots $(pwd)"
},
"engines": {
"node": ">=16"
@@ -25,6 +25,8 @@
},
"devDependencies": {
"@jest/globals": "^30.0.0",
"@testcontainers/postgresql": "^11.0.3",
"@testcontainers/redis": "^11.0.3",
"typescript": "^5.8.3"
}
}
@@ -1,23 +1,26 @@
import {
EntityPrivacyPolicy,
ViewerContext,
AlwaysAllowPrivacyPolicyRule,
Entity,
EntityCompanionDefinition,
EntityConfiguration,
EntityPrivacyPolicy,
StringField,
UUIDField,
ViewerContext,
} from '@expo/entity';
import {
GenericRedisCacheContext,
RedisCacheInvalidationStrategy,
} from '@expo/entity-cache-adapter-redis';
import { afterAll, beforeAll, beforeEach, describe, expect, test } from '@jest/globals';
import Redis from 'ioredis';
import { knex, Knex } from 'knex';
import nullthrows from 'nullthrows';
import { URL } from 'url';
import {
type Knex,
type Redis,
type StartedPostgreSqlContainer,
type StartedRedisContainer,
startServicesAsync,
} from './testcontainer';
import { createFullIntegrationTestEntityCompanionProvider } from '../__testfixtures__/createFullIntegrationTestEntityCompanionProvider';
interface TestFields {
@@ -91,28 +94,15 @@ async function createOrTruncatePostgresTablesAsync(knex: Knex): Promise<void> {
await knex.into('testentities').truncate();
}
async function dropPostgresTableAsync(knex: Knex): Promise<void> {
if (await knex.schema.hasTable('testentities')) {
await knex.schema.dropTable('testentities');
}
}
describe('Entity cache inconsistency', () => {
let postgresContainer: StartedPostgreSqlContainer;
let redisContainer: StartedRedisContainer;
let knexInstance: Knex;
const redisClient = new Redis(new URL(process.env['REDIS_URL']!).toString());
let redisClient: Redis;
let genericRedisCacheContext: GenericRedisCacheContext;
beforeAll(() => {
knexInstance = knex({
client: 'pg',
connection: {
user: nullthrows(process.env['PGUSER']),
password: nullthrows(process.env['PGPASSWORD']),
host: 'localhost',
port: parseInt(nullthrows(process.env['PGPORT']), 10),
database: nullthrows(process.env['PGDATABASE']),
},
});
beforeAll(async () => {
({ knexInstance, redisClient, postgresContainer, redisContainer } = await startServicesAsync());
genericRedisCacheContext = {
redisClient,
makeKeyFn(...parts: string[]): string {
@@ -137,9 +127,10 @@ describe('Entity cache inconsistency', () => {
});
afterAll(async () => {
await dropPostgresTableAsync(knexInstance);
await knexInstance.destroy();
redisClient.disconnect();
await postgresContainer.stop();
await redisContainer.stop();
});
test('lots of updates in long-ish running transactions', async () => {
@@ -1,23 +1,26 @@
import {
EntityPrivacyPolicy,
ViewerContext,
AlwaysAllowPrivacyPolicyRule,
Entity,
EntityCompanionDefinition,
EntityConfiguration,
EntityPrivacyPolicy,
StringField,
UUIDField,
ViewerContext,
} from '@expo/entity';
import {
GenericRedisCacheContext,
RedisCacheInvalidationStrategy,
} from '@expo/entity-cache-adapter-redis';
import { afterAll, beforeAll, beforeEach, describe, expect, it } from '@jest/globals';
import Redis from 'ioredis';
import { knex, Knex } from 'knex';
import nullthrows from 'nullthrows';
import { URL } from 'url';
import { afterEach, beforeEach, describe, expect, it } from '@jest/globals';
import {
type Knex,
type Redis,
type StartedPostgreSqlContainer,
type StartedRedisContainer,
startServicesAsync,
} from './testcontainer';
import { createFullIntegrationTestEntityCompanionProvider } from '../__testfixtures__/createFullIntegrationTestEntityCompanionProvider';
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
@@ -91,29 +94,25 @@ async function createOrTruncatePostgresTablesAsync(knex: Knex): Promise<void> {
await knex.into('testentities').truncate();
}
async function dropPostgresTableAsync(knex: Knex): Promise<void> {
if (await knex.schema.hasTable('testentities')) {
await knex.schema.dropTable('testentities');
}
}
describe('Lack of entity cache push safety with RedisCacheInvalidationStrategy.CURRENT_CACHE_KEY_VERSION', () => {
describe('Entity cache push safety', () => {
let postgresContainer: StartedPostgreSqlContainer;
let redisContainer: StartedRedisContainer;
let knexInstance: Knex;
const redisClient = new Redis(new URL(process.env['REDIS_URL']!).toString());
let genericRedisCacheContext: GenericRedisCacheContext;
let redisClient: Redis;
beforeEach(async () => {
({ knexInstance, redisClient, postgresContainer, redisContainer } = await startServicesAsync());
await createOrTruncatePostgresTablesAsync(knexInstance);
});
beforeAll(() => {
knexInstance = knex({
client: 'pg',
connection: {
user: nullthrows(process.env['PGUSER']),
password: nullthrows(process.env['PGPASSWORD']),
host: 'localhost',
port: parseInt(nullthrows(process.env['PGPORT']), 10),
database: nullthrows(process.env['PGDATABASE']),
},
});
genericRedisCacheContext = {
afterEach(async () => {
await knexInstance.destroy();
redisClient.disconnect();
await postgresContainer.stop();
await redisContainer.stop();
});
it('is absent with RedisCacheInvalidationStrategy.CURRENT_CACHE_KEY_VERSION', async () => {
const genericRedisCacheContext: GenericRedisCacheContext = {
redisClient,
makeKeyFn(...parts: string[]): string {
const delimiter = ':';
@@ -129,20 +128,6 @@ describe('Lack of entity cache push safety with RedisCacheInvalidationStrategy.C
invalidationStrategy: RedisCacheInvalidationStrategy.CURRENT_CACHE_KEY_VERSION,
},
};
});
beforeEach(async () => {
await createOrTruncatePostgresTablesAsync(knexInstance);
await redisClient.flushdb();
});
afterAll(async () => {
await dropPostgresTableAsync(knexInstance);
await knexInstance.destroy();
redisClient.disconnect();
});
it('does not cross-invalidates cache and thus has cache inconsistency', async () => {
const preDeployCacheKeyVersion = 1;
const newCacheKeyVersion = 2;
@@ -187,25 +172,8 @@ describe('Lack of entity cache push safety with RedisCacheInvalidationStrategy.C
// in a consistent system this would be req2. This fails due to lack of cross-invalidation since the cache for pre-deploy is stale and wasn't invalidated.
expect(entityReq3.getField('string_field')).toBe('req1');
});
});
describe('Entity cache push safety with RedisCacheInvalidationStrategy.SURROUNDING_CACHE_KEY_VERSIONS', () => {
let knexInstance: Knex;
const redisClient = new Redis(new URL(process.env['REDIS_URL']!).toString());
let genericRedisCacheContext: GenericRedisCacheContext;
beforeAll(() => {
knexInstance = knex({
client: 'pg',
connection: {
user: nullthrows(process.env['PGUSER']),
password: nullthrows(process.env['PGPASSWORD']),
host: 'localhost',
port: parseInt(nullthrows(process.env['PGPORT']), 10),
database: nullthrows(process.env['PGDATABASE']),
},
});
genericRedisCacheContext = {
it('is present with RedisCacheInvalidationStrategy.SURROUNDING_CACHE_KEY_VERSION', async () => {
const genericRedisCacheContext: GenericRedisCacheContext = {
redisClient,
makeKeyFn(...parts: string[]): string {
const delimiter = ':';
@@ -221,20 +189,6 @@ describe('Entity cache push safety with RedisCacheInvalidationStrategy.SURROUNDI
invalidationStrategy: RedisCacheInvalidationStrategy.SURROUNDING_CACHE_KEY_VERSIONS,
},
};
});
beforeEach(async () => {
await createOrTruncatePostgresTablesAsync(knexInstance);
await redisClient.flushdb();
});
afterAll(async () => {
await dropPostgresTableAsync(knexInstance);
await knexInstance.destroy();
redisClient.disconnect();
});
it('correctly cross-invalidates cache with new and old code (cache key) versions', async () => {
const preDeployCacheKeyVersion = 1;
const newCacheKeyVersion = 2;
@@ -4,14 +4,17 @@ import {
RedisCacheInvalidationStrategy,
} from '@expo/entity-cache-adapter-redis';
import { afterAll, beforeAll, beforeEach, describe, expect, it } from '@jest/globals';
import Redis from 'ioredis';
import { knex, Knex } from 'knex';
import nullthrows from 'nullthrows';
import { URL } from 'url';
import { createFullIntegrationTestEntityCompanionProvider } from '../__testfixtures__/createFullIntegrationTestEntityCompanionProvider';
import ChildEntity from './entities/ChildEntity';
import ParentEntity from './entities/ParentEntity';
import { createFullIntegrationTestEntityCompanionProvider } from '../__testfixtures__/createFullIntegrationTestEntityCompanionProvider';
import {
type Knex,
type Redis,
type StartedPostgreSqlContainer,
type StartedRedisContainer,
startServicesAsync,
} from './testcontainer';
async function createOrTruncatePostgresTablesAsync(knex: Knex): Promise<void> {
await knex.schema.createTable('parents', (table) => {
@@ -36,21 +39,14 @@ async function dropPostgresTableAsync(knex: Knex): Promise<void> {
}
describe('EntityMutator.processEntityDeletionForInboundEdgesAsync', () => {
let postgresContainer: StartedPostgreSqlContainer;
let redisContainer: StartedRedisContainer;
let knexInstance: Knex;
const redisClient = new Redis(new URL(process.env['REDIS_URL']!).toString());
let redisClient: Redis;
let genericRedisCacheContext: GenericRedisCacheContext;
beforeAll(() => {
knexInstance = knex({
client: 'pg',
connection: {
user: nullthrows(process.env['PGUSER']),
password: nullthrows(process.env['PGPASSWORD']),
host: 'localhost',
port: parseInt(nullthrows(process.env['PGPORT']), 10),
database: nullthrows(process.env['PGDATABASE']),
},
});
beforeAll(async () => {
({ knexInstance, redisClient, postgresContainer, redisContainer } = await startServicesAsync());
genericRedisCacheContext = {
redisClient,
makeKeyFn(...parts: string[]): string {
@@ -78,6 +74,8 @@ describe('EntityMutator.processEntityDeletionForInboundEdgesAsync', () => {
await dropPostgresTableAsync(knexInstance);
await knexInstance.destroy();
redisClient.disconnect();
await postgresContainer.stop();
await redisContainer.stop();
});
describe('EntityEdgeDeletionBehavior.INVALIDATE_CACHE', () => {
@@ -1,23 +1,26 @@
import {
EntityPrivacyPolicy,
ViewerContext,
AlwaysAllowPrivacyPolicyRule,
Entity,
EntityCompanionDefinition,
EntityConfiguration,
EntityPrivacyPolicy,
UUIDField,
ViewerContext,
} from '@expo/entity';
import {
GenericRedisCacheContext,
RedisCacheInvalidationStrategy,
} from '@expo/entity-cache-adapter-redis';
import { afterAll, beforeAll, beforeEach, describe, expect, test } from '@jest/globals';
import Redis from 'ioredis';
import { knex, Knex } from 'knex';
import nullthrows from 'nullthrows';
import { URL } from 'url';
import { v4 as uuidv4 } from 'uuid';
import {
type Knex,
type Redis,
type StartedPostgreSqlContainer,
type StartedRedisContainer,
startServicesAsync,
} from './testcontainer';
import { createFullIntegrationTestEntityCompanionProvider } from '../__testfixtures__/createFullIntegrationTestEntityCompanionProvider';
interface TestFields {
@@ -87,21 +90,14 @@ async function dropPostgresTableAsync(knex: Knex): Promise<void> {
}
describe('Entity integrity', () => {
let postgresContainer: StartedPostgreSqlContainer;
let redisContainer: StartedRedisContainer;
let knexInstance: Knex;
const redisClient = new Redis(new URL(process.env['REDIS_URL']!).toString());
let redisClient: Redis;
let genericRedisCacheContext: GenericRedisCacheContext;
beforeAll(() => {
knexInstance = knex({
client: 'pg',
connection: {
user: nullthrows(process.env['PGUSER']),
password: nullthrows(process.env['PGPASSWORD']),
host: 'localhost',
port: parseInt(nullthrows(process.env['PGPORT']), 10),
database: nullthrows(process.env['PGDATABASE']),
},
});
beforeAll(async () => {
({ knexInstance, redisClient, postgresContainer, redisContainer } = await startServicesAsync());
genericRedisCacheContext = {
redisClient,
makeKeyFn(...parts: string[]): string {
@@ -129,6 +125,8 @@ describe('Entity integrity', () => {
await dropPostgresTableAsync(knexInstance);
await knexInstance.destroy();
redisClient.disconnect();
await postgresContainer.stop();
await redisContainer.stop();
});
test('cannot update ID', async () => {
@@ -1,24 +1,27 @@
import {
EntityPrivacyPolicy,
ViewerContext,
AlwaysAllowPrivacyPolicyRule,
Entity,
EntityCompanionDefinition,
EntityConfiguration,
UUIDField,
EntityEdgeDeletionBehavior,
EntityPrivacyPolicy,
UUIDField,
ViewerContext,
} from '@expo/entity';
import {
GenericRedisCacheContext,
RedisCacheInvalidationStrategy,
} from '@expo/entity-cache-adapter-redis';
import { afterAll, beforeAll, describe, expect, it } from '@jest/globals';
import Redis from 'ioredis';
import { knex, Knex } from 'knex';
import nullthrows from 'nullthrows';
import { URL } from 'url';
import { v4 as uuidv4 } from 'uuid';
import {
type Knex,
type Redis,
type StartedPostgreSqlContainer,
type StartedRedisContainer,
startServicesAsync,
} from './testcontainer';
import { createFullIntegrationTestEntityCompanionProvider } from '../__testfixtures__/createFullIntegrationTestEntityCompanionProvider';
interface CategoryFields {
@@ -175,21 +178,14 @@ const makeEntityClasses = async (knex: Knex, edgeDeletionBehavior: EntityEdgeDel
return { CategoryEntity, OtherEntity };
};
describe('EntityMutator.processEntityDeletionForInboundEdgesAsync', () => {
let postgresContainer: StartedPostgreSqlContainer;
let redisContainer: StartedRedisContainer;
let knexInstance: Knex;
const redisClient = new Redis(new URL(process.env['REDIS_URL']!).toString());
let redisClient: Redis;
let genericRedisCacheContext: GenericRedisCacheContext;
beforeAll(() => {
knexInstance = knex({
client: 'pg',
connection: {
user: nullthrows(process.env['PGUSER']),
password: nullthrows(process.env['PGPASSWORD']),
host: 'localhost',
port: parseInt(nullthrows(process.env['PGPORT']), 10),
database: nullthrows(process.env['PGDATABASE']),
},
});
beforeAll(async () => {
({ knexInstance, redisClient, postgresContainer, redisContainer } = await startServicesAsync());
genericRedisCacheContext = {
redisClient,
makeKeyFn(...parts: string[]): string {
@@ -211,6 +207,8 @@ describe('EntityMutator.processEntityDeletionForInboundEdgesAsync', () => {
afterAll(async () => {
await knexInstance.destroy();
redisClient.disconnect();
await postgresContainer.stop();
await redisContainer.stop();
});
it.each([
@@ -0,0 +1,27 @@
import { PostgreSqlContainer, type StartedPostgreSqlContainer } from '@testcontainers/postgresql';
import { RedisContainer, type StartedRedisContainer } from '@testcontainers/redis';
import { Redis } from 'ioredis';
import { knex, type Knex } from 'knex';
export type { StartedPostgreSqlContainer } from '@testcontainers/postgresql';
export type { StartedRedisContainer } from '@testcontainers/redis';
export type { Redis } from 'ioredis';
export type { Knex } from 'knex';
export async function startServicesAsync(): Promise<{
knexInstance: Knex<any, unknown[]>;
redisClient: Redis;
postgresContainer: StartedPostgreSqlContainer;
redisContainer: StartedRedisContainer;
}> {
const postgresContainer = await new PostgreSqlContainer('postgres:14').start();
const knexInstance = knex({
client: 'pg',
connection: {
connectionString: postgresContainer.getConnectionUri(),
},
});
const redisContainer = await new RedisContainer('redis').start();
const redisClient = new Redis(redisContainer.getConnectionUrl());
return { knexInstance, redisClient, postgresContainer, redisContainer };
}
@@ -13,7 +13,7 @@
"clean": "yarn build --clean",
"lint": "yarn run --top-level eslint src",
"lint-fix": "yarn lint --fix",
"test": "yarn test:all --rootDir $(pwd)"
"test": "yarn test:unit --roots $(pwd)"
},
"engines": {
"node": ">=16"
@@ -13,8 +13,7 @@
"clean": "yarn build --clean",
"lint": "yarn run --top-level eslint src",
"lint-fix": "yarn run lint --fix",
"test": "yarn test:all --rootDir $(pwd)",
"integration": "yarn integration:all --rootDir $(pwd)"
"test": "yarn test:unit --roots $(pwd)"
},
"engines": {
"node": ">=16"
@@ -13,7 +13,7 @@
"clean": "yarn build --clean",
"lint": "yarn run --top-level eslint src",
"lint-fix": "yarn run lint --fix",
"integration": "yarn integration:all --rootDir $(pwd)"
"integration": "yarn test:integration --roots $(pwd)"
},
"engines": {
"node": ">=16"
@@ -4,9 +4,9 @@ import {
RedisCacheInvalidationStrategy,
} from '@expo/entity-cache-adapter-redis';
import { afterAll, beforeAll, beforeEach, describe, expect, it } from '@jest/globals';
import Redis from 'ioredis';
import { RedisContainer, StartedRedisContainer } from '@testcontainers/redis';
import { Redis } from 'ioredis';
import nullthrows from 'nullthrows';
import { URL } from 'url';
import { RedisSecondaryEntityCache } from '../RedisSecondaryEntityCache';
import {
@@ -57,10 +57,13 @@ class TestSecondaryRedisCacheLoader extends EntitySecondaryCacheLoader<
}
describe(RedisSecondaryEntityCache, () => {
const redisClient = new Redis(new URL(process.env['REDIS_URL']!).toString());
let container: StartedRedisContainer;
let redisClient: Redis;
let genericRedisCacheContext: GenericRedisCacheContext;
beforeAll(() => {
beforeAll(async () => {
container = await new RedisContainer('redis').start();
redisClient = new Redis(container.getConnectionUrl());
genericRedisCacheContext = {
redisClient,
makeKeyFn(..._parts: string[]): string {
@@ -80,6 +83,7 @@ describe(RedisSecondaryEntityCache, () => {
});
afterAll(async () => {
redisClient.disconnect();
await container.stop();
});
it('Loads through secondary loader, caches, and invalidates', async () => {
+1 -1
View File
@@ -13,7 +13,7 @@
"clean": "yarn build --clean",
"lint": "yarn run --top-level eslint src",
"lint-fix": "yarn run lint --fix",
"test": "yarn test:all --rootDir $(pwd)"
"test": "yarn test:unit --roots $(pwd)"
},
"engines": {
"node": ">=16"
+1 -1
View File
@@ -13,7 +13,7 @@
"clean": "yarn build --clean",
"lint": "yarn run --top-level eslint src",
"lint-fix": "yarn run lint --fix",
"test": "yarn test:all --rootDir $(pwd)"
"test": "yarn test:unit --roots $(pwd)"
},
"engines": {
"node": ">=16"
File renamed without changes.
-22
View File
@@ -1,22 +0,0 @@
version: '2'
services:
redis:
image: redis
ports:
- "${REDIS_PORT}:6379"
volumes:
- redis_data:/data
postgres:
image: postgres:14
ports:
- "${PGPORT}:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
environment:
POSTGRES_USER: "${PGUSER}"
POSTGRES_PASSWORD: "${PGPASSWORD}"
POSTGRES_DB: "${PGDATABASE}"
volumes:
postgres_data: {}
redis_data: {}
-23
View File
@@ -1,23 +0,0 @@
#!/usr/bin/env bash
set -e
DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
# shellcheck disable=SC1091
# shellcheck source=test-env.sh
source "$DIR/test-env.sh"
compose_down() {
docker compose -f "$DIR/docker-compose.yml" down -v
}
docker compose -f "$DIR/docker-compose.yml" up -d
trap compose_down EXIT SIGTERM
docker compose -f "$DIR/docker-compose.yml" logs --follow --timestamps &
sleep 5
"$@"
-11
View File
@@ -1,11 +0,0 @@
#!/usr/bin/env bash
export NODE_ENV=test
export REDIS_PORT="16379"
export REDIS_URL="redis://localhost:${REDIS_PORT}/0"
export PGUSER="postgresuser"
export PGPASSWORD="handkerchief-break-popular-population"
export PGDATABASE="test"
export PGPORT="25432"
+8
View File
@@ -0,0 +1,8 @@
import { getContainerRuntimeClient, ImageName } from 'testcontainers';
export default async function (globalConfig, projectConfig) {
const client = await getContainerRuntimeClient();
for (const i of ['redis', 'postgres:14', 'testcontainers/ryuk:0.11.0']) {
await client.image.pull(ImageName.fromString(i));
}
}
Generated
+753 -11
View File
File diff suppressed because it is too large. Load diff