diff --git a/.eslintrc.js b/.eslintrc.js index 140d1c8bef..e1e8c89848 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -9,7 +9,19 @@ const defaultRules = { 'error', { blankLine: 'always', - prev: ['block', 'block-like', 'cjs-export', 'class', 'export', 'import'], + prev: [ + 'block', + 'block-like', + 'cjs-export', + 'class', + 'export', + 'import', + 'multiline-block-like', + 'multiline-const', + 'multiline-expression', + 'multiline-let', + 'multiline-var', + ], next: '*', }, { @@ -17,6 +29,11 @@ const defaultRules = { prev: ['const', 'let'], next: ['block', 'block-like', 'cjs-export', 'class', 'export', 'import'], }, + { + blankLine: 'always', + prev: '*', + next: ['multiline-block-like', 'multiline-const', 'multiline-expression', 'multiline-let', 'multiline-var'], + }, { blankLine: 'any', prev: ['export', 'import'], next: ['export', 'import'] }, ], 'lines-between-class-members': ['error', 'always', { exceptAfterSingleLine: true }], diff --git a/api/src/app.test.ts b/api/src/app.test.ts index d68fbf6be2..2c9bcc90b4 100644 --- a/api/src/app.test.ts +++ b/api/src/app.test.ts @@ -14,6 +14,7 @@ vi.mock('./database', () => ({ vi.mock('./env', async () => { const actual = (await vi.importActual('./env')) as { default: Record }; + const MOCK_ENV = { ...actual.default, KEY: 'xxxxxxx-xxxxxx-xxxxxxxx-xxxxxxxxxx', @@ -170,9 +171,11 @@ describe('createApp', async () => { const testRoute = '/custom-endpoint-to-test'; const testResponse = { key: 'value' }; const mockRouter = Router(); + mockRouter.use(testRoute, (_, res) => { res.json(testResponse); }); + mockGetEndpointRouter.mockReturnValueOnce(mockRouter); const app = await createApp(); diff --git a/api/src/app.ts b/api/src/app.ts index eb78e5a3eb..25a1ef1653 100644 --- a/api/src/app.ts +++ b/api/src/app.ts @@ -194,6 +194,7 @@ export default async function createApp(): Promise { // Set the App's base path according to the APIs public URL const html = await readFile(adminPath, 'utf8'); + const htmlWithVars = html .replace(//, ``) .replace(//, embeds.head) diff --git a/api/src/auth/drivers/ldap.ts b/api/src/auth/drivers/ldap.ts index 98e4377dd7..d84e9338be 100644 --- a/api/src/auth/drivers/ldap.ts +++ b/api/src/auth/drivers/ldap.ts @@ -64,9 +64,11 @@ export class LDAPAuthDriver extends AuthDriver { const clientConfig = typeof config['client'] === 'object' ? config['client'] : {}; this.bindClient = ldap.createClient({ url: clientUrl, reconnect: true, ...clientConfig }); + this.bindClient.on('error', (err: Error) => { logger.warn(err); }); + this.usersService = new UsersService({ knex: this.knex, schema: this.schema }); this.config = config; } @@ -332,6 +334,7 @@ export class LDAPAuthDriver extends AuthDriver { return new Promise((resolve, reject) => { const clientConfig = typeof this.config['client'] === 'object' ? this.config['client'] : {}; + const client = ldap.createClient({ url: this.config['clientUrl'], ...clientConfig, diff --git a/api/src/auth/drivers/oauth2.ts b/api/src/auth/drivers/oauth2.ts index 40ecea8c2a..790545a141 100644 --- a/api/src/auth/drivers/oauth2.ts +++ b/api/src/auth/drivers/oauth2.ts @@ -121,6 +121,7 @@ export class OAuth2AuthDriver extends LocalAuthDriver { { code: payload['code'], state: payload['state'] }, { code_verifier: payload['codeVerifier'], state: generators.codeChallenge(payload['codeVerifier']) } ); + userInfo = await this.client.userinfo(tokenSet.access_token!); } catch (e) { throw handleError(e); @@ -270,6 +271,7 @@ export function createOAuth2AuthRouter(providerName: string): Router { const provider = getAuthProvider(providerName) as OAuth2AuthDriver; const codeVerifier = provider.generateCodeVerifier(); const prompt = !!req.query['prompt']; + const token = jwt.sign( { verifier: codeVerifier, redirect: req.query['redirect'], prompt }, env['SECRET'] as string, @@ -297,6 +299,7 @@ export function createOAuth2AuthRouter(providerName: string): Router { }, respond ); + router.get( '/callback', asyncHandler(async (req, res, next) => { @@ -337,6 +340,7 @@ export function createOAuth2AuthRouter(providerName: string): Router { try { res.clearCookie(`oauth2.${providerName}`); + authResponse = await authenticationService.login(providerName, { code: req.query['code'], codeVerifier: verifier, diff --git a/api/src/auth/drivers/openid.ts b/api/src/auth/drivers/openid.ts index 998ee6f493..eb41d7046a 100644 --- a/api/src/auth/drivers/openid.ts +++ b/api/src/auth/drivers/openid.ts @@ -55,6 +55,7 @@ export class OpenIDAuthDriver extends LocalAuthDriver { this.redirectUrl = redirectUrl.toString(); this.usersService = new UsersService({ knex: this.knex, schema: this.schema }); this.config = additionalConfig; + this.client = new Promise((resolve, reject) => { Issuer.discover(issuerUrl) .then((issuer) => { @@ -133,11 +134,13 @@ export class OpenIDAuthDriver extends LocalAuthDriver { try { const client = await this.client; const codeChallenge = generators.codeChallenge(payload['codeVerifier']); + tokenSet = await client.callback( this.redirectUrl, { code: payload['code'], state: payload['state'], iss: payload['iss'] }, { code_verifier: payload['codeVerifier'], state: codeChallenge, nonce: codeChallenge } ); + userInfo = tokenSet.claims(); if (client.issuer.metadata['userinfo_endpoint']) { @@ -297,6 +300,7 @@ export function createOpenIDAuthRouter(providerName: string): Router { const provider = getAuthProvider(providerName) as OpenIDAuthDriver; const codeVerifier = provider.generateCodeVerifier(); const prompt = !!req.query['prompt']; + const token = jwt.sign( { verifier: codeVerifier, redirect: req.query['redirect'], prompt }, env['SECRET'] as string, @@ -365,6 +369,7 @@ export function createOpenIDAuthRouter(providerName: string): Router { try { res.clearCookie(`openid.${providerName}`); + authResponse = await authenticationService.login(providerName, { code: req.query['code'], codeVerifier: verifier, diff --git a/api/src/cli/commands/schema/apply.ts b/api/src/cli/commands/schema/apply.ts index fa6cbc1802..b676aa31c1 100644 --- a/api/src/cli/commands/schema/apply.ts +++ b/api/src/cli/commands/schema/apply.ts @@ -137,6 +137,7 @@ export async function apply(snapshotPath: string, options?: { yes: boolean; dryR } message = 'The following changes will be applied:\n\n' + chalk.black(message); + if (dryRun) { logger.info(message); process.exit(0); diff --git a/api/src/cli/index.ts b/api/src/cli/index.ts index ba40bfddd2..0f2ebecb67 100644 --- a/api/src/cli/index.ts +++ b/api/src/cli/index.ts @@ -38,14 +38,17 @@ export async function createCli(): Promise { const dbCommand = program.command('database'); dbCommand.command('install').description('Install the database').action(dbInstall); + dbCommand .command('migrate:latest') .description('Upgrade the database') .action(() => dbMigrate('latest')); + dbCommand .command('migrate:up') .description('Upgrade the database') .action(() => dbMigrate('up')); + dbCommand .command('migrate:down') .description('Downgrade the database') @@ -69,6 +72,7 @@ export async function createCli(): Promise { .action(usersPasswd); const rolesCommand = program.command('roles'); + rolesCommand .command('create') .description('Create a new role') diff --git a/api/src/controllers/assets.ts b/api/src/controllers/assets.ts index 6319d70d7a..36a19915d4 100644 --- a/api/src/controllers/assets.ts +++ b/api/src/controllers/assets.ts @@ -29,6 +29,7 @@ router.get( const defaults = { storage_asset_presets: [], storage_asset_transform: 'all' }; const database = getDatabase(); + const savedAssetSettings = await database .select('storage_asset_presets', 'storage_asset_transform') .from('directus_settings') @@ -81,6 +82,7 @@ router.get( } const systemKeys = SYSTEM_ASSET_ALLOW_LIST.map((transformation) => transformation['key']!); + const allKeys: string[] = [ ...systemKeys, ...(assetSettings.storage_asset_presets || []).map( diff --git a/api/src/controllers/auth.ts b/api/src/controllers/auth.ts index e7f3ec8080..32370ecb1d 100644 --- a/api/src/controllers/auth.ts +++ b/api/src/controllers/auth.ts @@ -218,6 +218,7 @@ router.get( data: getAuthProviders(), disableDefault: env['AUTH_DISABLE_DEFAULT'], }; + return next(); }), respond diff --git a/api/src/controllers/dashboards.ts b/api/src/controllers/dashboards.ts index eb89d30aba..f93bb6c63c 100644 --- a/api/src/controllers/dashboards.ts +++ b/api/src/controllers/dashboards.ts @@ -57,6 +57,7 @@ const readHandler = asyncHandler(async (req, res, next) => { accountability: req.accountability, schema: req.schema, }); + const metaService = new MetaService({ accountability: req.accountability, schema: req.schema, diff --git a/api/src/controllers/extensions.ts b/api/src/controllers/extensions.ts index e8a695bbfa..c7cab92b85 100644 --- a/api/src/controllers/extensions.ts +++ b/api/src/controllers/extensions.ts @@ -53,10 +53,12 @@ router.get( } res.setHeader('Content-Type', 'application/javascript; charset=UTF-8'); + res.setHeader( 'Cache-Control', getCacheControlHeader(req, getMilliseconds(env['EXTENSIONS_CACHE_TTL']), false, false) ); + res.setHeader('Vary', 'Origin, Cache-Control'); res.end(source); }) diff --git a/api/src/controllers/fields.ts b/api/src/controllers/fields.ts index 4073762ea9..871bc3e457 100644 --- a/api/src/controllers/fields.ts +++ b/api/src/controllers/fields.ts @@ -21,6 +21,7 @@ router.get( accountability: req.accountability, schema: req.schema, }); + const fields = await service.readAll(); res.locals['payload'] = { data: fields || null }; @@ -37,6 +38,7 @@ router.get( accountability: req.accountability, schema: req.schema, }); + const fields = await service.readAll(req.params['collection']); res.locals['payload'] = { data: fields || null }; @@ -215,6 +217,7 @@ router.delete( accountability: req.accountability, schema: req.schema, }); + await service.deleteField(req.params['collection']!, req.params['field']!); return next(); }), diff --git a/api/src/controllers/files.test.ts b/api/src/controllers/files.test.ts index b89e5f8c54..cccec36831 100644 --- a/api/src/controllers/files.test.ts +++ b/api/src/controllers/files.test.ts @@ -28,6 +28,7 @@ describe('multipartHandler', () => { params: {}, pipe: (input: NodeJS.WritableStream) => stream.pipe(input), } as unknown as Request; + const res = {} as Response; const stream = new PassThrough(); @@ -56,6 +57,7 @@ describe('multipartHandler', () => { params: {}, pipe: (input: NodeJS.WritableStream) => stream.pipe(input), } as unknown as Request; + const res = {} as Response; const stream = new PassThrough(); diff --git a/api/src/controllers/files.ts b/api/src/controllers/files.ts index 614b7219a4..a6a85b4308 100644 --- a/api/src/controllers/files.ts +++ b/api/src/controllers/files.ts @@ -128,6 +128,7 @@ router.post( accountability: req.accountability, schema: req.schema, }); + let keys: PrimaryKey | PrimaryKey[] = []; if (req.is('multipart/form-data')) { diff --git a/api/src/controllers/flows.ts b/api/src/controllers/flows.ts index 86e7a27a48..e92ac306c9 100644 --- a/api/src/controllers/flows.ts +++ b/api/src/controllers/flows.ts @@ -84,6 +84,7 @@ const readHandler = asyncHandler(async (req, res, next) => { accountability: req.accountability, schema: req.schema, }); + const metaService = new MetaService({ accountability: req.accountability, schema: req.schema, diff --git a/api/src/controllers/folders.ts b/api/src/controllers/folders.ts index eeb20d5e4f..79b6625500 100644 --- a/api/src/controllers/folders.ts +++ b/api/src/controllers/folders.ts @@ -57,6 +57,7 @@ const readHandler = asyncHandler(async (req, res, next) => { accountability: req.accountability, schema: req.schema, }); + const metaService = new MetaService({ accountability: req.accountability, schema: req.schema, @@ -88,6 +89,7 @@ router.get( accountability: req.accountability, schema: req.schema, }); + const record = await service.readOne(req.params['pk']!, req.sanitizedQuery); res.locals['payload'] = { data: record || null }; diff --git a/api/src/controllers/notifications.ts b/api/src/controllers/notifications.ts index 69579caee2..77e66dc01d 100644 --- a/api/src/controllers/notifications.ts +++ b/api/src/controllers/notifications.ts @@ -57,6 +57,7 @@ const readHandler = asyncHandler(async (req, res, next) => { accountability: req.accountability, schema: req.schema, }); + const metaService = new MetaService({ accountability: req.accountability, schema: req.schema, diff --git a/api/src/controllers/operations.ts b/api/src/controllers/operations.ts index f1ce31ce84..71f96a5c30 100644 --- a/api/src/controllers/operations.ts +++ b/api/src/controllers/operations.ts @@ -57,6 +57,7 @@ const readHandler = asyncHandler(async (req, res, next) => { accountability: req.accountability, schema: req.schema, }); + const metaService = new MetaService({ accountability: req.accountability, schema: req.schema, diff --git a/api/src/controllers/panels.ts b/api/src/controllers/panels.ts index 7740f8b7df..65ffff1294 100644 --- a/api/src/controllers/panels.ts +++ b/api/src/controllers/panels.ts @@ -57,6 +57,7 @@ const readHandler = asyncHandler(async (req, res, next) => { accountability: req.accountability, schema: req.schema, }); + const metaService = new MetaService({ accountability: req.accountability, schema: req.schema, diff --git a/api/src/controllers/permissions.ts b/api/src/controllers/permissions.ts index a349b8bc82..7329d53c3f 100644 --- a/api/src/controllers/permissions.ts +++ b/api/src/controllers/permissions.ts @@ -86,6 +86,7 @@ router.get( '/:pk', asyncHandler(async (req, res, next) => { if (req.path.endsWith('me')) return next(); + const service = new PermissionsService({ accountability: req.accountability, schema: req.schema, diff --git a/api/src/controllers/presets.ts b/api/src/controllers/presets.ts index 00fa5f9d74..c00c14cadb 100644 --- a/api/src/controllers/presets.ts +++ b/api/src/controllers/presets.ts @@ -57,6 +57,7 @@ const readHandler = asyncHandler(async (req, res, next) => { accountability: req.accountability, schema: req.schema, }); + const metaService = new MetaService({ accountability: req.accountability, schema: req.schema, diff --git a/api/src/controllers/relations.ts b/api/src/controllers/relations.ts index b8be681f78..3cac5a8519 100644 --- a/api/src/controllers/relations.ts +++ b/api/src/controllers/relations.ts @@ -34,6 +34,7 @@ router.get( accountability: req.accountability, schema: req.schema, }); + const relations = await service.readAll(req.params['collection']); res.locals['payload'] = { data: relations || null }; @@ -156,6 +157,7 @@ router.delete( accountability: req.accountability, schema: req.schema, }); + await service.deleteOne(req.params['collection']!, req.params['field']!); return next(); }), diff --git a/api/src/controllers/revisions.ts b/api/src/controllers/revisions.ts index 805fca5bb1..ae8a87c6c6 100644 --- a/api/src/controllers/revisions.ts +++ b/api/src/controllers/revisions.ts @@ -15,6 +15,7 @@ const readHandler = asyncHandler(async (req, res, next) => { accountability: req.accountability, schema: req.schema, }); + const metaService = new MetaService({ accountability: req.accountability, schema: req.schema, diff --git a/api/src/controllers/server.ts b/api/src/controllers/server.ts index c8d65be90e..e2b61121fd 100644 --- a/api/src/controllers/server.ts +++ b/api/src/controllers/server.ts @@ -55,6 +55,7 @@ router.get( accountability: req.accountability, schema: req.schema, }); + const data = await service.serverInfo(); res.locals['payload'] = { data }; return next(); diff --git a/api/src/controllers/settings.ts b/api/src/controllers/settings.ts index 6ab77754c1..464f51224c 100644 --- a/api/src/controllers/settings.ts +++ b/api/src/controllers/settings.ts @@ -16,6 +16,7 @@ router.get( accountability: req.accountability, schema: req.schema, }); + const records = await service.readSingleton(req.sanitizedQuery); res.locals['payload'] = { data: records || null }; return next(); @@ -30,6 +31,7 @@ router.patch( accountability: req.accountability, schema: req.schema, }); + await service.upsertSingleton(req.body); try { diff --git a/api/src/controllers/users.ts b/api/src/controllers/users.ts index d61895aa10..f2aa8f2adc 100644 --- a/api/src/controllers/users.ts +++ b/api/src/controllers/users.ts @@ -90,6 +90,7 @@ router.get( app_access: false, }, }; + res.locals['payload'] = { data: user }; return next(); } @@ -124,6 +125,7 @@ router.get( '/:pk', asyncHandler(async (req, res, next) => { if (req.path.endsWith('me')) return next(); + const service = new UsersService({ accountability: req.accountability, schema: req.schema, @@ -293,6 +295,7 @@ router.post( accountability: req.accountability, schema: req.schema, }); + await service.inviteUser(req.body.email, req.body.role, req.body.invite_url || null); return next(); }), @@ -309,10 +312,12 @@ router.post( asyncHandler(async (req, _res, next) => { const { error } = acceptInviteSchema.validate(req.body); if (error) throw new InvalidPayloadException(error.message); + const service = new UsersService({ accountability: req.accountability, schema: req.schema, }); + await service.acceptInvite(req.body.token, req.body.password); return next(); }), @@ -339,6 +344,7 @@ router.post( accountability: req.accountability, schema: req.schema, }); + await authService.verifyPassword(req.accountability.user, req.body.password); const { url, secret } = await service.generateTFA(req.accountability.user); @@ -369,6 +375,7 @@ router.post( const rolesService = new RolesService({ schema: req.schema, }); + const role = (await rolesService.readOne(req.accountability.role)) as Role; if (role && role.enforce_tfa) { @@ -423,6 +430,7 @@ router.post( const rolesService = new RolesService({ schema: req.schema, }); + const role = (await rolesService.readOne(req.accountability.role)) as Role; if (role && role.enforce_tfa) { diff --git a/api/src/controllers/utils.ts b/api/src/controllers/utils.ts index 53540e9643..70cae488ee 100644 --- a/api/src/controllers/utils.ts +++ b/api/src/controllers/utils.ts @@ -80,6 +80,7 @@ router.post( accountability: req.accountability, schema: req.schema, }); + await service.sort(req.collection, req.body); return res.status(200).end(); @@ -93,6 +94,7 @@ router.post( accountability: req.accountability, schema: req.schema, }); + await service.revert(req.params['revision']!); next(); }), diff --git a/api/src/controllers/webhooks.ts b/api/src/controllers/webhooks.ts index b4157f44ad..3919a20195 100644 --- a/api/src/controllers/webhooks.ts +++ b/api/src/controllers/webhooks.ts @@ -57,6 +57,7 @@ const readHandler = asyncHandler(async (req, res, next) => { accountability: req.accountability, schema: req.schema, }); + const metaService = new MetaService({ accountability: req.accountability, schema: req.schema, diff --git a/api/src/database/helpers/schema/types.ts b/api/src/database/helpers/schema/types.ts index b19bd6aac5..adb5633368 100644 --- a/api/src/database/helpers/schema/types.ts +++ b/api/src/database/helpers/schema/types.ts @@ -132,6 +132,7 @@ export abstract class SchemaHelper extends DatabaseHelper { knex.ref('directus_row_number').toQuery(), knex.raw(`partition by ?? order by ${orderByString}`, [`${table}.${primaryKey}`, ...orderByFields]) ); + return dbQuery; } diff --git a/api/src/database/index.ts b/api/src/database/index.ts index cd0ae7e7de..ca1e46d965 100644 --- a/api/src/database/index.ts +++ b/api/src/database/index.ts @@ -264,6 +264,7 @@ export async function validateMigrations(): Promise { migrationFiles.push(...customMigrationFiles); const requiredVersions = migrationFiles.map((filePath) => filePath.split('-')[0]); + const completedVersions = (await database.select('version').from('directus_migrations')).map( ({ version }) => version ); diff --git a/api/src/database/migrations/20210518A-add-foreign-key-constraints.ts b/api/src/database/migrations/20210518A-add-foreign-key-constraints.ts index 2f3c3f16f0..5162f19c44 100644 --- a/api/src/database/migrations/20210518A-add-foreign-key-constraints.ts +++ b/api/src/database/migrations/20210518A-add-foreign-key-constraints.ts @@ -8,6 +8,7 @@ export async function up(knex: Knex): Promise { const inspector = createInspector(knex); const foreignKeys = await inspector.foreignKeys(); + const relations = await knex .select('id', 'many_collection', 'many_field', 'one_collection') .from('directus_relations'); @@ -16,6 +17,7 @@ export async function up(knex: Knex): Promise { const exists = !!foreignKeys.find( (fk) => fk.table === relation?.many_collection && fk.column === relation?.many_field ); + return exists === false; }); @@ -43,6 +45,7 @@ export async function up(knex: Knex): Promise { logger.warn( `Illegal relationship ${constraint.many_collection}.${constraint.many_field}<->${constraint.one_collection} encountered. Many field equals collections primary key.` ); + corruptedRelations.push(constraint.id); continue; } @@ -101,6 +104,7 @@ export async function up(knex: Knex): Promise { } const indexName = getDefaultIndexName('foreign', constraint.many_collection, constraint.many_field); + const builder = table .foreign(constraint.many_field, indexName) .references(relatedPrimaryKeyField) @@ -115,6 +119,7 @@ export async function up(knex: Knex): Promise { logger.warn( `Couldn't add foreign key constraint for ${constraint.many_collection}.${constraint.many_field}<->${constraint.one_collection}` ); + logger.warn(err); } } @@ -144,6 +149,7 @@ export async function down(knex: Knex): Promise { logger.warn( `Couldn't drop foreign key constraint for ${relation.many_collection}.${relation.many_field}<->${relation.one_collection}` ); + logger.warn(err); } } diff --git a/api/src/database/migrations/20210519A-add-system-fk-triggers.ts b/api/src/database/migrations/20210519A-add-system-fk-triggers.ts index db0d2de068..e4ab42091e 100644 --- a/api/src/database/migrations/20210519A-add-system-fk-triggers.ts +++ b/api/src/database/migrations/20210519A-add-system-fk-triggers.ts @@ -118,6 +118,7 @@ export async function up(knex: Knex): Promise { logger.warn( `Couldn't clean up index for foreign key ${update.table}.${constraint.column}->${constraint.references}` ); + logger.warn(err); } } @@ -160,6 +161,7 @@ export async function down(knex: Knex): Promise { logger.warn( `Couldn't clean up index for foreign key ${update.table}.${constraint.column}->${constraint.references}` ); + logger.warn(err); } } diff --git a/api/src/database/migrations/20210907A-webhooks-collections-not-null.ts b/api/src/database/migrations/20210907A-webhooks-collections-not-null.ts index dbef445222..aabe97ae4b 100644 --- a/api/src/database/migrations/20210907A-webhooks-collections-not-null.ts +++ b/api/src/database/migrations/20210907A-webhooks-collections-not-null.ts @@ -4,6 +4,7 @@ import { getHelpers } from '../helpers/index.js'; export async function up(knex: Knex): Promise { const helper = getHelpers(knex).schema; const type = helper.isOneOfClients(['oracle', 'cockroachdb']) ? 'text' : 'string'; + await helper.changeToType('directus_webhooks', 'collections', type, { nullable: false, }); diff --git a/api/src/database/migrations/run.test.ts b/api/src/database/migrations/run.test.ts index db93afc970..d12cbce16e 100644 --- a/api/src/database/migrations/run.test.ts +++ b/api/src/database/migrations/run.test.ts @@ -26,6 +26,7 @@ describe('run', () => { expect(e.message).toBe('Nothing to upgrade'); }); }); + it('returns "Method implemented in the dialect driver" if no directus_migrations', async () => { tracker.on.select('directus_migrations').response([]); @@ -34,6 +35,7 @@ describe('run', () => { expect(e.message).toBe('Method implemented in the dialect driver'); }); }); + it('returns undefined if the migration is successful', async () => { tracker.on.select('directus_migrations').response([ { @@ -42,12 +44,14 @@ describe('run', () => { timestamp: '2021-11-27 11:36:56.471595-05', }, ]); + tracker.on.delete('directus_relations').response([]); tracker.on.insert('directus_migrations').response(['Remove System Relations', '20201029A']); expect(await run(db, 'up')).toBe(undefined); }); }); + describe('when passed the argument down', () => { it('returns "Nothing To downgrade" if no valid directus_migrations', async () => { tracker.on.select('directus_migrations').response(['Empty']); @@ -57,6 +61,7 @@ describe('run', () => { expect(e.message).toBe(`Couldn't find migration`); }); }); + it('returns "Method implemented in the dialect driver" if no directus_migrations', async () => { tracker.on.select('directus_migrations').response([]); @@ -65,6 +70,7 @@ describe('run', () => { expect(e.message).toBe('Nothing to downgrade'); }); }); + it(`returns "Couldn't find migration" if an invalid migration object is supplied`, async () => { tracker.on.select('directus_migrations').response([ { @@ -73,12 +79,14 @@ describe('run', () => { timestamp: '2020-00-32 11:36:56.471595-05', }, ]); + await run(db, 'down').catch((e: Error) => { expect(e).toBeInstanceOf(Error); expect(e.message).toBe(`Couldn't find migration`); }); }); }); + describe('when passed the argument latest', () => { it('returns "Nothing To downgrade" if no valid directus_migrations', async () => { tracker.on.select('directus_migrations').response(['Empty']); @@ -88,8 +96,10 @@ describe('run', () => { expect(e.message).toBe(`Method implemented in the dialect driver`); }); }); + it('returns "Method implemented in the dialect driver" if no directus_migrations', async () => { tracker.on.select('directus_migrations').response([]); + await run(db, 'latest').catch((e: Error) => { expect(e).toBeInstanceOf(Error); expect(e.message).toBe('Method implemented in the dialect driver'); diff --git a/api/src/database/migrations/run.ts b/api/src/database/migrations/run.ts index fe302aef99..1d5d5d79aa 100644 --- a/api/src/database/migrations/run.ts +++ b/api/src/database/migrations/run.ts @@ -15,6 +15,7 @@ export default async function run(database: Knex, direction: 'up' | 'down' | 'la let migrationFiles = await fse.readdir(__dirname); const customMigrationsPath = path.resolve(env['EXTENSIONS_PATH'], 'migrations'); + let customMigrationFiles = ((await fse.pathExists(customMigrationsPath)) && (await fse.readdir(customMigrationsPath))) || []; diff --git a/api/src/database/run-ast.ts b/api/src/database/run-ast.ts index 05da453f27..e9a8b7f387 100644 --- a/api/src/database/run-ast.ts +++ b/api/src/database/run-ast.ts @@ -335,6 +335,7 @@ async function getDBQuery( sortRecords.map((sortRecord) => { if (sortRecord.column.includes('.')) { const [alias, field] = sortRecord.column.split('.'); + sortRecord.column = getColumn(knex, alias!, field!, false, schema, { originalCollectionName: getCollectionFromAlias(alias!, aliasMap), }) as any; @@ -498,6 +499,7 @@ function mergeWithParentItems( if (a[column] === b[column]) return 0; if (a[column] === null) return 1; if (b[column] === null) return -1; + if (order === 'asc') { return a[column] < b[column] ? -1 : 1; } else { diff --git a/api/src/emitter.ts b/api/src/emitter.ts index 31097ab068..8171564538 100644 --- a/api/src/emitter.ts +++ b/api/src/emitter.ts @@ -29,6 +29,7 @@ export class Emitter { context: EventContext ): Promise { const events = Array.isArray(event) ? event : [event]; + const eventListeners = events.map((event) => ({ event, listeners: this.filterEmitter.listeners(event) as FilterHandler[], diff --git a/api/src/env.ts b/api/src/env.ts index 41a2597c6a..5fa6b392b8 100644 --- a/api/src/env.ts +++ b/api/src/env.ts @@ -443,6 +443,7 @@ function processValues(env: Record) { if (key.length > 5 && key.endsWith('_FILE')) { newKey = key.slice(0, -5); + if (allowedEnvironmentVars.some((pattern) => pattern.test(newKey as string))) { if (newKey in env && !(newKey in defaults && env[newKey] === defaults[newKey])) { throw new Error( diff --git a/api/src/extensions.ts b/api/src/extensions.ts index 4a26497c5f..9d826df760 100644 --- a/api/src/extensions.ts +++ b/api/src/extensions.ts @@ -171,6 +171,7 @@ class ExtensionManager { const added = this.extensions.filter( (extension) => !prevExtensions.some((prevExtension) => extension.path === prevExtension.path) ); + const removed = prevExtensions.filter( (prevExtension) => !this.extensions.some((extension) => prevExtension.path === extension.path) ); @@ -356,6 +357,7 @@ class ExtensionManager { private async generateExtensionBundle(): Promise { const sharedDepsMapping = await this.getSharedDepsMapping(APP_SHARED_DEPS); + const internalImports = Object.entries(sharedDepsMapping).map(([name, path]) => ({ find: name, replacement: path, @@ -370,6 +372,7 @@ class ExtensionManager { makeAbsoluteExternalsRelative: false, plugins: [virtual({ entry: entrypoint }), alias({ entries: internalImports }), nodeResolve({ browser: true })], }); + const { output } = await bundle.generate({ format: 'es', compact: true }); for (const out of output) { @@ -470,6 +473,7 @@ class ExtensionManager { for (const operation of [...internalOperations, ...operations]) { try { const operationPath = path.resolve(operation.path, operation.entrypoint.api!); + const operationInstance: OperationApiConfig | { default: OperationApiConfig } = await import( `file://${operationPath}` ); diff --git a/api/src/flows.ts b/api/src/flows.ts index 604211bef4..85d50a70f3 100644 --- a/api/src/flows.ts +++ b/api/src/flows.ts @@ -181,6 +181,7 @@ class FlowManager { ); events.forEach((event) => emitter.onFilter(event, handler)); + this.triggerHandlers.push({ id: flow.id, events: events.map((event) => ({ type: 'filter', name: event, handler })), @@ -194,6 +195,7 @@ class FlowManager { }); events.forEach((event) => emitter.onAction(event, handler)); + this.triggerHandlers.push({ id: flow.id, events: events.map((event) => ({ type: 'action', name: event, handler })), diff --git a/api/src/logger.test.ts b/api/src/logger.test.ts index 17ca5121a5..9118fe8ee4 100644 --- a/api/src/logger.test.ts +++ b/api/src/logger.test.ts @@ -11,6 +11,7 @@ vi.mock('./env', async () => { LOG_LEVEL: 'info', LOG_STYLE: 'raw', }; + return { default: MOCK_ENV, getEnv: () => MOCK_ENV, @@ -41,6 +42,7 @@ afterEach(() => { describe('req.headers.authorization', () => { test('Should redact bearer token in Authorization header', () => { const instance = pino(httpLoggerOptions, stream); + instance.info({ req: { headers: { @@ -48,6 +50,7 @@ describe('req.headers.authorization', () => { }, }, }); + expect(logOutput.mock.calls[0][0]).toMatchObject({ req: { headers: { @@ -61,6 +64,7 @@ describe('req.headers.authorization', () => { describe('req.headers.cookie', () => { test('Should redact refresh token when there is only one entry', () => { const instance = pino(httpLoggerOptions, stream); + instance.info({ req: { headers: { @@ -68,6 +72,7 @@ describe('req.headers.cookie', () => { }, }, }); + expect(logOutput.mock.calls[0][0]).toMatchObject({ req: { headers: { @@ -79,6 +84,7 @@ describe('req.headers.cookie', () => { test('Should redact refresh token with multiple entries', () => { const instance = pino(httpLoggerOptions, stream); + instance.info({ req: { headers: { @@ -86,6 +92,7 @@ describe('req.headers.cookie', () => { }, }, }); + expect(logOutput.mock.calls[0][0]).toMatchObject({ req: { headers: { @@ -99,6 +106,7 @@ describe('req.headers.cookie', () => { describe('res.headers', () => { test('Should redact refresh token when there is only one entry', () => { const instance = pino(httpLoggerOptions, stream); + instance.info({ res: { headers: { @@ -106,6 +114,7 @@ describe('res.headers', () => { }, }, }); + expect(logOutput.mock.calls[0][0]).toMatchObject({ res: { headers: { @@ -117,6 +126,7 @@ describe('res.headers', () => { test('Should redact refresh token with multiple entries', () => { const instance = pino(httpLoggerOptions, stream); + instance.info({ res: { headers: { @@ -129,6 +139,7 @@ describe('res.headers', () => { }, }, }); + expect(logOutput.mock.calls[0][0]).toMatchObject({ res: { headers: { diff --git a/api/src/logger.ts b/api/src/logger.ts index 2c89c89095..bb88593fc1 100644 --- a/api/src/logger.ts +++ b/api/src/logger.ts @@ -33,6 +33,7 @@ if (env['LOG_STYLE'] !== 'raw') { sync: true, }, }; + httpLoggerOptions.transport = { target: 'pino-http-print', options: { @@ -85,6 +86,7 @@ if (loggerEnvConfig['levels']) { }; }, }; + httpLoggerOptions.formatters = { level(label: string, number: any) { return { diff --git a/api/src/mailer.ts b/api/src/mailer.ts index 111a2db61f..25ca038ce2 100644 --- a/api/src/mailer.ts +++ b/api/src/mailer.ts @@ -54,6 +54,7 @@ export default function getMailer(): Transporter { } as Record); } else if (transportName === 'mailgun') { const mg = require('nodemailer-mailgun-transport'); + transporter = nodemailer.createTransport( mg({ auth: { @@ -65,6 +66,7 @@ export default function getMailer(): Transporter { ); } else if (transportName === 'sendgrid') { const sg = require('nodemailer-sendgrid'); + transporter = nodemailer.createTransport( sg({ apiKey: env['EMAIL_SENDGRID_API_KEY'], diff --git a/api/src/middleware/authenticate.test.ts b/api/src/middleware/authenticate.test.ts index 713904b785..af1c2b03ed 100644 --- a/api/src/middleware/authenticate.test.ts +++ b/api/src/middleware/authenticate.test.ts @@ -10,10 +10,12 @@ import { InvalidCredentialsException } from '../exceptions/invalid-credentials.j import { handler } from './authenticate.js'; vi.mock('../../src/database'); + vi.mock('../../src/env', () => { const MOCK_ENV = { SECRET: 'test', }; + return { default: MOCK_ENV, getEnv: () => MOCK_ENV, @@ -29,6 +31,7 @@ test('Short-circuits when authenticate filter is used', async () => { ip: '127.0.0.1', get: vi.fn(), } as unknown as Request; + const res = {} as Response; const next = vi.fn(); @@ -56,6 +59,7 @@ test('Uses default public accountability when no token is given', async () => { } }), } as unknown as Request; + const res = {} as Response; const next = vi.fn(); @@ -80,10 +84,12 @@ test('Sets accountability to payload contents if valid token is passed', async ( const userID = '3fac3c02-607f-4438-8d6e-6b8b25109b52'; const roleID = '38269fc6-6eb6-475a-93cb-479d97f73039'; const share = 'ca0ad005-f4ad-4bfe-b428-419ee8784790'; + const shareScope = { collection: 'articles', item: 15, }; + const appAccess = true; const adminAccess = false; @@ -114,6 +120,7 @@ test('Sets accountability to payload contents if valid token is passed', async ( }), token, } as unknown as Request; + const res = {} as Response; const next = vi.fn(); @@ -189,6 +196,7 @@ test('Throws InvalidCredentialsException when static token is used, but user doe }), token: 'static-token', } as unknown as Request; + const res = {} as Response; const next = vi.fn(); @@ -211,6 +219,7 @@ test('Sets accountability to user information when static token is used', async }), token: 'static-token', } as unknown as Request; + const res = {} as Response; const next = vi.fn(); diff --git a/api/src/middleware/rate-limiter-global.ts b/api/src/middleware/rate-limiter-global.ts index 1b2ffc970a..8fb9e6cf47 100644 --- a/api/src/middleware/rate-limiter-global.ts +++ b/api/src/middleware/rate-limiter-global.ts @@ -47,6 +47,7 @@ function validateConfiguration() { const globalPointsPerSec = Number(env['RATE_LIMITER_GLOBAL_POINTS']) / Math.max(Number(env['RATE_LIMITER_GLOBAL_DURATION']), 1); + const regularPointsPerSec = Number(env['RATE_LIMITER_POINTS']) / Math.max(Number(env['RATE_LIMITER_DURATION']), 1); if (globalPointsPerSec <= regularPointsPerSec) { diff --git a/api/src/middleware/validate-batch.test.ts b/api/src/middleware/validate-batch.test.ts index b2c85fc6b2..be8b0ed007 100644 --- a/api/src/middleware/validate-batch.test.ts +++ b/api/src/middleware/validate-batch.test.ts @@ -55,6 +55,7 @@ test(`Short circuits on Array body in update/delete use`, async () => { test(`Sets sanitizedQuery based on body.query in read operations`, async () => { mockRequest.method = 'SEARCH'; + mockRequest.body = { query: { sort: 'id', @@ -70,6 +71,7 @@ test(`Sets sanitizedQuery based on body.query in read operations`, async () => { test(`Doesn't allow both query and keys in a batch delete`, async () => { mockRequest.method = 'DELETE'; + mockRequest.body = { keys: [1, 2, 3], query: { filter: {} }, @@ -83,6 +85,7 @@ test(`Doesn't allow both query and keys in a batch delete`, async () => { test(`Requires 'data' on batch update`, async () => { mockRequest.method = 'PATCH'; + mockRequest.body = { keys: [1, 2, 3], query: { filter: {} }, diff --git a/api/src/operations/condition/index.test.ts b/api/src/operations/condition/index.test.ts index ce4e1f39a2..37178227c1 100644 --- a/api/src/operations/condition/index.test.ts +++ b/api/src/operations/condition/index.test.ts @@ -9,6 +9,7 @@ describe('Operations / Condition', () => { _eq: true, }, }; + const data = { status: true, }; @@ -22,6 +23,7 @@ describe('Operations / Condition', () => { _eq: true, }, }; + const data = { status: false, }; @@ -42,6 +44,7 @@ describe('Operations / Condition', () => { _eq: true, }, }; + const data = {}; expect.assertions(2); // ensure catch block is reached diff --git a/api/src/operations/item-delete/index.test.ts b/api/src/operations/item-delete/index.test.ts index 605a249803..70575360fe 100644 --- a/api/src/operations/item-delete/index.test.ts +++ b/api/src/operations/item-delete/index.test.ts @@ -96,6 +96,7 @@ describe('Operations / Item Delete', () => { test('should emit events for deleteOne when true', async () => { const key = 1; + await config.handler( { collection: testCollection, key, emitEvents: true } as any, { accountability: testAccountability, getSchema } as any @@ -106,6 +107,7 @@ describe('Operations / Item Delete', () => { test.each([undefined, false])('should not emit events for deleteOne when %s', async (emitEvents) => { const key = 1; + await config.handler( { collection: testCollection, key, emitEvents } as any, { accountability: testAccountability, getSchema } as any @@ -127,6 +129,7 @@ describe('Operations / Item Delete', () => { test('should emit events for deleteMany when true', async () => { const keys = [1, 2, 3]; + await config.handler( { collection: testCollection, key: keys, emitEvents: true } as any, { accountability: testAccountability, getSchema } as any @@ -137,6 +140,7 @@ describe('Operations / Item Delete', () => { test.each([undefined, false])('should not emit events for deleteMany when %s', async (emitEvents) => { const keys = [1, 2, 3]; + await config.handler( { collection: testCollection, key: keys, emitEvents } as any, { accountability: testAccountability, getSchema } as any diff --git a/api/src/operations/item-read/index.test.ts b/api/src/operations/item-read/index.test.ts index 30a97dd84d..3178dfc447 100644 --- a/api/src/operations/item-read/index.test.ts +++ b/api/src/operations/item-read/index.test.ts @@ -96,6 +96,7 @@ describe('Operations / Item Read', () => { test('should emit events for readOne when true', async () => { const key = 1; + await config.handler( { collection: testCollection, key, emitEvents: true } as any, { accountability: testAccountability, getSchema } as any @@ -106,6 +107,7 @@ describe('Operations / Item Read', () => { test.each([undefined, false])('should not emit events for readOne when %s', async (emitEvents) => { const key = 1; + await config.handler( { collection: testCollection, key, emitEvents } as any, { accountability: testAccountability, getSchema } as any @@ -127,6 +129,7 @@ describe('Operations / Item Read', () => { test('should emit events for readMany when true', async () => { const keys = [1, 2, 3]; + await config.handler( { collection: testCollection, key: keys, emitEvents: true } as any, { accountability: testAccountability, getSchema } as any @@ -137,6 +140,7 @@ describe('Operations / Item Read', () => { test.each([undefined, false])('should not emit events for readMany when %s', async (emitEvents) => { const keys = [1, 2, 3]; + await config.handler( { collection: testCollection, key: keys, emitEvents } as any, { accountability: testAccountability, getSchema } as any diff --git a/api/src/operations/item-update/index.test.ts b/api/src/operations/item-update/index.test.ts index dae4c35935..b664bda7f7 100644 --- a/api/src/operations/item-update/index.test.ts +++ b/api/src/operations/item-update/index.test.ts @@ -59,6 +59,7 @@ describe('Operations / Item Update', () => { test.each([undefined, []])('should call updateByQuery with correct query when key is $payload', async (key) => { const query = { limit: -1 }; + await config.handler( { collection: testCollection, payload: testPayload, query, key } as any, { accountability: testAccountability, getSchema } as any @@ -71,6 +72,7 @@ describe('Operations / Item Update', () => { test('should emit events for updateByQuery when true', async () => { const query = { limit: -1 }; + await config.handler( { collection: testCollection, payload: testPayload, query, emitEvents: true } as any, { accountability: testAccountability, getSchema } as any @@ -83,6 +85,7 @@ describe('Operations / Item Update', () => { test.each([undefined, false])('should not emit events for updateByQuery when %s', async (emitEvents) => { const query = { limit: -1 }; + await config.handler( { collection: testCollection, payload: testPayload, query, emitEvents } as any, { accountability: testAccountability, getSchema } as any @@ -106,6 +109,7 @@ describe('Operations / Item Update', () => { test('should emit events for updateOne when true', async () => { const key = 1; + await config.handler( { collection: testCollection, payload: testPayload, key, emitEvents: true } as any, { accountability: testAccountability, getSchema } as any @@ -116,6 +120,7 @@ describe('Operations / Item Update', () => { test.each([undefined, false])('should not emit events for updateOne when %s', async (emitEvents) => { const key = 1; + await config.handler( { collection: testCollection, payload: testPayload, key: key, emitEvents } as any, { accountability: testAccountability, getSchema } as any @@ -137,6 +142,7 @@ describe('Operations / Item Update', () => { test('should emit events for updateMany when true', async () => { const keys = [1, 2, 3]; + await config.handler( { collection: testCollection, payload: testPayload, key: keys, emitEvents: true } as any, { accountability: testAccountability, getSchema } as any @@ -147,6 +153,7 @@ describe('Operations / Item Update', () => { test.each([undefined, false])('should not emit events for updateMany when %s', async (emitEvents) => { const keys = [1, 2, 3]; + await config.handler( { collection: testCollection, payload: testPayload, key: keys, emitEvents } as any, { accountability: testAccountability, getSchema } as any diff --git a/api/src/operations/mail/index.ts b/api/src/operations/mail/index.ts index 4b57bad6b2..8027c0b794 100644 --- a/api/src/operations/mail/index.ts +++ b/api/src/operations/mail/index.ts @@ -17,6 +17,7 @@ export default defineOperationApi({ // If 'body' is of type object/undefined (happens when body consists solely of a placeholder) // convert it to JSON string const safeBody = typeof body !== 'string' ? JSON.stringify(body) : body; + await mailService.send({ html: type === 'wysiwyg' ? safeBody : md(safeBody), to, diff --git a/api/src/operations/notification/index.ts b/api/src/operations/notification/index.ts index 7d98329c6e..c291852bcf 100644 --- a/api/src/operations/notification/index.ts +++ b/api/src/operations/notification/index.ts @@ -43,6 +43,7 @@ export default defineOperationApi({ message: messageString, }; }); + const result = await notificationsService.createMany(payload); return result; diff --git a/api/src/operations/request/index.test.ts b/api/src/operations/request/index.test.ts index 24fcd448eb..f749044ad6 100644 --- a/api/src/operations/request/index.test.ts +++ b/api/src/operations/request/index.test.ts @@ -37,10 +37,12 @@ test('no headers configured', async () => { test('headers array is converted to object', async () => { const body = 'body'; + const headers = [ { header: 'header1', value: 'value1' }, { header: 'header2', value: 'value2' }, ]; + await config.handler({ url, method, body, headers }, {} as any); expect(axiosDefault).toHaveBeenCalledWith( diff --git a/api/src/request/validate-ip.test.ts b/api/src/request/validate-ip.test.ts index 5043e58f7c..0e299a5d3c 100644 --- a/api/src/request/validate-ip.test.ts +++ b/api/src/request/validate-ip.test.ts @@ -48,6 +48,7 @@ test(`Checks against IPs of local networkInterfaces if IP deny list contains 0.0 test(`Throws error if IP address matches resolved localhost IP`, async () => { vi.mocked(getEnv).mockReturnValue({ IMPORT_IP_DENY_LIST: ['0.0.0.0'] }); + vi.mocked(os.networkInterfaces).mockReturnValue({ fa0: undefined, lo0: [ diff --git a/api/src/services/authentication.ts b/api/src/services/authentication.ts index 9704099ccb..53400da4a6 100644 --- a/api/src/services/authentication.ts +++ b/api/src/services/authentication.ts @@ -347,10 +347,12 @@ export class AuthenticationService { if (record.share_id) { tokenPayload.share = record.share_id; tokenPayload.role = record.share_role; + tokenPayload.share_scope = { collection: record.share_collection, item: record.share_item, }; + tokenPayload.app_access = false; tokenPayload.admin_access = false; diff --git a/api/src/services/authorization.ts b/api/src/services/authorization.ts index 86f5b29d93..7e52497786 100644 --- a/api/src/services/authorization.ts +++ b/api/src/services/authorization.ts @@ -38,6 +38,7 @@ export class AuthorizationService { this.knex = options.knex || getDatabase(); this.accountability = options.accountability || null; this.schema = options.schema; + this.payloadService = new PayloadService('directus_permissions', { knex: this.knex, schema: this.schema, @@ -236,6 +237,7 @@ export class AuthorizationService { parentCollection, parentField ); + result = mergeRequiredFieldPermissions(result, requiredPermissions); } } @@ -306,6 +308,7 @@ export class AuthorizationService { parentCollection = relation.related_collection === parentCollection ? relation.collection : relation.related_collection!; + (result[parentCollection] || (result[parentCollection] = new Set())).add(filterKey); } @@ -324,6 +327,7 @@ export class AuthorizationService { parentCollection, filterKey ); + result = mergeRequiredFieldPermissions(result, requiredPermissions); } } @@ -335,6 +339,7 @@ export class AuthorizationService { parentCollection, filterKey ); + result = mergeRequiredFieldPermissions(result, requiredPermissions); } } diff --git a/api/src/services/collections.ts b/api/src/services/collections.ts index 15fd9f1915..9d8dc5a098 100644 --- a/api/src/services/collections.ts +++ b/api/src/services/collections.ts @@ -141,6 +141,7 @@ export class CollectionsService { }); const fieldPayloads = payload.fields!.filter((field) => field.meta).map((field) => field.meta) as FieldMeta[]; + await fieldItemsService.createMany(fieldPayloads, { bypassEmitAction: (params) => opts?.bypassEmitAction ? opts.bypassEmitAction(params) : nestedActionEvents.push(params), @@ -213,6 +214,7 @@ export class CollectionsService { autoPurgeSystemCache: false, bypassEmitAction: (params) => nestedActionEvents.push(params), }); + collectionNames.push(name); } @@ -456,6 +458,7 @@ export class CollectionsService { bypassEmitAction: (params) => opts?.bypassEmitAction ? opts.bypassEmitAction(params) : nestedActionEvents.push(params), }); + collectionKeys.push(payload[collectionKey]); } }); @@ -636,6 +639,7 @@ export class CollectionsService { const newAllowedCollections = relation .meta!.one_allowed_collections!.filter((collection) => collectionKey !== collection) .join(','); + await trx('directus_relations') .update({ one_allowed_collections: newAllowedCollections }) .where({ id: relation.meta!.id }); diff --git a/api/src/services/fields.ts b/api/src/services/fields.ts index 25ab8bf0e2..a0ca9cfa17 100644 --- a/api/src/services/fields.ts +++ b/api/src/services/fields.ts @@ -196,6 +196,7 @@ export class FieldsService { }); if (!permissions || !permissions.fields) throw new ForbiddenException(); + if (permissions.fields.includes('*') === false) { const allowedFields = permissions.fields; if (allowedFields.includes(field) === false) throw new ForbiddenException(); diff --git a/api/src/services/files.test.ts b/api/src/services/files.test.ts index 6418cd196d..64f59cfc6f 100644 --- a/api/src/services/files.test.ts +++ b/api/src/services/files.test.ts @@ -29,6 +29,7 @@ describe('Integration Tests', () => { knex: db, schema: { collections: {}, relations: [] }, }); + superCreateOne = vi.spyOn(ItemsService.prototype, 'createOne').mockReturnValue(Promise.resolve(1)); }); diff --git a/api/src/services/files.ts b/api/src/services/files.ts index 719a767cd6..27b65a5038 100644 --- a/api/src/services/files.ts +++ b/api/src/services/files.ts @@ -271,6 +271,7 @@ export class FilesService extends ItemsService { try { const axios = await getAxios(); + fileResponse = await axios.get(encodeURL(importURL), { responseType: 'stream', }); diff --git a/api/src/services/graphql/index.ts b/api/src/services/graphql/index.ts index befeb6d822..3fdd31e024 100644 --- a/api/src/services/graphql/index.ts +++ b/api/src/services/graphql/index.ts @@ -204,6 +204,7 @@ export class GraphQLService { const scopeFilter = (collection: SchemaOverview['collections'][string]) => { if (this.scope === 'items' && collection.collection.startsWith('directus_') === true) return false; + if (this.scope === 'system') { if (collection.collection.startsWith('directus_') === false) return false; if (SYSTEM_DENY_LIST.includes(collection.collection)) return false; @@ -239,6 +240,7 @@ export class GraphQLService { acc[`${collectionName}_by_id`] = ReadCollectionTypes[collection.collection]!.getResolver( `${collection.collection}_by_id` ); + acc[`${collectionName}_aggregated`] = ReadCollectionTypes[collection.collection]!.getResolver( `${collection.collection}_aggregated` ); @@ -264,12 +266,15 @@ export class GraphQLService { .filter((collection) => READ_ONLY.includes(collection.collection) === false) .reduce((acc, collection) => { const collectionName = this.scope === 'items' ? collection.collection : collection.collection.substring(9); + acc[`create_${collectionName}_items`] = CreateCollectionTypes[collection.collection]!.getResolver( `create_${collection.collection}_items` ); + acc[`create_${collectionName}_item`] = CreateCollectionTypes[collection.collection]!.getResolver( `create_${collection.collection}_item` ); + return acc; }, {} as ObjectTypeComposerFieldConfigAsObjectDefinition) ); @@ -893,6 +898,7 @@ export class GraphQLService { type: GraphQLFloat, description: field.note, }; + break; default: break; @@ -1115,6 +1121,7 @@ export class GraphQLService { } } else if (relation.meta?.one_allowed_collections) { ReadableCollectionFilterTypes[relation.collection]?.removeField('item'); + for (const collection of relation.meta.one_allowed_collections) { ReadableCollectionFilterTypes[relation.collection]?.addFields({ [`item__${collection}`]: ReadableCollectionFilterTypes[collection]!, @@ -1997,6 +2004,7 @@ export class GraphQLService { accountability: this.accountability, scope: args['scope'] ?? 'items', }); + return service.getSchema('sdl'); }, }, @@ -2011,6 +2019,7 @@ export class GraphQLService { accountability: this.accountability, schema: this.schema, }); + return await service.serverInfo(); }, }, @@ -2021,6 +2030,7 @@ export class GraphQLService { accountability: this.accountability, schema: this.schema, }); + return await service.health(); }, }, @@ -2065,6 +2075,7 @@ export class GraphQLService { accountability: accountability, schema: this.schema, }); + const result = await authenticationService.login(DEFAULT_AUTH_PROVIDER, args, args?.otp); if (args['mode'] === 'cookie') { @@ -2105,6 +2116,7 @@ export class GraphQLService { accountability: accountability, schema: this.schema, }); + const currentRefreshToken = args['refresh_token'] || req?.cookies[env['REFRESH_TOKEN_COOKIE_NAME']]; if (!currentRefreshToken) { @@ -2150,6 +2162,7 @@ export class GraphQLService { accountability: accountability, schema: this.schema, }); + const currentRefreshToken = args['refresh_token'] || req?.cookies[env['REFRESH_TOKEN_COOKIE_NAME']]; if (!currentRefreshToken) { @@ -2224,14 +2237,17 @@ export class GraphQLService { }, resolve: async (_, args) => { if (!this.accountability?.user) return null; + const service = new TFAService({ accountability: this.accountability, schema: this.schema, }); + const authService = new AuthenticationService({ accountability: this.accountability, schema: this.schema, }); + await authService.verifyPassword(this.accountability.user, args['password']); const { url, secret } = await service.generateTFA(this.accountability.user); return { secret, otpauth_url: url }; @@ -2245,6 +2261,7 @@ export class GraphQLService { }, resolve: async (_, args) => { if (!this.accountability?.user) return null; + const service = new TFAService({ accountability: this.accountability, schema: this.schema, @@ -2261,10 +2278,12 @@ export class GraphQLService { }, resolve: async (_, args) => { if (!this.accountability?.user) return null; + const service = new TFAService({ accountability: this.accountability, schema: this.schema, }); + const otpValid = await service.verifyOTP(this.accountability.user, args['otp']); if (otpValid === false) { @@ -2321,6 +2340,7 @@ export class GraphQLService { accountability: this.accountability, schema: this.schema, }); + const { item, to } = args; await service.sort(args['collection'], { item, to }); return true; @@ -2336,6 +2356,7 @@ export class GraphQLService { accountability: this.accountability, schema: this.schema, }); + await service.revert(args['revision']); return true; }, @@ -2366,6 +2387,7 @@ export class GraphQLService { accountability: this.accountability, schema: this.schema, }); + await service.acceptInvite(args['token'], args['password']); return true; }, @@ -2474,6 +2496,7 @@ export class GraphQLService { accountability: this.accountability, schema: this.schema, }); + return await service.readAll(); }, }, @@ -2502,6 +2525,7 @@ export class GraphQLService { accountability: this.accountability, schema: this.schema, }); + return await service.readOne(args['collection'], args['field']); }, }, @@ -2575,6 +2599,7 @@ export class GraphQLService { accountability: this.accountability, schema: this.schema, }); + return await service.readOne(args['collection'], args['field']); }, }, @@ -2599,6 +2624,7 @@ export class GraphQLService { accountability: this.accountability, schema: this.schema, }); + const collectionKey = await collectionsService.createOne(args['data']); return await collectionsService.readOne(collectionKey); }, @@ -2616,6 +2642,7 @@ export class GraphQLService { accountability: this.accountability, schema: this.schema, }); + const collectionKey = await collectionsService.updateOne(args['collection'], args['data']); return await collectionsService.readOne(collectionKey); }, @@ -2635,6 +2662,7 @@ export class GraphQLService { accountability: this.accountability, schema: this.schema, }); + await collectionsService.deleteOne(args['collection']); return { collection: args['collection'] }; }, @@ -2653,6 +2681,7 @@ export class GraphQLService { accountability: this.accountability, schema: this.schema, }); + await service.createField(args['collection'], args['data']); return await service.readOne(args['collection'], args['data'].field); }, @@ -2669,10 +2698,12 @@ export class GraphQLService { accountability: this.accountability, schema: this.schema, }); + await service.updateField(args['collection'], { ...args['data'], field: args['field'], }); + return await service.readOne(args['collection'], args['data'].field); }, }, @@ -2693,6 +2724,7 @@ export class GraphQLService { accountability: this.accountability, schema: this.schema, }); + await service.deleteField(args['collection'], args['field']); const { collection, field } = args; return { collection, field }; @@ -2750,6 +2782,7 @@ export class GraphQLService { accountability: this.accountability, schema: this.schema, }); + await relationsService.deleteOne(args['collection'], args['field']); return { collection: args['collection'], field: args['field'] }; }, @@ -2764,10 +2797,12 @@ export class GraphQLService { resolve: async (_, args, __, info) => { if (!this.accountability?.user) return null; const service = new UsersService({ schema: this.schema, accountability: this.accountability }); + const selections = this.replaceFragmentsInSelections( info.fieldNodes[0]?.selectionSet?.selections, info.fragments ); + const query = this.getQuery(args, selections || [], info.variableValues); return await service.readOne(this.accountability.user, query); @@ -2785,6 +2820,7 @@ export class GraphQLService { }, resolve: async (_, args, __, info) => { if (!this.accountability?.user) return null; + const service = new UsersService({ schema: this.schema, accountability: this.accountability, @@ -2797,6 +2833,7 @@ export class GraphQLService { info.fieldNodes[0]?.selectionSet?.selections, info.fragments ); + const query = this.getQuery(args, selections || [], info.variableValues); return await service.readOne(this.accountability.user, query); @@ -2837,6 +2874,7 @@ export class GraphQLService { info.fieldNodes[0]?.selectionSet?.selections, info.fragments ); + const query = this.getQuery(args, selections || [], info.variableValues); return await service.readOne(primaryKey, query); @@ -2861,6 +2899,7 @@ export class GraphQLService { accountability: this.accountability, schema: this.schema, }); + const primaryKey = await service.updateOne(args['id'], { comment: args['comment'] }); if ('directus_activity' in ReadCollectionTypes) { @@ -2868,6 +2907,7 @@ export class GraphQLService { info.fieldNodes[0]?.selectionSet?.selections, info.fragments ); + const query = this.getQuery(args, selections || [], info.variableValues); return await service.readOne(primaryKey, query); @@ -2891,6 +2931,7 @@ export class GraphQLService { accountability: this.accountability, schema: this.schema, }); + await service.deleteOne(args['id']); return { id: args['id'] }; }, @@ -2913,6 +2954,7 @@ export class GraphQLService { accountability: this.accountability, schema: this.schema, }); + const primaryKey = await service.importOne(args['url'], args['data']); if ('directus_files' in ReadCollectionTypes) { @@ -2920,6 +2962,7 @@ export class GraphQLService { info.fieldNodes[0]?.selectionSet?.selections, info.fragments ); + const query = this.getQuery(args, selections || [], info.variableValues); return await service.readOne(primaryKey, query); } @@ -2944,6 +2987,7 @@ export class GraphQLService { accountability: this.accountability, schema: this.schema, }); + await service.inviteUser(args['email'], args['role'], args['invite_url'] || null); return true; }, diff --git a/api/src/services/graphql/types/bigint.ts b/api/src/services/graphql/types/bigint.ts index 11fa6ef40a..cf2793a7b5 100644 --- a/api/src/services/graphql/types/bigint.ts +++ b/api/src/services/graphql/types/bigint.ts @@ -6,6 +6,7 @@ export const GraphQLBigInt = new GraphQLScalarType({ serialize(value) { if (!value) return value; if (typeof value === 'string') return value; + if (typeof value !== 'number') { throw new Error('Value must be a Number'); } diff --git a/api/src/services/graphql/utils/process-error.test.ts b/api/src/services/graphql/utils/process-error.test.ts index dd8d341c0f..d02a1e604f 100644 --- a/api/src/services/graphql/utils/process-error.test.ts +++ b/api/src/services/graphql/utils/process-error.test.ts @@ -4,6 +4,7 @@ import processError from './process-error.js'; describe('GraphQL processError util', () => { const sampleError = new GraphQLError('An error message', { path: ['test_collection'] }); + const redactedError = { message: 'An unexpected error occurred.', locations: undefined, diff --git a/api/src/services/import-export.ts b/api/src/services/import-export.ts index 42dbeb974c..84ce35ff38 100644 --- a/api/src/services/import-export.ts +++ b/api/src/services/import-export.ts @@ -358,6 +358,7 @@ export class ExportService { if (format === 'csv') { if (input.length === 0) return ''; + const parser = new CSVParser({ transforms: [CSVTransforms.flatten({ separator: '.' })], header: options?.includeHeader !== false, diff --git a/api/src/services/items.test.ts b/api/src/services/items.test.ts index e893dbaa2e..784b6df855 100644 --- a/api/src/services/items.test.ts +++ b/api/src/services/items.test.ts @@ -12,10 +12,12 @@ import { systemSchema, userSchema } from '../__utils__/schemas.js'; vi.mock('../env', async () => { const actual = (await vi.importActual('../env')) as { default: Record }; + const MOCK_ENV = { ...actual.default, CACHE_AUTO_PURGE: true, }; + return { default: MOCK_ENV, getEnv: () => MOCK_ENV, @@ -80,6 +82,7 @@ describe('Integration Tests', () => { expect(tracker.history.insert.length).toBe(1); expect(tracker.history.insert[0]!.bindings).toStrictEqual([item.id, item.name]); + expect(tracker.history.insert[0]!.sql).toBe( `insert into "${table}" (${sqlFieldList(schemas[schema].schema, table)}) values (?, ?)` ); @@ -122,10 +125,12 @@ describe('Integration Tests', () => { accountability: { role: 'admin', admin: true }, schema: schemas[schema].schema, }); + const response = await itemsService.readOne(rawItems[0]!.id, { fields: ['id', 'name'] }); expect(tracker.history.select.length).toBe(1); expect(tracker.history.select[0]!.bindings).toStrictEqual([rawItems[0]!.id, 100]); + expect(tracker.history.select[0]!.sql).toBe( `select ${sqlFieldFormatter( schemas[schema].schema, @@ -163,10 +168,12 @@ describe('Integration Tests', () => { }, schema: schemas[schema].schema, }); + const response = await itemsService.readOne(rawItems[0]!.id); expect(tracker.history.select.length).toBe(1); expect(tracker.history.select[0]!.bindings).toStrictEqual([rawItems[0]!.id, 100]); + expect(tracker.history.select[0]!.sql).toBe( `select "${table}"."id" from "${table}" where ("${table}"."id" = ?) order by "${table}"."id" asc limit ?` ); @@ -185,6 +192,7 @@ describe('Integration Tests', () => { accountability: { role: 'admin', admin: true }, schema: schemas[schema].schema, }); + const response = await itemsService.readOne(rawItems[0]!.id, { fields: ['id', 'name'], filter: { name: { _eq: 'something' } }, @@ -192,6 +200,7 @@ describe('Integration Tests', () => { expect(tracker.history.select.length).toBe(1); expect(tracker.history.select[0]!.bindings).toStrictEqual(['something', rawItems[0]!.id, 100]); + expect(tracker.history.select[0]!.sql).toBe( `select "${table}"."id", "${table}"."name" from "${table}" where "${table}"."name" = ? and "${table}"."id" = ? order by "${table}"."id" asc limit ?` ); @@ -236,6 +245,7 @@ describe('Integration Tests', () => { }, schema: schemas[schema].schema, }); + const response = await itemsService.readOne(rawItems[0]!.id, { fields: ['id', 'name'], filter: { name: { _eq: 'something' } }, @@ -243,6 +253,7 @@ describe('Integration Tests', () => { expect(tracker.history.select.length).toBe(1); expect(tracker.history.select[0]!.bindings).toStrictEqual(['something', rawItems[0]!.id, 100]); + expect(tracker.history.select[0]!.sql).toBe( `select "${table}"."id", "${table}"."name" from "${table}" where ("${table}"."name" = ? and "${table}"."id" = ?) order by "${table}"."id" asc limit ?` ); @@ -288,17 +299,20 @@ describe('Integration Tests', () => { }, schema: schemas[schema].schema, }); + const response = await itemsService.readOne(rawItems[0]!.id, { fields: ['id'], filter: { uploaded_by: { _in: ['b5a7dd0f-fc9f-4242-b331-83990990198f'] } }, }); expect(tracker.history.select.length).toBe(1); + expect(tracker.history.select[0]!.bindings).toStrictEqual([ 'b5a7dd0f-fc9f-4242-b331-83990990198f', rawItems[0]!.id, 100, ]); + expect(tracker.history.select[0]!.sql).toBe( `select "${table}"."id" from "${table}" where ("${table}"."uploaded_by" in (?) and "${table}"."id" = ?) order by "${table}"."id" asc limit ?` ); @@ -364,17 +378,20 @@ describe('Integration Tests', () => { }, schema: schemas[schema].schema, }); + const response = await itemsService.readOne(rawItems[0]!.id, { fields: ['id'], filter: { uploaded_by: { _in: ['b5a7dd0f-fc9f-4242-b331-83990990198f'] } }, }); expect(tracker.history.select.length).toBe(1); + expect(tracker.history.select[0]!.bindings).toStrictEqual([ 'b5a7dd0f-fc9f-4242-b331-83990990198f', rawItems[0]!.id, 100, ]); + expect(tracker.history.select[0]!.sql).toBe( `select "${table}"."id" from "${table}" where ("${table}"."uploaded_by" in (?) and "${table}"."id" = ?) order by "${table}"."id" asc limit ?` ); @@ -387,10 +404,12 @@ describe('Integration Tests', () => { '%s denies one item with filter from tables not as admin and has no field permissions', async (schema) => { let table = schemas[schema].tables[1]; + const item = { id: 'd66ec139-2655-48c1-9d9a-4753f98a9ee7', uploaded_by: '6107c897-9182-40f7-b22e-4f044d1258d2', }; + let itemsService = new ItemsService(table, { knex: db, accountability: { role: 'admin', admin: true }, @@ -439,6 +458,7 @@ describe('Integration Tests', () => { expect(() => itemsService.readOne(rawItems[0]!.id, { filter: { name: { _eq: 'something' } } }) ).rejects.toThrow("You don't have permission to access this."); + expect(tracker.history.select.length).toBe(0); } ); @@ -465,6 +485,7 @@ describe('Integration Tests', () => { accountability: { role: 'admin', admin: true }, schema: schemas[schema].schema, }); + const response = await itemsService.readOne(rawItems[0]!.id, { fields: ['id', 'items.*'], deep: { items: { _filter: { title: { _eq: childItems[0]!.title } } } as NestedDeepQuery }, @@ -472,17 +493,21 @@ describe('Integration Tests', () => { expect(tracker.history.select.length).toBe(2); expect(tracker.history.select[0]!.bindings).toStrictEqual([rawItems[0]!.id, 100]); + expect(tracker.history.select[0]!.sql).toBe( `select "${table}"."id" from "${table}" where "${table}"."id" = ? order by "${table}"."id" asc limit ?` ); + expect(tracker.history.select[1]!.bindings).toStrictEqual([ childItems[0]!.title, ...rawItems.map((item) => item.id), 25000, ]); + expect(tracker.history.select[1]!.sql).toBe( `select "${childTable}"."id", "${childTable}"."title", "${childTable}"."uploaded_by" from "${childTable}" where "${childTable}"."title" = ? and "${childTable}"."uploaded_by" in (?, ?) order by "${childTable}"."id" asc limit ?` ); + expect(response).toStrictEqual({ id: rawItems[0]!.id, items: childItems }); }); @@ -555,6 +580,7 @@ describe('Integration Tests', () => { }, schema: schemas[schema].schema, }); + const response = await itemsService.readOne(rawItems[0]!.id, { fields: ['id', 'items.*'], deep: { items: { _filter: { title: { _eq: childItems[0]!.title } } } as NestedDeepQuery }, @@ -562,17 +588,21 @@ describe('Integration Tests', () => { expect(tracker.history.select.length).toBe(2); expect(tracker.history.select[0]!.bindings).toStrictEqual([rawItems[0]!.id, 100]); + expect(tracker.history.select[0]!.sql).toBe( `select "${table}"."id" from "${table}" where ("${table}"."id" = ?) order by "${table}"."id" asc limit ?` ); + expect(tracker.history.select[1]!.bindings).toStrictEqual([ childItems[0]!.title, ...rawItems.map((item) => item.id), 25000, ]); + expect(tracker.history.select[1]!.sql).toBe( `select "${childTable}"."id", "${childTable}"."title", "${childTable}"."uploaded_by" from "${childTable}" where ("${childTable}"."title" = ?) and "${childTable}"."uploaded_by" in (?, ?) order by "${childTable}"."id" asc limit ?` ); + expect(response).toStrictEqual({ id: rawItems[0]!.id, items: childItems }); } ); @@ -653,6 +683,7 @@ describe('Integration Tests', () => { deep: { items: { _filter: { title: { _eq: childItems[0]!.title } } } as NestedDeepQuery }, }) ).rejects.toThrow("You don't have permission to access this."); + expect(tracker.history.select.length).toBe(0); } ); @@ -677,6 +708,7 @@ describe('Integration Tests', () => { expect(() => itemsService.readOne(rawItems[0]!.id)).rejects.toThrow( "You don't have permission to access this." ); + expect(tracker.history.select.length).toBe(0); } ); @@ -706,9 +738,11 @@ describe('Integration Tests', () => { }, schema: schemas[schema].schema, }); + expect(() => itemsService.readOne(rawItems[0]!.id)).rejects.toThrow( "You don't have permission to access this." ); + expect(tracker.history.select.length).toBe(0); } ); @@ -757,12 +791,14 @@ describe('Integration Tests', () => { }, schema: schemas[schema].schema, }); + const response = await itemsService.readOne(rawItems[0]!.id, { fields: ['count(items)'], }); expect(tracker.history.select.length).toBe(1); expect(tracker.history.select[0]!.bindings).toStrictEqual([rawItems[0]!.id, 100]); + expect(tracker.history.select[0]!.sql).toBe( `select (select count(*) from "${childTable}" where "uploaded_by" = "${table}"."id") AS "items_count", "${table}"."id" from "${table}" where ("${table}"."id" = ?) order by "${table}"."id" asc limit ?` ); @@ -814,12 +850,14 @@ describe('Integration Tests', () => { }, schema: schemas[schema].schema, }); + const response = await itemsService.readOne(rawItems[0]!.id, { fields: ['count(items)'], }); expect(tracker.history.select.length).toBe(1); expect(tracker.history.select[0]!.bindings).toStrictEqual([rawItems[0]!.id, 100]); + expect(tracker.history.select[0]!.sql).toBe( `select (select count(*) from "${childTable}" where "uploaded_by" = "${table}"."id" and (("${childTable}"."title" like '%child%'))) AS "items_count", "${table}"."id" from "${table}" where ("${table}"."id" = ?) order by "${table}"."id" asc limit ?` ); @@ -845,10 +883,12 @@ describe('Integration Tests', () => { accountability: { role: 'admin', admin: true }, schema: schemas[schema].schema, }); + const response = await itemsService.readMany([items[0]!.id, items[1]!.id], { fields: ['id', 'name'] }); expect(tracker.history.select.length).toBe(1); expect(tracker.history.select[0]!.bindings).toStrictEqual([items[0]!.id, items[1]!.id, 2]); + expect(tracker.history.select[0]!.sql).toBe( `select ${sqlFieldFormatter( schemas[schema].schema, @@ -870,6 +910,7 @@ describe('Integration Tests', () => { accountability: { role: 'admin', admin: true }, schema: schemas[schema].schema, }); + const response = await itemsService.readMany([], { fields: ['id', 'name'], filter: { id: { _eq: items[1]!.id } }, @@ -877,6 +918,7 @@ describe('Integration Tests', () => { expect(tracker.history.select.length).toBe(1); expect(tracker.history.select[0]!.bindings).toStrictEqual([0, items[1]!.id, 100]); + expect(tracker.history.select[0]!.sql).toBe( `select ${sqlFieldFormatter( schemas[schema].schema, @@ -897,6 +939,7 @@ describe('Integration Tests', () => { accountability: { role: 'admin', admin: true }, schema: schemas[schema].schema, }); + const response = await itemsService.readMany([], { fields: ['id', 'name'], filter: { _or: [{ id: { _eq: items[1]!.id } }, { name: { _eq: items[1]!.name } }] }, @@ -904,6 +947,7 @@ describe('Integration Tests', () => { expect(tracker.history.select.length).toBe(1); expect(tracker.history.select[0]!.bindings).toStrictEqual([0, items[1]!.id, items[1]!.name, 100]); + expect(tracker.history.select[0]!.sql).toBe( `select ${sqlFieldFormatter( schemas[schema].schema, @@ -929,6 +973,7 @@ describe('Integration Tests', () => { title: 'A new child item', uploaded_by: '6107c897-9182-40f7-b22e-4f044d1258d2', }; + tracker.on.select(childTable).response([childItem]); tracker.on.update(childTable).response(childItem); @@ -987,6 +1032,7 @@ describe('Integration Tests', () => { }, schema: schemas[schema].schema, }); + const response = await itemsService.updateOne( item.id, { @@ -997,21 +1043,29 @@ describe('Integration Tests', () => { expect(tracker.history.select.length).toBe(4); expect(tracker.history.select[0]!.bindings).toStrictEqual([item.id, 1]); + expect(tracker.history.select[0]!.sql).toBe( `select "${table}"."id", "${table}"."name" from "${table}" where (("${table}"."id" in (?))) order by "${table}"."id" asc limit ?` ); + expect(tracker.history.select[1]!.bindings).toStrictEqual([item.id, 25000]); + expect(tracker.history.select[1]!.sql).toBe( `select "${childTable}"."uploaded_by", "${childTable}"."id" from "${childTable}" where "${childTable}"."uploaded_by" in (?) order by "${childTable}"."id" asc limit ?` ); + expect(tracker.history.select[2]!.bindings).toStrictEqual([item.id, 1, 100]); + expect(tracker.history.select[2]!.sql).toBe( `select "${childTable}"."id" from "${childTable}" where ("${childTable}"."uploaded_by" = ? and 1 = ?) order by "${childTable}"."id" asc limit ?` ); + expect(tracker.history.select[3]!.bindings).toStrictEqual([childItem.id, 1]); + expect(tracker.history.select[3]!.sql).toBe( `select "${childTable}"."id", "${childTable}"."title", "${childTable}"."uploaded_by" from "${childTable}" where (("${childTable}"."id" in (?))) order by "${childTable}"."id" asc limit ?` ); + expect(tracker.history.update[0]!.bindings).toStrictEqual([null, childItem.id]); expect(tracker.history.update[0]!.sql).toBe(`update "${childTable}" set "uploaded_by" = ? where "id" in (?)`); @@ -1093,6 +1147,7 @@ describe('Integration Tests', () => { accountability: { role: 'admin', admin: true }, schema: schemas[schema].schema, }); + await itemsService.readByQuery({ fields: ['id', 'name'], filter: { name: { _eq: 'something' } }, @@ -1100,6 +1155,7 @@ describe('Integration Tests', () => { expect(tracker.history.select.length).toBe(1); expect(tracker.history.select[0]!.bindings).toStrictEqual(['something', 100]); + expect(tracker.history.select[0]!.sql).toBe( `select "${table}"."id", "${table}"."name" from "${table}" where "${table}"."name" = ? order by "${table}"."id" asc limit ?` ); @@ -1116,6 +1172,7 @@ describe('Integration Tests', () => { accountability: { role: 'admin', admin: true }, schema: schemas[schema].schema, }); + await itemsService.readByQuery({ fields: ['id', 'title'], filter: { uploaded_by: { name: { _eq: 'something' } } }, @@ -1123,6 +1180,7 @@ describe('Integration Tests', () => { expect(tracker.history.select.length).toBe(1); expect(tracker.history.select[0]!.bindings).toStrictEqual(['something', 100]); + expect(tracker.history.select[0]!.sql).toMatch( new RegExp( `select "${otherTable}"."id", "${otherTable}"."title" from "${otherTable}" ` + @@ -1143,6 +1201,7 @@ describe('Integration Tests', () => { accountability: { role: 'admin', admin: true }, schema: schemas[schema].schema, }); + await itemsService.readByQuery({ fields: ['id', 'name'], filter: { items: { title: { _eq: 'something' } } }, @@ -1150,6 +1209,7 @@ describe('Integration Tests', () => { expect(tracker.history.select.length).toBe(1); expect(tracker.history.select[0]!.bindings).toStrictEqual(['something', 100]); + expect(tracker.history.select[0]!.sql).toMatch( new RegExp( `select "${table}"."id", "${table}"."name" from "${table}" inner join ` + @@ -1174,6 +1234,7 @@ describe('Integration Tests', () => { accountability: { role: 'admin', admin: true }, schema: schemas[schema].schema, }); + await itemsService.readByQuery({ fields: ['id', 'name'], sort: ['name'], @@ -1181,6 +1242,7 @@ describe('Integration Tests', () => { expect(tracker.history.select.length).toBe(1); expect(tracker.history.select[0]!.bindings).toStrictEqual([100]); + expect(tracker.history.select[0]!.sql).toBe( `select "${table}"."id", "${table}"."name" from "${table}" order by "${table}"."name" asc limit ?` ); @@ -1197,6 +1259,7 @@ describe('Integration Tests', () => { accountability: { role: 'admin', admin: true }, schema: schemas[schema].schema, }); + await itemsService.readByQuery({ fields: ['id', 'title'], sort: ['uploaded_by.name'], @@ -1204,6 +1267,7 @@ describe('Integration Tests', () => { expect(tracker.history.select.length).toBe(1); expect(tracker.history.select[0]!.bindings).toStrictEqual([100]); + expect(tracker.history.select[0]!.sql).toMatch( new RegExp( `select "${otherTable}"."id", "${otherTable}"."title" from "${otherTable}" ` + @@ -1223,6 +1287,7 @@ describe('Integration Tests', () => { accountability: { role: 'admin', admin: true }, schema: schemas[schema].schema, }); + await itemsService.readByQuery({ fields: ['id', 'name'], sort: ['items.title'], @@ -1230,6 +1295,7 @@ describe('Integration Tests', () => { expect(tracker.history.select.length).toBe(1); expect(tracker.history.select[0]!.bindings).toStrictEqual([100, 1, 100]); + expect(tracker.history.select[0]!.sql).toMatch( new RegExp( `select "${table}"."id", "${table}"."name" from "${table}" ` + @@ -1378,6 +1444,7 @@ describe('Integration Tests', () => { accountability: { role: 'admin', admin: true }, schema: testSchema, }); + const response = await itemsService.readSingleton({ fields: ['*'] }); expect(tracker.history.select.length).toBe(1); diff --git a/api/src/services/items.ts b/api/src/services/items.ts index 632d527d40..896b758835 100644 --- a/api/src/services/items.ts +++ b/api/src/services/items.ts @@ -60,6 +60,7 @@ export class ItemsService implements AbstractSer return { trackMutations(count: number) { mutationCount += count; + if (mutationCount > maxCount) { throw new InvalidPayloadException(`Exceeded max batch mutation limit of ${maxCount}.`); } @@ -92,6 +93,7 @@ export class ItemsService implements AbstractSer */ async createOne(data: Partial, opts: MutationOptions = {}): Promise { if (!opts.mutationTracker) opts.mutationTracker = this.createMutationTracker(); + if (!opts.bypassLimits) { opts.mutationTracker.trackMutations(1); } @@ -101,6 +103,7 @@ export class ItemsService implements AbstractSer const primaryKeyField = this.schema.collections[this.collection]!.primary; const fields = Object.keys(this.schema.collections[this.collection]!.fields); + const aliases = Object.values(this.schema.collections[this.collection]!.fields) .filter((field) => field.alias === true) .map((field) => field.field); @@ -159,6 +162,7 @@ export class ItemsService implements AbstractSer revisions: revisionsM2O, nestedActionEvents: nestedActionEventsM2O, } = await payloadService.processM2O(payloadWithPresets, opts); + const { payload: payloadWithA2O, revisions: revisionsA2O, @@ -324,6 +328,7 @@ export class ItemsService implements AbstractSer bypassEmitAction: (params) => nestedActionEvents.push(params), mutationTracker: opts.mutationTracker, }); + primaryKeys.push(primaryKey); } @@ -538,6 +543,7 @@ export class ItemsService implements AbstractSer */ async updateMany(keys: PrimaryKey[], data: Partial, opts: MutationOptions = {}): Promise { if (!opts.mutationTracker) opts.mutationTracker = this.createMutationTracker(); + if (!opts.bypassLimits) { opts.mutationTracker.trackMutations(keys.length); } @@ -549,6 +555,7 @@ export class ItemsService implements AbstractSer validateKeys(this.schema, this.collection, primaryKeyField, keys); const fields = Object.keys(this.schema.collections[this.collection]!.fields); + const aliases = Object.values(this.schema.collections[this.collection]!.fields) .filter((field) => field.alias === true) .map((field) => field.field); @@ -610,6 +617,7 @@ export class ItemsService implements AbstractSer revisions: revisionsM2O, nestedActionEvents: nestedActionEventsM2O, } = await payloadService.processM2O(payloadWithPresets, opts); + const { payload: payloadWithA2O, revisions: revisionsA2O, @@ -638,6 +646,7 @@ export class ItemsService implements AbstractSer key, opts ); + childrenRevisions.push(...revisions); nestedActionEvents.push(...nestedActionEventsO2M); } @@ -835,6 +844,7 @@ export class ItemsService implements AbstractSer */ async deleteMany(keys: PrimaryKey[], opts: MutationOptions = {}): Promise { if (!opts.mutationTracker) opts.mutationTracker = this.createMutationTracker(); + if (!opts.bypassLimits) { opts.mutationTracker.trackMutations(keys.length); } diff --git a/api/src/services/notifications.ts b/api/src/services/notifications.ts index cbd1a7f9dc..e2dab62a0a 100644 --- a/api/src/services/notifications.ts +++ b/api/src/services/notifications.ts @@ -41,6 +41,7 @@ export class NotificationsService extends ItemsService { const user = await this.usersService.readOne(data.recipient, { fields: ['id', 'email', 'email_notifications', 'role.app_access'], }); + const manageUserAccountUrl = new Url(env['PUBLIC_URL']).addPath('admin', 'users', user['id']).toString(); const html = data.message ? md(data.message) : ''; diff --git a/api/src/services/payload.test.ts b/api/src/services/payload.test.ts index 3494f9358d..3204760e6b 100644 --- a/api/src/services/payload.test.ts +++ b/api/src/services/payload.test.ts @@ -32,6 +32,7 @@ describe('Integration Tests', () => { knex: db, schema: { collections: {}, relations: [] }, }); + helpers = getHelpers(db); }); @@ -219,6 +220,7 @@ describe('Integration Tests', () => { ], 'read' ); + expect(result).toMatchObject([ { [dateFieldId]: '2022-01-10', diff --git a/api/src/services/payload.ts b/api/src/services/payload.ts index 963bcbde7c..0d285ed54e 100644 --- a/api/src/services/payload.ts +++ b/api/src/services/payload.ts @@ -58,6 +58,7 @@ export class PayloadService { public transformers: Transformers = { async hash({ action, value }) { if (!value) return; + if (action === 'create' || action === 'update') { return await generateHash(String(value)); } diff --git a/api/src/services/relations.ts b/api/src/services/relations.ts index df62e7b067..3d1eae49e2 100644 --- a/api/src/services/relations.ts +++ b/api/src/services/relations.ts @@ -32,6 +32,7 @@ export class RelationsService { this.schemaInspector = options.knex ? createInspector(options.knex) : getSchemaInspector(); this.schema = options.schema; this.accountability = options.accountability || null; + this.relationsItemService = new ItemsService('directus_relations', { knex: this.knex, schema: this.schema, @@ -85,6 +86,7 @@ export class RelationsService { }); if (!permissions || !permissions.fields) throw new ForbiddenException(); + if (permissions.fields.includes('*') === false) { const allowedFields = permissions.fields; if (allowedFields.includes(field) === false) throw new ForbiddenException(); @@ -112,6 +114,7 @@ export class RelationsService { const schemaRow = (await this.schemaInspector.foreignKeys(collection)).find( (foreignKey) => foreignKey.column === field ); + const stitched = this.stitchRelations(metaRow, schemaRow ? [schemaRow] : []); const results = await this.filterForbidden(stitched); @@ -189,6 +192,7 @@ export class RelationsService { this.alterType(table, relation); const constraintName: string = getDefaultIndexName('foreign', relation.collection!, relation.field!); + const builder = table .foreign(relation.field!, constraintName) .references( @@ -558,6 +562,7 @@ export class RelationsService { */ private alterType(table: Knex.TableBuilder, relation: Partial) { const m2oFieldDBType = this.schema.collections[relation.collection!]!.fields[relation.field!]!.dbType; + const relatedFieldDBType = this.schema.collections[relation.related_collection!]!.fields[ this.schema.collections[relation.related_collection!]!.primary diff --git a/api/src/services/roles.test.ts b/api/src/services/roles.test.ts index 7eaab1d5dc..c88ece1de2 100644 --- a/api/src/services/roles.test.ts +++ b/api/src/services/roles.test.ts @@ -51,6 +51,7 @@ describe('Integration Tests', () => { beforeEach(() => { tracker.on.any('directus_roles').response({}); + tracker.on .select(/"directus_roles"."id" from "directus_roles" order by "directus_roles"."id" asc limit .*/) .response([]); @@ -73,6 +74,7 @@ describe('Integration Tests', () => { knex: db, schema: testSchema, }); + superUpdateOne = vi.spyOn(ItemsService.prototype, 'updateOne'); }); @@ -89,6 +91,7 @@ describe('Integration Tests', () => { const data: Record = { users: [userId1, userId2], }; + tracker.on.select('select "admin_access" from "directus_roles"').responseOnce({ admin_access }); tracker.on.select('select "id" from "directus_users" where "role" = ?').responseOnce([{ id: userId1 }]); @@ -101,7 +104,9 @@ describe('Integration Tests', () => { const data: Record = { users: [userId1], }; + tracker.on.select('select "admin_access" from "directus_roles"').responseOnce({ admin_access }); + tracker.on .select('select "id" from "directus_users" where "role" = ?') .responseOnce([{ id: userId1 }, { id: userId2 }]); @@ -115,6 +120,7 @@ describe('Integration Tests', () => { const data: Record = { users: [], }; + tracker.on.select('select "admin_access" from "directus_roles"').responseOnce({ admin_access }); tracker.on.select('select "id" from "directus_users" where "role" = ?').responseOnce([{ id: userId1 }]); tracker.on.select('select count(*) as "count" from "directus_users"').responseOnce({ count: 1 }); @@ -134,6 +140,7 @@ describe('Integration Tests', () => { const data: Record = { users: [], }; + tracker.on.select('select "admin_access" from "directus_roles"').responseOnce({ admin_access }); tracker.on.select('select "id" from "directus_users" where "role" = ?').responseOnce([{ id: userId1 }]); tracker.on.select('select count(*) as "count" from "directus_users"').responseOnce({ count: 0 }); @@ -150,9 +157,11 @@ describe('Integration Tests', () => { } expect(superUpdateOne).toHaveBeenCalled(); + expect(superUpdateOne.mock.lastCall![2].preMutationException.message).toBe( `You can't remove the last admin user from the admin role.` ); + expect(superUpdateOne.mock.lastCall![2].preMutationException).toBeInstanceOf( UnprocessableEntityException ); @@ -164,6 +173,7 @@ describe('Integration Tests', () => { const data: Record = { users: [{ id: userId1 }, { id: userId2 }], }; + tracker.on.select('select "admin_access" from "directus_roles"').responseOnce({ admin_access }); tracker.on.select('select "id" from "directus_users" where "role" = ?').responseOnce([{ id: userId1 }]); @@ -176,7 +186,9 @@ describe('Integration Tests', () => { const data: Record = { users: [{ id: userId1 }], }; + tracker.on.select('select "admin_access" from "directus_roles"').responseOnce({ admin_access }); + tracker.on .select('select "id" from "directus_users" where "role" = ?') .responseOnce([{ id: userId1 }, { id: userId2 }]); @@ -190,6 +202,7 @@ describe('Integration Tests', () => { const data: Record = { users: [], }; + tracker.on.select('select "admin_access" from "directus_roles"').responseOnce({ admin_access }); tracker.on.select('select "id" from "directus_users" where "role" = ?').responseOnce([{ id: userId1 }]); tracker.on.select('select count(*) as "count" from "directus_users"').responseOnce({ count: 1 }); @@ -209,6 +222,7 @@ describe('Integration Tests', () => { const data: Record = { users: [], }; + tracker.on.select('select "admin_access" from "directus_roles"').responseOnce({ admin_access }); tracker.on.select('select "id" from "directus_users" where "role" = ?').responseOnce([{ id: userId1 }]); tracker.on.select('select count(*) as "count" from "directus_users"').responseOnce({ count: 0 }); @@ -225,9 +239,11 @@ describe('Integration Tests', () => { } expect(superUpdateOne).toHaveBeenCalled(); + expect(superUpdateOne.mock.lastCall![2].preMutationException.message).toBe( `You can't remove the last admin user from the admin role.` ); + expect(superUpdateOne.mock.lastCall![2].preMutationException).toBeInstanceOf( UnprocessableEntityException ); @@ -243,6 +259,7 @@ describe('Integration Tests', () => { delete: [], }, }; + tracker.on.select('select "admin_access" from "directus_roles"').responseOnce({ admin_access }); tracker.on.select('select "id" from "directus_users" where "role" = ?').responseOnce([{ id: userId1 }]); @@ -259,6 +276,7 @@ describe('Integration Tests', () => { delete: [], }, }; + tracker.on.select('select "admin_access" from "directus_roles"').responseOnce({ admin_access }); tracker.on.select('select "id" from "directus_users" where "role" = ?').responseOnce([{ id: userId1 }]); tracker.on.select('select count(*) as "count" from "directus_users"').responseOnce({ count: 1 }); @@ -276,10 +294,13 @@ describe('Integration Tests', () => { delete: [userId2], }, }; + tracker.on.select('select "admin_access" from "directus_roles"').responseOnce({ admin_access }); + tracker.on .select('select "id" from "directus_users" where "role" = ?') .responseOnce([{ id: userId1 }, { id: userId2 }]); + tracker.on.select('select count(*) as "count" from "directus_users"').responseOnce({ count: 1 }); const result = await service.updateOne(adminRoleId, data); @@ -295,6 +316,7 @@ describe('Integration Tests', () => { delete: [userId1], }, }; + tracker.on.select('select "admin_access" from "directus_roles"').responseOnce({ admin_access }); tracker.on.select('select "id" from "directus_users" where "role" = ?').responseOnce([{ id: userId1 }]); tracker.on.select('select count(*) as "count" from "directus_users"').responseOnce({ count: 1 }); @@ -318,6 +340,7 @@ describe('Integration Tests', () => { delete: [userId1], }, }; + tracker.on.select('select "admin_access" from "directus_roles"').responseOnce({ admin_access }); tracker.on.select('select "id" from "directus_users" where "role" = ?').responseOnce([{ id: userId1 }]); tracker.on.select('select count(*) as "count" from "directus_users"').responseOnce({ count: 0 }); @@ -334,9 +357,11 @@ describe('Integration Tests', () => { } expect(superUpdateOne).toHaveBeenCalled(); + expect(superUpdateOne.mock.lastCall![2].preMutationException.message).toBe( `You can't remove the last admin user from the admin role.` ); + expect(superUpdateOne.mock.lastCall![2].preMutationException).toBeInstanceOf( UnprocessableEntityException ); @@ -352,6 +377,7 @@ describe('Integration Tests', () => { const data: Record = { users: [userId1, userId2], }; + tracker.on.select('select "admin_access" from "directus_roles"').responseOnce({ admin_access }); tracker.on.select('select "id" from "directus_users" where "role" = ?').responseOnce([{ id: userId1 }]); tracker.on.select('select count(*) as "count" from "directus_users"').responseOnce({ count: 1 }); @@ -371,6 +397,7 @@ describe('Integration Tests', () => { const data: Record = { users: [userId1, userId2], }; + tracker.on.select('select "admin_access" from "directus_roles"').responseOnce({ admin_access }); tracker.on.select('select "id" from "directus_users" where "role" = ?').responseOnce([{ id: userId1 }]); tracker.on.select('select count(*) as "count" from "directus_users"').responseOnce({ count: 0 }); @@ -387,9 +414,11 @@ describe('Integration Tests', () => { } expect(superUpdateOne).toHaveBeenCalled(); + expect(superUpdateOne.mock.lastCall![2].preMutationException.message).toBe( `You can't remove the last admin user from the admin role.` ); + expect(superUpdateOne.mock.lastCall![2].preMutationException).toBeInstanceOf( UnprocessableEntityException ); @@ -399,10 +428,13 @@ describe('Integration Tests', () => { const data: Record = { users: [userId1], }; + tracker.on.select('select "admin_access" from "directus_roles"').responseOnce({ admin_access }); + tracker.on .select('select "id" from "directus_users" where "role" = ?') .responseOnce([{ id: userId1 }, { id: userId2 }]); + tracker.on.select('select count(*) as "count" from "directus_users"').responseOnce({ count: 1 }); const result = await service.updateOne(adminRoleId, data); @@ -414,6 +446,7 @@ describe('Integration Tests', () => { const data: Record = { users: [], }; + tracker.on.select('select "admin_access" from "directus_roles"').responseOnce({ admin_access }); tracker.on.select('select "id" from "directus_users" where "role" = ?').responseOnce([{ id: userId1 }]); tracker.on.select('select count(*) as "count" from "directus_users"').responseOnce({ count: 1 }); @@ -433,6 +466,7 @@ describe('Integration Tests', () => { const data: Record = { users: [], }; + tracker.on.select('select "admin_access" from "directus_roles"').responseOnce({ admin_access }); tracker.on.select('select "id" from "directus_users" where "role" = ?').responseOnce([{ id: userId1 }]); tracker.on.select('select count(*) as "count" from "directus_users"').responseOnce({ count: 0 }); @@ -449,9 +483,11 @@ describe('Integration Tests', () => { } expect(superUpdateOne).toHaveBeenCalled(); + expect(superUpdateOne.mock.lastCall![2].preMutationException.message).toBe( `You can't remove the last admin user from the admin role.` ); + expect(superUpdateOne.mock.lastCall![2].preMutationException).toBeInstanceOf( UnprocessableEntityException ); @@ -463,6 +499,7 @@ describe('Integration Tests', () => { const data: Record = { users: [{ id: userId1 }, { id: userId2 }], }; + tracker.on.select('select "admin_access" from "directus_roles"').responseOnce({ admin_access }); tracker.on.select('select "id" from "directus_users" where "role" = ?').responseOnce([{ id: userId1 }]); tracker.on.select('select count(*) as "count" from "directus_users"').responseOnce({ count: 1 }); @@ -482,6 +519,7 @@ describe('Integration Tests', () => { const data: Record = { users: [{ id: userId1 }, { id: userId2 }], }; + tracker.on.select('select "admin_access" from "directus_roles"').responseOnce({ admin_access }); tracker.on.select('select "id" from "directus_users" where "role" = ?').responseOnce([{ id: userId1 }]); tracker.on.select('select count(*) as "count" from "directus_users"').responseOnce({ count: 0 }); @@ -498,9 +536,11 @@ describe('Integration Tests', () => { } expect(superUpdateOne).toHaveBeenCalled(); + expect(superUpdateOne.mock.lastCall![2].preMutationException.message).toBe( `You can't remove the last admin user from the admin role.` ); + expect(superUpdateOne.mock.lastCall![2].preMutationException).toBeInstanceOf( UnprocessableEntityException ); @@ -510,10 +550,13 @@ describe('Integration Tests', () => { const data: Record = { users: [{ id: userId1 }], }; + tracker.on.select('select "admin_access" from "directus_roles"').responseOnce({ admin_access }); + tracker.on .select('select "id" from "directus_users" where "role" = ?') .responseOnce([{ id: userId1 }, { id: userId2 }]); + tracker.on.select('select count(*) as "count" from "directus_users"').responseOnce({ count: 1 }); const result = await service.updateOne(adminRoleId, data); @@ -525,6 +568,7 @@ describe('Integration Tests', () => { const data: Record = { users: [], }; + tracker.on.select('select "admin_access" from "directus_roles"').responseOnce({ admin_access }); tracker.on.select('select "id" from "directus_users" where "role" = ?').responseOnce([{ id: userId1 }]); tracker.on.select('select count(*) as "count" from "directus_users"').responseOnce({ count: 1 }); @@ -544,6 +588,7 @@ describe('Integration Tests', () => { const data: Record = { users: [], }; + tracker.on.select('select "admin_access" from "directus_roles"').responseOnce({ admin_access }); tracker.on.select('select "id" from "directus_users" where "role" = ?').responseOnce([{ id: userId1 }]); tracker.on.select('select count(*) as "count" from "directus_users"').responseOnce({ count: 0 }); @@ -560,9 +605,11 @@ describe('Integration Tests', () => { } expect(superUpdateOne).toHaveBeenCalled(); + expect(superUpdateOne.mock.lastCall![2].preMutationException.message).toBe( `You can't remove the last admin user from the admin role.` ); + expect(superUpdateOne.mock.lastCall![2].preMutationException).toBeInstanceOf( UnprocessableEntityException ); @@ -578,6 +625,7 @@ describe('Integration Tests', () => { delete: [], }, }; + tracker.on.select('select "admin_access" from "directus_roles"').responseOnce({ admin_access }); tracker.on.select('select "id" from "directus_users" where "role" = ?').responseOnce([{ id: userId1 }]); tracker.on.select('select count(*) as "count" from "directus_users"').responseOnce({ count: 1 }); @@ -595,6 +643,7 @@ describe('Integration Tests', () => { delete: [], }, }; + tracker.on.select('select "admin_access" from "directus_roles"').responseOnce({ admin_access }); tracker.on.select('select "id" from "directus_users" where "role" = ?').responseOnce([{ id: userId1 }]); tracker.on.select('select count(*) as "count" from "directus_users"').responseOnce({ count: 1 }); @@ -618,6 +667,7 @@ describe('Integration Tests', () => { delete: [], }, }; + tracker.on.select('select "admin_access" from "directus_roles"').responseOnce({ admin_access }); tracker.on.select('select "id" from "directus_users" where "role" = ?').responseOnce([{ id: userId1 }]); tracker.on.select('select count(*) as "count" from "directus_users"').responseOnce({ count: 0 }); @@ -634,9 +684,11 @@ describe('Integration Tests', () => { } expect(superUpdateOne).toHaveBeenCalled(); + expect(superUpdateOne.mock.lastCall![2].preMutationException.message).toBe( `You can't remove the last admin user from the admin role.` ); + expect(superUpdateOne.mock.lastCall![2].preMutationException).toBeInstanceOf( UnprocessableEntityException ); @@ -650,10 +702,13 @@ describe('Integration Tests', () => { delete: [userId2], }, }; + tracker.on.select('select "admin_access" from "directus_roles"').responseOnce({ admin_access }); + tracker.on .select('select "id" from "directus_users" where "role" = ?') .responseOnce([{ id: userId1 }, { id: userId2 }]); + tracker.on.select('select count(*) as "count" from "directus_users"').responseOnce({ count: 1 }); const result = await service.updateOne(adminRoleId, data); @@ -669,6 +724,7 @@ describe('Integration Tests', () => { delete: [userId1], }, }; + tracker.on.select('select "admin_access" from "directus_roles"').responseOnce({ admin_access }); tracker.on.select('select "id" from "directus_users" where "role" = ?').responseOnce([{ id: userId1 }]); tracker.on.select('select count(*) as "count" from "directus_users"').responseOnce({ count: 1 }); @@ -692,6 +748,7 @@ describe('Integration Tests', () => { delete: [userId1], }, }; + tracker.on.select('select "admin_access" from "directus_roles"').responseOnce({ admin_access }); tracker.on.select('select "id" from "directus_users" where "role" = ?').responseOnce([{ id: userId1 }]); tracker.on.select('select count(*) as "count" from "directus_users"').responseOnce({ count: 0 }); @@ -708,9 +765,11 @@ describe('Integration Tests', () => { } expect(superUpdateOne).toHaveBeenCalled(); + expect(superUpdateOne.mock.lastCall![2].preMutationException.message).toBe( `You can't remove the last admin user from the admin role.` ); + expect(superUpdateOne.mock.lastCall![2].preMutationException).toBeInstanceOf( UnprocessableEntityException ); @@ -769,6 +828,7 @@ describe('Integration Tests', () => { checkForOtherAdminRolesSpy = vi .spyOn(RolesService.prototype as any, 'checkForOtherAdminRoles') .mockResolvedValueOnce(true); + checkForOtherAdminUsersSpy = vi .spyOn(RolesService.prototype as any, 'checkForOtherAdminUsers') .mockResolvedValueOnce(true); @@ -829,6 +889,7 @@ describe('Integration Tests', () => { await service.updateBatch([{ id: 1 }]); expect(checkForOtherAdminRolesSpy).not.toBeCalled(); }); + it('should checkForOtherAdminRoles once', async () => { await service.updateBatch([{ id: 1, admin_access: false }]); expect(checkForOtherAdminRolesSpy).toBeCalledTimes(1); diff --git a/api/src/services/roles.ts b/api/src/services/roles.ts index 64e11694b9..5d9d61cd15 100644 --- a/api/src/services/roles.ts +++ b/api/src/services/roles.ts @@ -41,6 +41,7 @@ export class RolesService extends ItemsService { const usersThatWereInRoleBefore = (await this.knex.select('id').from('directus_users').where('role', '=', key)).map( (user) => user.id ); + const usersThatAreRemoved = usersThatWereInRoleBefore.filter((id) => Array.isArray(users) ? userKeys.includes(id) === false : users.delete.includes(id) === true ); diff --git a/api/src/services/server.ts b/api/src/services/server.ts index 3801dd5068..241c973e50 100644 --- a/api/src/services/server.ts +++ b/api/src/services/server.ts @@ -148,6 +148,7 @@ export class ServerService { logger.warn( `${service} in WARN state, the observed value ${healthCheck.observedValue} is above the threshold of ${healthCheck.threshold}${healthCheck.observedUnit}` ); + data.status = 'warn'; continue; } @@ -358,6 +359,7 @@ export class ServerService { for (const location of toArray(env['STORAGE_LOCATIONS'])) { const disk = storage.location(location); const envThresholdKey = `STORAGE_${location}_HEALTHCHECK_THRESHOLD`.toUpperCase(); + checks[`storage:${location}:responseTime`] = [ { status: 'ok', @@ -373,6 +375,7 @@ export class ServerService { try { await disk.write(`health-${checkID}`, Readable.from(['check'])); const fileStream = await disk.read(`health-${checkID}`); + fileStream.on('data', async () => { fileStream.destroy(); await disk.delete(`health-${checkID}`); diff --git a/api/src/services/specifications.test.ts b/api/src/services/specifications.test.ts index c279232414..70de6854ec 100644 --- a/api/src/services/specifications.test.ts +++ b/api/src/services/specifications.test.ts @@ -150,9 +150,11 @@ describe('Integration Tests', () => { }, }, ]); + vi.spyOn(RelationsService.prototype, 'readAll').mockResolvedValue([]); const spec = await service.oas.generate(); + expect(spec.components?.schemas).toMatchInlineSnapshot(` { "Diff": { @@ -421,6 +423,7 @@ describe('Integration Tests', () => { }, }, ]); + vi.spyOn(RelationsService.prototype, 'readAll').mockResolvedValue([]); const spec = await service.oas.generate(); diff --git a/api/src/services/specifications.ts b/api/src/services/specifications.ts index b4b92118c8..246bbccf02 100644 --- a/api/src/services/specifications.ts +++ b/api/src/services/specifications.ts @@ -439,6 +439,7 @@ class OASSpecsService implements SpecificationSubService { if (relationType === 'm2o') { const relatedTag = tags.find((tag) => tag['x-collection'] === relation.related_collection); + const relatedPrimaryKeyField = fields.find( (field) => field.collection === relation.related_collection && field.schema?.is_primary_key ); @@ -455,6 +456,7 @@ class OASSpecsService implements SpecificationSubService { ]; } else if (relationType === 'o2m') { const relatedTag = tags.find((tag) => tag['x-collection'] === relation.collection); + const relatedPrimaryKeyField = fields.find( (field) => field.collection === relation.collection && field.schema?.is_primary_key ); @@ -462,6 +464,7 @@ class OASSpecsService implements SpecificationSubService { if (!relatedTag || !relatedPrimaryKeyField) return propertyObject; propertyObject.type = 'array'; + propertyObject.items = { oneOf: [ { @@ -476,6 +479,7 @@ class OASSpecsService implements SpecificationSubService { const relatedTags = tags.filter((tag) => relation.meta!.one_allowed_collections!.includes(tag['x-collection'])); propertyObject.type = 'array'; + propertyObject.items = { oneOf: [ { diff --git a/api/src/services/users.test.ts b/api/src/services/users.test.ts index 43843d6d6b..30b504a941 100644 --- a/api/src/services/users.test.ts +++ b/api/src/services/users.test.ts @@ -122,12 +122,15 @@ describe('Integration Tests', () => { checkUniqueEmailsSpy = vi .spyOn(UsersService.prototype as any, 'checkUniqueEmails') .mockImplementation(() => vi.fn()); + checkPasswordPolicySpy = vi .spyOn(UsersService.prototype as any, 'checkPasswordPolicy') .mockResolvedValue(() => vi.fn()); + checkRemainingAdminExistenceSpy = vi .spyOn(UsersService.prototype as any, 'checkRemainingAdminExistence') .mockResolvedValue(() => vi.fn()); + checkRemainingActiveAdminSpy = vi .spyOn(UsersService.prototype as any, 'checkRemainingActiveAdmin') .mockResolvedValue(() => vi.fn()); @@ -255,9 +258,11 @@ describe('Integration Tests', () => { } expect(superUpdateManySpy).toHaveBeenCalled(); + expect(superUpdateManySpy.mock.lastCall![2].preMutationException.message).toBe( `You can't change the "${field}" value manually.` ); + expect(superUpdateManySpy.mock.lastCall![2].preMutationException).toBeInstanceOf(InvalidPayloadException); } ); @@ -370,9 +375,11 @@ describe('Integration Tests', () => { } expect(superUpdateManySpy).toHaveBeenCalled(); + expect(superUpdateManySpy.mock.lastCall![2].preMutationException.message).toBe( `You can't change the "${field}" value manually.` ); + expect(superUpdateManySpy.mock.lastCall![2].preMutationException).toBeInstanceOf(InvalidPayloadException); } ); @@ -505,9 +512,11 @@ describe('Integration Tests', () => { } expect(superUpdateManySpy).toHaveBeenCalled(); + expect(superUpdateManySpy.mock.lastCall![2].preMutationException.message).toBe( `You can't change the "${field}" value manually.` ); + expect(superUpdateManySpy.mock.lastCall![2].preMutationException).toBeInstanceOf(InvalidPayloadException); } ); diff --git a/api/src/services/users.ts b/api/src/services/users.ts index 351fa836f2..e4f2eb77a2 100644 --- a/api/src/services/users.ts +++ b/api/src/services/users.ts @@ -345,6 +345,7 @@ export class UsersService extends ItemsService { } const emails = toArray(email); + const mailService = new MailService({ schema: this.schema, accountability: this.accountability, @@ -428,9 +429,11 @@ export class UsersService extends ItemsService { const payload = { email, scope: 'password-reset', hash: getSimpleHash('' + user.password) }; const token = jwt.sign(payload, env['SECRET'] as string, { expiresIn: '1d', issuer: 'directus' }); + const acceptURL = url ? new Url(url).setQuery('token', token).toString() : new Url(env['PUBLIC_URL']).addPath('admin', 'reset-password').setQuery('token', token).toString(); + const subjectLine = subject ? subject : 'Password Reset Request'; await mailService.send({ diff --git a/api/src/services/utils.ts b/api/src/services/utils.ts index 08b6612925..fafe2b3c84 100644 --- a/api/src/services/utils.ts +++ b/api/src/services/utils.ts @@ -61,6 +61,7 @@ export class UtilsService { for (const row of rowsWithoutSortValue) { lastSortValue++; + await this.knex(collection) .update({ [sortField]: lastSortValue }) .where({ [primaryKeyField]: row[primaryKeyField] }); @@ -93,6 +94,7 @@ export class UtilsService { .from(collection) .where({ [primaryKeyField]: to }) .first(); + const targetSortValue = targetSortValueResponse[sortField]; const sourceSortValueResponse = await this.knex @@ -100,6 +102,7 @@ export class UtilsService { .from(collection) .where({ [primaryKeyField]: item }) .first(); + const sourceSortValue = sourceSortValueResponse[sortField]; // Set the target item to the new sort value diff --git a/api/src/services/webhooks.test.ts b/api/src/services/webhooks.test.ts index f341b8275c..fae32793f5 100644 --- a/api/src/services/webhooks.test.ts +++ b/api/src/services/webhooks.test.ts @@ -67,6 +67,7 @@ describe('Integration Tests', () => { relations: [], }, }); + messengerPublishSpy = vi.spyOn(getMessenger(), 'publish'); }); diff --git a/api/src/storage/register-drivers.test.ts b/api/src/storage/register-drivers.test.ts index aa3b47c001..68d46a4365 100644 --- a/api/src/storage/register-drivers.test.ts +++ b/api/src/storage/register-drivers.test.ts @@ -10,6 +10,7 @@ vi.mock('../env'); let mockStorage: StorageManager; let mockDriver: typeof Driver; + let sample: { name: string; }; diff --git a/api/src/storage/register-locations.test.ts b/api/src/storage/register-locations.test.ts index 97bfac951e..566df6025d 100644 --- a/api/src/storage/register-locations.test.ts +++ b/api/src/storage/register-locations.test.ts @@ -18,6 +18,7 @@ let sample: { }; locations: string[]; }; + let mockStorage: StorageManager; beforeEach(() => { @@ -42,9 +43,11 @@ beforeEach(() => { } as unknown as StorageManager; vi.mocked(getConfigFromEnv).mockImplementation((name) => sample.options[name]!); + vi.mocked(getEnv).mockReturnValue({ STORAGE_LOCATIONS: sample.locations.join(', '), }); + vi.mocked(toArray).mockReturnValue(sample.locations); }); @@ -61,6 +64,7 @@ test('Gets config for each location', async () => { await registerLocations(mockStorage); expect(getConfigFromEnv).toHaveBeenCalledTimes(sample.locations.length); + sample.locations.forEach((location) => expect(getConfigFromEnv).toHaveBeenCalledWith(`STORAGE_${location.toUpperCase()}_`) ); @@ -70,6 +74,7 @@ test('Registers location with driver options for each location', async () => { await registerLocations(mockStorage); expect(mockStorage.registerLocation).toHaveBeenCalledTimes(sample.locations.length); + sample.locations.forEach((location) => { const { driver, ...options } = sample.options[`STORAGE_${location.toUpperCase()}_`]!; diff --git a/api/src/utils/apply-diff.ts b/api/src/utils/apply-diff.ts index f1b78f1501..0a16c808dd 100644 --- a/api/src/utils/apply-diff.ts +++ b/api/src/utils/apply-diff.ts @@ -37,6 +37,7 @@ export async function applyDiff( const schema = options?.schema ?? (await getSchema({ database, bypassCache: true })); const nestedActionEvents: ActionEventParams[] = []; + const mutationOptions: MutationOptions = { autoPurgeSystemCache: false, bypassEmitAction: (params) => nestedActionEvents.push(params), @@ -119,6 +120,7 @@ export async function applyDiff( logger.error( `Failed to delete collection "${collection}" due to relation "${relation.collection}.${relation.field}"` ); + throw err; } } @@ -153,6 +155,7 @@ export async function applyDiff( // Check if parent collection already exists in schema const parentExists = currentSnapshot.collections.find((c) => c.collection === groupName) !== undefined; + // If this is a new collection and the parent collection doesn't exist in current schema -> // Check if the parent collection will be created as part of applying this snapshot -> // If yes -> this collection will be created recursively @@ -165,6 +168,7 @@ export async function applyDiff( snapshotDiff.collections.filter( ({ collection, diff }) => diff[0]?.kind === DiffKind.NEW && collection === groupName ).length > 0; + // Has group, but parent is not new, parent is also not being created in this snapshot apply if (parentExists && !parentWillBeCreatedInThisApply) return true; @@ -230,6 +234,7 @@ export async function applyDiff( deepDiff.applyChange(acc, undefined, currentDiff); return acc; }, cloneDeep(currentField)); + await fieldsService.updateField(collection, newValues, mutationOptions); } catch (err) { logger.error(`Failed to update field "${collection}.${field}"`); @@ -286,6 +291,7 @@ export async function applyDiff( deepDiff.applyChange(acc, undefined, currentDiff); return acc; }, cloneDeep(currentRelation)); + await relationsService.updateOne(collection, field, newValues, mutationOptions); } catch (err) { logger.error(`Failed to update relation "${collection}.${field}"`); diff --git a/api/src/utils/apply-query.ts b/api/src/utils/apply-query.ts index 7daa441757..fb9d2eb56a 100644 --- a/api/src/utils/apply-query.ts +++ b/api/src/utils/apply-query.ts @@ -165,6 +165,7 @@ function addJoin({ path, collection, aliasMap, rootQuery, schema, relations, kne `${aliasedParentCollection}.${relation.field}`, `${alias}.${schema.collections[relation.related_collection!]!.primary}` ); + aliasMap[aliasKey]!.collection = relation.related_collection!; } else if (relationType === 'a2o') { const pathScope = pathParts[0]!.split(':')[1]; @@ -187,6 +188,7 @@ function addJoin({ path, collection, aliasMap, rootQuery, schema, relations, kne ) ); }); + aliasMap[aliasKey]!.collection = pathScope; } else if (relationType === 'o2a') { rootQuery.leftJoin({ [alias]: relation.collection }, (joinClause) => { @@ -201,6 +203,7 @@ function addJoin({ path, collection, aliasMap, rootQuery, schema, relations, kne ) ); }); + aliasMap[aliasKey]!.collection = relation.collection; hasMultiRelational = true; @@ -210,6 +213,7 @@ function addJoin({ path, collection, aliasMap, rootQuery, schema, relations, kne `${aliasedParentCollection}.${schema.collections[relation.related_collection!]!.primary}`, `${alias}.${relation.field}` ); + aliasMap[aliasKey]!.collection = relation.collection; hasMultiRelational = true; @@ -295,6 +299,7 @@ export function applySort( relations, schema, }); + const [alias, field] = columnPath.split('.'); if (!hasMultiRelationalSort) { diff --git a/api/src/utils/filter-items.test.ts b/api/src/utils/filter-items.test.ts index f987ec7121..52074499ea 100644 --- a/api/src/utils/filter-items.test.ts +++ b/api/src/utils/filter-items.test.ts @@ -50,6 +50,7 @@ describe('filter items', () => { {}, ], }); + expect(result).toStrictEqual(items.filter((item) => item.action === 'read')); }); @@ -59,6 +60,7 @@ describe('filter items', () => { _eq: 'read', }, }); + expect(result).toStrictEqual(items.filter((item) => item.action === 'read')); }); }); diff --git a/api/src/utils/generate-hash.ts b/api/src/utils/generate-hash.ts index a916ae0323..7ed8b6467a 100644 --- a/api/src/utils/generate-hash.ts +++ b/api/src/utils/generate-hash.ts @@ -3,8 +3,10 @@ import { getConfigFromEnv } from './get-config-from-env.js'; export function generateHash(stringToHash: string): Promise { const argon2HashConfigOptions = getConfigFromEnv('HASH_', 'HASH_RAW'); // Disallow the HASH_RAW option, see https://github.com/directus/directus/discussions/7670#discussioncomment-1255805 + // associatedData, if specified, must be passed as a Buffer to argon2.hash, see https://github.com/ranisalt/node-argon2/wiki/Options#associateddata 'associatedData' in argon2HashConfigOptions && (argon2HashConfigOptions['associatedData'] = Buffer.from(argon2HashConfigOptions['associatedData'])); + return argon2.hash(stringToHash, argon2HashConfigOptions); } diff --git a/api/src/utils/get-ast-from-query.ts b/api/src/utils/get-ast-from-query.ts index 77c576c43f..519e269937 100644 --- a/api/src/utils/get-ast-from-query.ts +++ b/api/src/utils/get-ast-from-query.ts @@ -183,6 +183,7 @@ export default async function getASTFromQuery( query: {}, relatedCollection: foundRelation.collection, }); + continue; } } diff --git a/api/src/utils/get-cache-headers.test.ts b/api/src/utils/get-cache-headers.test.ts index f162564fc3..2890a4d813 100644 --- a/api/src/utils/get-cache-headers.test.ts +++ b/api/src/utils/get-cache-headers.test.ts @@ -204,8 +204,10 @@ describe('get cache headers', () => { return matchingKey ? (scenario.input.headers as any)?.[matchingKey] : undefined; }), } as Partial; + factoryEnv = scenario.input.env; const { ttl, globalCacheSettings, personalized } = scenario.input; + expect(getCacheControlHeader(mockRequest as Request, ttl, globalCacheSettings, personalized)).toEqual( scenario.output ); diff --git a/api/src/utils/get-config-from-env.test.ts b/api/src/utils/get-config-from-env.test.ts index 13ca335982..815e7b004d 100644 --- a/api/src/utils/get-config-from-env.test.ts +++ b/api/src/utils/get-config-from-env.test.ts @@ -8,6 +8,7 @@ vi.mock('../../src/env', () => { CAMELCASE_OBJECT__FIRST_KEY: 'firstValue', CAMELCASE_OBJECT__SECOND_KEY: 'secondValue', }; + return { default: MOCK_ENV, getEnv: () => MOCK_ENV, diff --git a/api/src/utils/get-config-from-env.ts b/api/src/utils/get-config-from-env.ts index 7fe17a891a..3da2875f73 100644 --- a/api/src/utils/get-config-from-env.ts +++ b/api/src/utils/get-config-from-env.ts @@ -30,6 +30,7 @@ export function getConfigFromEnv( const path = key .split('__') .map((key, index) => (index === 0 ? transform(transform(key.slice(prefix.length))) : transform(key))); + set(config, path.join('.'), value); } else { config[transform(key.slice(prefix.length))] = value; diff --git a/api/src/utils/get-graphql-query-and-variables.test.ts b/api/src/utils/get-graphql-query-and-variables.test.ts index 24f9dc8303..d45b6b33b1 100644 --- a/api/src/utils/get-graphql-query-and-variables.test.ts +++ b/api/src/utils/get-graphql-query-and-variables.test.ts @@ -10,6 +10,7 @@ const query = ` } } `; + const variables = JSON.stringify({ id: 1 }); const additionalProperty = 'test'; diff --git a/api/src/utils/get-local-type.ts b/api/src/utils/get-local-type.ts index fb66a124af..671ce4e411 100644 --- a/api/src/utils/get-local-type.ts +++ b/api/src/utils/get-local-type.ts @@ -125,6 +125,7 @@ export default function getLocalType( if (special.includes('uuid') || special.includes('file')) return 'uuid'; if (special.includes('cast-timestamp')) return 'timestamp'; if (special.includes('cast-datetime')) return 'dateTime'; + if (type?.startsWith('geometry')) { return (special[0] as Type) || 'geometry'; } diff --git a/api/src/utils/jwt.test.ts b/api/src/utils/jwt.test.ts index df7ff875b8..abdfa6c5ca 100644 --- a/api/src/utils/jwt.test.ts +++ b/api/src/utils/jwt.test.ts @@ -64,6 +64,7 @@ test('Returns the payload of an access token', () => { const payload = { id: 1, role: 1, app_access: true, admin_access: true }; const token = jwt.sign(payload, secret, options); const result = verifyAccessJWT(token, secret); + expect(result).toEqual({ id: 1, role: 1, diff --git a/api/src/utils/merge-permissions.test.ts b/api/src/utils/merge-permissions.test.ts index e9ae3cf3ff..1b697b2e99 100644 --- a/api/src/utils/merge-permissions.test.ts +++ b/api/src/utils/merge-permissions.test.ts @@ -22,6 +22,7 @@ describe('merging permissions', () => { { ...permissionTemplate, permissions: conditionalFilter }, { ...permissionTemplate, permissions: conditionalFilter2 } ); + expect(mergedPermission).toStrictEqual({ ...permissionTemplate, permissions: { @@ -36,6 +37,7 @@ describe('merging permissions', () => { { ...permissionTemplate, validation: conditionalFilter }, { ...permissionTemplate, validation: conditionalFilter2 } ); + expect(mergedPermission).toStrictEqual({ ...permissionTemplate, validation: { @@ -50,6 +52,7 @@ describe('merging permissions', () => { { ...permissionTemplate, permissions: conditionalFilter }, { ...permissionTemplate, permissions: conditionalFilter2 } ); + expect(mergedPermission).toStrictEqual({ ...permissionTemplate, permissions: { @@ -64,6 +67,7 @@ describe('merging permissions', () => { { ...permissionTemplate, validation: conditionalFilter }, { ...permissionTemplate, validation: conditionalFilter2 } ); + expect(mergedPermission).toStrictEqual({ ...permissionTemplate, validation: { @@ -78,6 +82,7 @@ describe('merging permissions', () => { { ...permissionTemplate, permissions: fullFilter }, { ...permissionTemplate, permissions: conditionalFilter } ); + expect(mergedPermission).toStrictEqual({ ...permissionTemplate, permissions: fullFilter }); }); @@ -87,6 +92,7 @@ describe('merging permissions', () => { { ...permissionTemplate, validation: fullFilter }, { ...permissionTemplate, validation: conditionalFilter } ); + expect(mergedPermission).toStrictEqual({ ...permissionTemplate, validation: fullFilter }); }); @@ -96,6 +102,7 @@ describe('merging permissions', () => { { ...permissionTemplate, permissions: fullFilter }, { ...permissionTemplate, permissions: conditionalFilter } ); + const expectedPermission = { ...permissionTemplate, permissions: { @@ -112,6 +119,7 @@ describe('merging permissions', () => { { ...permissionTemplate, validation: fullFilter }, { ...permissionTemplate, validation: conditionalFilter } ); + const expectedPermission = { ...permissionTemplate, validation: { diff --git a/api/src/utils/sanitize-schema.ts b/api/src/utils/sanitize-schema.ts index 707fd1d735..c54780e6de 100644 --- a/api/src/utils/sanitize-schema.ts +++ b/api/src/utils/sanitize-schema.ts @@ -27,6 +27,7 @@ export function sanitizeField(field: Field | undefined, sanitizeAllSchema = fals if (!field) return field; const defaultPaths = ['collection', 'field', 'type', 'meta', 'name', 'children']; + const pickedPaths = sanitizeAllSchema ? defaultPaths : [ diff --git a/api/src/utils/url.ts b/api/src/utils/url.ts index 725885b452..2c64f428b5 100644 --- a/api/src/utils/url.ts +++ b/api/src/utils/url.ts @@ -19,6 +19,7 @@ export class Url { !isProtocolRelative && !isRootRelative && !isPathRelative ? parsedUrl.protocol.substring(0, parsedUrl.protocol.length - 1) : null; + this.host = !isRootRelative && !isPathRelative ? parsedUrl.hostname : null; this.port = parsedUrl.port !== '' ? parsedUrl.port : null; this.path = parsedUrl.pathname.split('/').filter((p) => p !== ''); diff --git a/api/src/utils/validate-diff.test.ts b/api/src/utils/validate-diff.test.ts index 63f944dbf2..64c8c804d1 100644 --- a/api/src/utils/validate-diff.test.ts +++ b/api/src/utils/validate-diff.test.ts @@ -22,6 +22,7 @@ test('should fail on invalid hash', () => { hash: 'abc', diff: { collections: [{ collection: 'test', diff: [] }], fields: [], relations: [] }, } as SnapshotDiffWithHash; + const snapshot = { hash: 'xyz' } as SnapshotWithHash; expect(() => validateApplyDiff(diff, snapshot)).toThrowError( @@ -56,6 +57,7 @@ describe('should throw accurate error', () => { const diff = baseDiff({ collections: [{ collection: 'test', diff: [{ kind: 'N', rhs: {} as Collection }] }], }); + const snapshot = baseSnapshot({ collections: [{ collection: 'test' } as Collection] }); expect(() => validateApplyDiff(diff, snapshot)).toThrowError( @@ -77,6 +79,7 @@ describe('should throw accurate error', () => { const diff = baseDiff({ fields: [{ collection: 'test', field: 'test', diff: [{ kind: 'N', rhs: {} as SnapshotField }] }], }); + const snapshot = baseSnapshot({ fields: [{ collection: 'test', field: 'test' } as SnapshotField] }); expect(() => validateApplyDiff(diff, snapshot)).toThrowError( @@ -105,6 +108,7 @@ describe('should throw accurate error', () => { }, ], }); + const snapshot = baseSnapshot({ relations: [{ collection: 'test', field: 'test', related_collection: 'relation' } as SnapshotRelation], }); @@ -248,6 +252,7 @@ test('should not throw error for diffs with varying types of lhs/rhs', () => { ], }, }; + const snapshot = { hash: 'abc' } as SnapshotWithHash; expect(() => validateApplyDiff(diff, snapshot)).not.toThrow(); @@ -289,6 +294,7 @@ test('should not throw error for relation diff with null related_collection (app ], }, }; + const snapshot = { hash: 'abc' } as SnapshotWithHash; expect(() => validateApplyDiff(diff, snapshot)).not.toThrow(); @@ -299,6 +305,7 @@ test('should detect empty diff', () => { hash: 'abc', diff: { collections: [], fields: [], relations: [] }, }; + const snapshot = {} as SnapshotWithHash; expect(validateApplyDiff(diff, snapshot)).toBe(false); @@ -309,6 +316,7 @@ test('should pass on valid diff', () => { hash: 'abc', diff: { collections: [{ collection: 'test', diff: [] }], fields: [], relations: [] }, }; + const snapshot = { hash: 'abc' } as SnapshotWithHash; expect(validateApplyDiff(diff, snapshot)).toBe(true); diff --git a/api/src/utils/validate-env.test.ts b/api/src/utils/validate-env.test.ts index f6f79996e9..c1d9533d1c 100644 --- a/api/src/utils/validate-env.test.ts +++ b/api/src/utils/validate-env.test.ts @@ -8,6 +8,7 @@ vi.mock('../env', () => ({ PRESENT_TEST_VARIABLE: true, }), })); + vi.mock('../logger', () => ({ default: { error: vi.fn(), diff --git a/api/src/utils/validate-keys.test.ts b/api/src/utils/validate-keys.test.ts index 8722e508a6..e5a0b7d7e5 100644 --- a/api/src/utils/validate-keys.test.ts +++ b/api/src/utils/validate-keys.test.ts @@ -92,6 +92,7 @@ describe('validate keys', () => { expect(() => validateKeys(schema, 'pk_uuid', 'id', [uuid(), 'fakeuuid-62d9-434d-a7c7-878c8376782e', uuid()]) ).toThrowError(); + expect(() => validateKeys(schema, 'pk_uuid', 'id', [uuid(), 'invalid', uuid()])).toThrowError(); expect(() => validateKeys(schema, 'pk_uuid', 'id', [uuid(), NaN, uuid()])).toThrowError(); expect(() => validateKeys(schema, 'pk_uuid', 'id', [uuid(), 111, uuid()])).toThrowError(); diff --git a/api/src/utils/validate-query.test.ts b/api/src/utils/validate-query.test.ts index 1e1f5e61ec..d410ca2caf 100644 --- a/api/src/utils/validate-query.test.ts +++ b/api/src/utils/validate-query.test.ts @@ -3,10 +3,12 @@ import { validateQuery } from './validate-query.js'; vi.mock('../env', async () => { const actual = (await vi.importActual('../env')) as { default: Record }; + const MOCK_ENV = { ...actual.default, MAX_QUERY_LIMIT: 100, }; + return { default: MOCK_ENV, getEnv: () => MOCK_ENV, diff --git a/api/src/utils/validate-query.ts b/api/src/utils/validate-query.ts index 6ec5b10a69..0a240d9765 100644 --- a/api/src/utils/validate-query.ts +++ b/api/src/utils/validate-query.ts @@ -134,6 +134,7 @@ function validateList(value: any, key: string) { function validateBoolean(value: any, key: string) { if (value === null) return true; + if (typeof value !== 'boolean') { throw new InvalidQueryException(`"${key}" has to be a boolean`); } @@ -143,6 +144,7 @@ function validateBoolean(value: any, key: string) { function validateGeometry(value: any, key: string) { if (value === null) return true; + try { stringify(value); } catch { diff --git a/app/src/__utils__/router.ts b/app/src/__utils__/router.ts index 60b421ab29..b62f754c46 100644 --- a/app/src/__utils__/router.ts +++ b/app/src/__utils__/router.ts @@ -5,5 +5,6 @@ export function generateRouter(routes: RouteRecordRaw[]) { history: createWebHistory(), routes, }); + return router; } diff --git a/app/src/app.vue b/app/src/app.vue index 4078c143ac..1a020c7856 100644 --- a/app/src/app.vue +++ b/app/src/app.vue @@ -69,6 +69,7 @@ export default defineComponent({ if (theme) { document.body.classList.add(theme); + document .querySelector('head meta[name="theme-color"]') ?.setAttribute('content', theme === 'light' ? '#ffffff' : '#263238'); diff --git a/app/src/components/transition/expand-methods.ts b/app/src/components/transition/expand-methods.ts index c8ba806a54..2a601d574b 100644 --- a/app/src/components/transition/expand-methods.ts +++ b/app/src/components/transition/expand-methods.ts @@ -35,6 +35,7 @@ export default function ( emit('beforeEnter'); el._parent = el.parentNode as (Node & ParentNode & HTMLElement) | null; + el._initialStyle = { transition: el.style.transition, visibility: el.style.visibility, @@ -84,6 +85,7 @@ export default function ( emit('beforeLeave'); el._parent = el.parentNode as (Node & ParentNode & HTMLElement) | null; + el._initialStyle = { transition: el.style.transition, visibility: el.style.visibility, diff --git a/app/src/components/v-button.test.ts b/app/src/components/v-button.test.ts index cc40959327..7c0cc4ca09 100644 --- a/app/src/components/v-button.test.ts +++ b/app/src/components/v-button.test.ts @@ -23,6 +23,7 @@ beforeEach(async () => { component: h('div', 'empty'), }, ]); + router.push('/'); await router.isReady(); diff --git a/app/src/components/v-checkbox-tree/v-checkbox-tree.test.ts b/app/src/components/v-checkbox-tree/v-checkbox-tree.test.ts index 955186e005..bc7edaeeee 100644 --- a/app/src/components/v-checkbox-tree/v-checkbox-tree.test.ts +++ b/app/src/components/v-checkbox-tree/v-checkbox-tree.test.ts @@ -27,6 +27,7 @@ beforeEach(async () => { component: h('div', 'empty'), }, ]); + router.push('/'); await router.isReady(); diff --git a/app/src/components/v-emoji-picker.vue b/app/src/components/v-emoji-picker.vue index 2720d578ba..12a306ae8c 100644 --- a/app/src/components/v-emoji-picker.vue +++ b/app/src/components/v-emoji-picker.vue @@ -14,6 +14,7 @@ const emojiPicker = new EmojiButton({ position: 'bottom', emojisPerRow: 8, }); + const emit = defineEmits(['emoji-selected']); emojiPicker.on('emoji', (event) => { diff --git a/app/src/components/v-error-boundary.test.ts b/app/src/components/v-error-boundary.test.ts index d8ed17d6cc..7956fde085 100644 --- a/app/src/components/v-error-boundary.test.ts +++ b/app/src/components/v-error-boundary.test.ts @@ -44,6 +44,7 @@ test('Should show fallback component when there is an error', async () => { }, render: () => h('div', 'test'), }); + const fallbackComponent = defineComponent({ render: () => h('div', 'fallback') }); const wrapper = mount(VErrorBoundary, { diff --git a/app/src/components/v-error.vue b/app/src/components/v-error.vue index bb71af69c7..fa364e405e 100644 --- a/app/src/components/v-error.vue +++ b/app/src/components/v-error.vue @@ -47,9 +47,11 @@ const { isCopySupported, copyToClipboard } = useClipboard(); async function copyError() { const error = props.error?.response?.data || props.error; + const isCopied = await copyToClipboard( JSON.stringify(error, isPlainObject(error) ? null : Object.getOwnPropertyNames(error), 2) ); + if (!isCopied) return; copied.value = true; } diff --git a/app/src/components/v-field-list/v-field-list.vue b/app/src/components/v-field-list/v-field-list.vue index ee120b0d0a..62375903e3 100644 --- a/app/src/components/v-field-list/v-field-list.vue +++ b/app/src/components/v-field-list/v-field-list.vue @@ -118,6 +118,7 @@ function filter(field: Field, parent?: FieldNode): boolean { const children = isNil(field.schema?.foreign_key_table) ? fieldsStore.getFieldGroupChildren(field.collection, field.field) : fieldsStore.getFieldsForCollection(field.schema!.foreign_key_table); + return children?.some((field) => matchesSearch(field)) || matchesSearch(field); function matchesSearch(field: Field) { diff --git a/app/src/components/v-field-template/v-field-template.vue b/app/src/components/v-field-template/v-field-template.vue index 66dd7e9124..a8b2b5525c 100644 --- a/app/src/components/v-field-template/v-field-template.vue +++ b/app/src/components/v-field-template/v-field-template.vue @@ -288,6 +288,7 @@ function setContent() { }`; }) .join(''); + contentEl.value.innerHTML = newInnerHTML; } } diff --git a/app/src/components/v-form/form-field-raw-editor.test.ts b/app/src/components/v-form/form-field-raw-editor.test.ts index d12242bbb6..e1f729c5f0 100644 --- a/app/src/components/v-form/form-field-raw-editor.test.ts +++ b/app/src/components/v-form/form-field-raw-editor.test.ts @@ -23,12 +23,14 @@ test('should render', () => { }, global, }); + expect(wrapper.html()).toMatchSnapshot(); }); // test if there is a value test('submitting', async () => { expect(formFieldRawEditor).toBeTruthy(); + const wrapper = mount(formFieldRawEditor, { props: { showModal: true, @@ -38,6 +40,7 @@ test('submitting', async () => { }, global, }); + const button = wrapper.findAll('v-button').at(1); await button!.trigger('click'); await wrapper.vm.$nextTick(); @@ -54,6 +57,7 @@ it('should cancel with keydown', async () => { }, global, }); + await wrapper.trigger('esc'); await wrapper.vm.$nextTick(); expect(wrapper.emitted().cancel.length).toBe(1); @@ -69,6 +73,7 @@ it('should cancel with the cancel button', async () => { }, global, }); + const button = wrapper.findAll('v-button').at(0); await button!.trigger('click'); await wrapper.vm.$nextTick(); diff --git a/app/src/components/v-form/form-field.vue b/app/src/components/v-form/form-field.vue index 36091779b5..932921f08c 100644 --- a/app/src/components/v-form/form-field.vue +++ b/app/src/components/v-form/form-field.vue @@ -179,6 +179,7 @@ function useRaw() { async function pasteRaw() { const pastedValue = await pasteFromClipboard(); if (!pastedValue) return; + try { internalValue.value = parseJSON(pastedValue); } catch (e) { @@ -194,6 +195,7 @@ function useRaw() { function useComputedValues() { const defaultValue = computed(() => props.field?.schema?.default_value); const internalValue = ref(getInternalValue()); + const isEdited = computed( () => props.modelValue !== undefined && isEqual(props.modelValue, props.initialValue) === false ); diff --git a/app/src/components/v-form/v-form.vue b/app/src/components/v-form/v-form.vue index ca19276f1c..2ed2728dc9 100644 --- a/app/src/components/v-form/v-form.vue +++ b/app/src/components/v-form/v-form.vue @@ -251,6 +251,7 @@ function useForm() { (field: Field) => field.meta?.group === props.group || (props.group === null && isNil(field.meta?.group)) ) ); + const fieldNames = computed(() => { return fieldsInGroup.value.map((f) => f.field); }); @@ -342,6 +343,7 @@ function apply(updates: { [field: string]: any }) { const groupFields = getFieldsForGroup(props.group) .filter((field) => !field.schema?.is_primary_key && !isDisabled(field)) .map((field) => field.field); + emit('update:modelValue', assign({}, omit(props.modelValue, groupFields), pick(updates, updatableKeys))); } else { emit('update:modelValue', pick(assign({}, props.modelValue, updates), updatableKeys)); @@ -366,6 +368,7 @@ function useBatch() { function toggleBatchField(field: Field | undefined) { if (!field) return; + if (batchActiveFields.value.includes(field.field)) { batchActiveFields.value = batchActiveFields.value.filter((fieldKey) => fieldKey !== field.field); @@ -390,6 +393,7 @@ function useRawEditor() { function toggleRawField(field: Field | undefined) { if (!field) return; + if (rawActiveFields.value.has(field.field)) { rawActiveFields.value.delete(field.field); } else { diff --git a/app/src/components/v-image.vue b/app/src/components/v-image.vue index 28cb35fdea..715d052de8 100644 --- a/app/src/components/v-image.vue +++ b/app/src/components/v-image.vue @@ -19,6 +19,7 @@ const imageElement = ref(); const emptyPixel = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII='; + const srcData = ref(emptyPixel); const observer = new IntersectionObserver((entries, observer) => { @@ -31,6 +32,7 @@ const observer = new IntersectionObserver((entries, observer) => { loadImage(); } }); + watch( () => props.src, () => { diff --git a/app/src/components/v-input.vue b/app/src/components/v-input.vue index c3e3d8f0ff..07965fdee3 100644 --- a/app/src/components/v-input.vue +++ b/app/src/components/v-input.vue @@ -151,6 +151,7 @@ const listeners = computed(() => ({ }, focus: (e: PointerEvent) => emit('focus', e), })); + const attributes = computed(() => omit(attrs, ['class'])); const classes = computed(() => [ diff --git a/app/src/components/v-item-group.vue b/app/src/components/v-item-group.vue index 96d9e999bf..537881150c 100644 --- a/app/src/components/v-item-group.vue +++ b/app/src/components/v-item-group.vue @@ -32,6 +32,7 @@ const props = withDefaults(defineProps(), { const emit = defineEmits(['update:modelValue']); const { modelValue: selection, multiple, max, mandatory } = toRefs(props); + useGroupableParent( { selection: selection, diff --git a/app/src/components/v-item.vue b/app/src/components/v-item.vue index 8a8affaf66..e2dbaa41af 100644 --- a/app/src/components/v-item.vue +++ b/app/src/components/v-item.vue @@ -27,6 +27,7 @@ const props = withDefaults(defineProps(), { }); const { active } = toRefs(props); + const { active: isActive, toggle } = useGroupable({ value: props.value, group: props.scope, diff --git a/app/src/components/v-menu.vue b/app/src/components/v-menu.vue index d3726a7af3..6d30f0ac20 100644 --- a/app/src/components/v-menu.vue +++ b/app/src/components/v-menu.vue @@ -242,6 +242,7 @@ function onClickOutsideMiddleware(e: Event) { function onContentClick(e: Event) { e.stopPropagation(); + if (e.target !== e.currentTarget) { deactivate(); } @@ -329,7 +330,9 @@ function usePopper( modifiers: getModifiers(resolve), strategy: 'fixed', }); + popperInstance.value.forceUpdate(); + observer.observe(popper.value!, { attributes: false, childList: true, diff --git a/app/src/components/v-select/v-select.test.ts b/app/src/components/v-select/v-select.test.ts index 1abbed843e..b2a9617bda 100644 --- a/app/src/components/v-select/v-select.test.ts +++ b/app/src/components/v-select/v-select.test.ts @@ -7,6 +7,7 @@ import { createI18n } from 'vue-i18n'; import { Focus } from '@/__utils__/focus'; const i18n = createI18n({ legacy: false }); + const global: GlobalMountOptions = { stubs: [ 'v-list', diff --git a/app/src/components/v-select/v-select.vue b/app/src/components/v-select/v-select.vue index 5f0e980bb7..ea09e644c5 100644 --- a/app/src/components/v-select/v-select.vue +++ b/app/src/components/v-select/v-select.vue @@ -218,9 +218,11 @@ const { t } = useI18n(); const { internalItems, internalItemsCount, internalSearch } = useItems(); const { displayValue } = useDisplayValue(); const { modelValue } = toRefs(props); + const { otherValue, usesOtherValue } = useCustomSelection(modelValue as Ref, internalItems, (value) => emit('update:modelValue', value) ); + const { otherValues, addOtherValue, setOtherValue } = useCustomSelectionMultiple( modelValue as Ref, internalItems, @@ -228,6 +230,7 @@ const { otherValues, addOtherValue, setOtherValue } = useCustomSelectionMultiple ); const search = ref(null); + watch( search, debounce((val: string | null) => { diff --git a/app/src/components/v-table/table-header.vue b/app/src/components/v-table/table-header.vue index d13ee4527b..decb0a30eb 100644 --- a/app/src/components/v-table/table-header.vue +++ b/app/src/components/v-table/table-header.vue @@ -233,6 +233,7 @@ function onMouseMove(event: PointerEvent) { if (resizing.value === true) { const newWidth = resizeStartWidth.value + (event.pageX - resizeStartX.value); const currentHeaders = clone(props.headers); + const newHeaders = currentHeaders.map((existing: Header) => { if (existing.value === resizeHeader.value?.value) { return { @@ -243,6 +244,7 @@ function onMouseMove(event: PointerEvent) { return existing; }); + emit('update:headers', newHeaders); } } diff --git a/app/src/components/v-table/v-table.vue b/app/src/components/v-table/v-table.vue index dc4bab1eea..7b9132d0ab 100644 --- a/app/src/components/v-table/v-table.vue +++ b/app/src/components/v-table/v-table.vue @@ -298,6 +298,7 @@ function getSelectedState(item: Item) { const selectedKeys = props.selectionUseKeys ? props.modelValue : props.modelValue.map((item: any) => item[props.itemKey]); + return selectedKeys.includes(item[props.itemKey]); } diff --git a/app/src/components/v-template-input.vue b/app/src/components/v-template-input.vue index f000e69211..7323a492cf 100644 --- a/app/src/components/v-template-input.vue +++ b/app/src/components/v-template-input.vue @@ -86,6 +86,7 @@ function checkKeyDown(event: any) { input.value!.innerText.substring(caretPos), true ); + position(input.value!, caretPos + 1); } } else if (event.code === 'ArrowUp' && !event.shiftKey) { @@ -123,6 +124,7 @@ function checkKeyDown(event: any) { event.preventDefault(); const newCaretPos = matchedPositions[checkCaretPos - 1]; + parseHTML( (input.value!.innerText.substring(0, newCaretPos) + input.value!.innerText.substring(caretPos)).replaceAll( String.fromCharCode(160), @@ -130,6 +132,7 @@ function checkKeyDown(event: any) { ), true ); + position(input.value!, newCaretPos); emit('update:modelValue', input.value!.innerText); } @@ -146,6 +149,7 @@ function checkKeyDown(event: any) { ).replaceAll(String.fromCharCode(160), ' '), true ); + position(input.value!, caretPos); emit('update:modelValue', input.value!.innerText); } @@ -257,6 +261,7 @@ function parseHTML(innerText?: string, isDirectInput = false) { } let searchString = replaceSpaceBefore + match + replaceSpaceAfter; + let replacementString = `${addSpaceBefore}${match}${addSpaceAfter}`; @@ -280,6 +285,7 @@ function parseHTML(innerText?: string, isDirectInput = false) { } lastMatchIndex = 0; + for (const match of matches ?? []) { let matchIndex = input.value.innerText.indexOf(match, lastMatchIndex); matchedPositions.push(matchIndex, matchIndex + match.length); diff --git a/app/src/components/v-upload.vue b/app/src/components/v-upload.vue index 396110bf4b..e2d59eed01 100644 --- a/app/src/components/v-upload.vue +++ b/app/src/components/v-upload.vue @@ -131,6 +131,7 @@ const filterByFolder = computed(() => { function validFiles(files: FileList) { if (files.length === 0) return false; + for (const file of files) { if (file.size === 0) return false; } @@ -162,6 +163,7 @@ function useUpload() { } numberOfFiles.value = files.length; + if (props.multiple === true) { const uploadedFiles = await uploadFiles(Array.from(files), { onProgressChange: (percentage) => { diff --git a/app/src/components/v-workspace-tile.vue b/app/src/components/v-workspace-tile.vue index f85db991c8..eb9e0ae7ec 100644 --- a/app/src/components/v-workspace-tile.vue +++ b/app/src/components/v-workspace-tile.vue @@ -306,6 +306,7 @@ function useDragDrop() { function onPointerUp() { dragging.value = false; + if ( props.editMode === false || props.draggable === false || diff --git a/app/src/composables/use-alias-fields.ts b/app/src/composables/use-alias-fields.ts index cc6899e667..9be196d01a 100644 --- a/app/src/composables/use-alias-fields.ts +++ b/app/src/composables/use-alias-fields.ts @@ -24,6 +24,7 @@ export function useAliasFields(fields: Ref, collection: Ref { if (field.includes('.')) { return `${alias}.${field.split('.').slice(1).join('.')}`; diff --git a/app/src/composables/use-clipboard.test.ts b/app/src/composables/use-clipboard.test.ts index 2c5a397871..b4ce61bba9 100644 --- a/app/src/composables/use-clipboard.test.ts +++ b/app/src/composables/use-clipboard.test.ts @@ -85,6 +85,7 @@ describe('useClipboard', () => { value: { writeText: vi.fn().mockImplementation(() => Promise.reject()) }, configurable: true, }); + const copyValue = 'test'; const wrapper = mount(testComponent, { global }); diff --git a/app/src/composables/use-clipboard.ts b/app/src/composables/use-clipboard.ts index 74b649f272..738ecdaa39 100644 --- a/app/src/composables/use-clipboard.ts +++ b/app/src/composables/use-clipboard.ts @@ -24,15 +24,18 @@ export function useClipboard() { try { const valueString = typeof value === 'string' ? value : JSON.stringify(value); await navigator?.clipboard?.writeText(valueString); + notify({ title: message?.success ?? t('copy_raw_value_success'), }); + return true; } catch (err: any) { notify({ type: 'error', title: message?.fail ?? t('copy_raw_value_fail'), }); + return false; } } @@ -40,15 +43,18 @@ export function useClipboard() { async function pasteFromClipboard(message?: Message): Promise { try { const pasteValue = await navigator?.clipboard?.readText(); + notify({ title: message?.success ?? t('paste_raw_value_success'), }); + return pasteValue; } catch (err: any) { notify({ type: 'error', title: message?.fail ?? t('paste_raw_value_fail'), }); + return null; } } diff --git a/app/src/composables/use-dialog-route.ts b/app/src/composables/use-dialog-route.ts index bb690dce10..fbfeb5b56a 100644 --- a/app/src/composables/use-dialog-route.ts +++ b/app/src/composables/use-dialog-route.ts @@ -7,6 +7,7 @@ export function useDialogRoute(): Ref { const isOpen = ref(false); let resolveGuard: () => void; + const leaveGuard: Promise = new Promise((resolve) => { resolveGuard = resolve; }); diff --git a/app/src/composables/use-field-tree.test.ts b/app/src/composables/use-field-tree.test.ts index 2a298c9685..2fb88b1eef 100644 --- a/app/src/composables/use-field-tree.test.ts +++ b/app/src/composables/use-field-tree.test.ts @@ -23,6 +23,7 @@ import { Field, Relation } from '@directus/types'; test('Returns tree list of same length', () => { const fieldsStore = useFieldsStore(); + fieldsStore.fields = [ { collection: 'a', @@ -82,6 +83,7 @@ test('Returns tree list of same length', () => { test('Returns tree list with injected field', () => { const fieldsStore = useFieldsStore(); + fieldsStore.fields = [ { collection: 'a', @@ -173,6 +175,7 @@ test('Returns tree list with injected field', () => { test('Returns tree list with filter', () => { const fieldsStore = useFieldsStore(); + fieldsStore.fields = [ { collection: 'a', @@ -221,6 +224,7 @@ test('Returns tree list with filter', () => { const { treeList } = useFieldTree(ref('a'), undefined, filterIntegerFields); expect(unref(treeList)).toHaveLength(1); + expect(unref(treeList)).toEqual([ { name: 'ID', field: 'id', collection: 'a', relatedCollection: undefined, key: 'id', path: 'id', type: 'integer' }, ]); @@ -228,6 +232,7 @@ test('Returns tree list with filter', () => { test('Returns tree list with group', () => { const fieldsStore = useFieldsStore(); + fieldsStore.fields = [ { collection: 'a', @@ -313,6 +318,7 @@ test('Returns tree list with group', () => { test('Returns tree list for O2M', () => { const fieldsStore = useFieldsStore(); + fieldsStore.fields = [ { collection: 'a', @@ -402,6 +408,7 @@ test('Returns tree list for O2M', () => { ] as Field[]; const relationsStore = useRelationsStore(); + relationsStore.relations = [ { collection: 'b', @@ -468,6 +475,7 @@ test('Returns tree list for O2M', () => { test('Returns tree list for M2O', () => { const fieldsStore = useFieldsStore(); + fieldsStore.fields = [ { collection: 'a', @@ -523,6 +531,7 @@ test('Returns tree list for M2O', () => { ] as Field[]; const relationsStore = useRelationsStore(); + relationsStore.relations = [ { collection: 'a', @@ -572,6 +581,7 @@ test('Returns tree list for M2O', () => { test('Returns tree list for M2A with single related collection', () => { const fieldsStore = useFieldsStore(); + fieldsStore.fields = [ { collection: 'a', @@ -695,6 +705,7 @@ test('Returns tree list for M2A with single related collection', () => { ] as Field[]; const relationsStore = useRelationsStore(); + relationsStore.relations = [ { collection: 'a_m2a', @@ -788,6 +799,7 @@ test('Returns tree list for M2A with single related collection', () => { test('Returns tree list for M2A with multiple related collections', () => { const fieldsStore = useFieldsStore(); + fieldsStore.fields = [ { collection: 'a', @@ -939,6 +951,7 @@ test('Returns tree list for M2A with multiple related collections', () => { ] as Field[]; const relationsStore = useRelationsStore(); + relationsStore.relations = [ { collection: 'a_m2a', diff --git a/app/src/composables/use-item.ts b/app/src/composables/use-item.ts index 081a20a807..9a67f428f4 100644 --- a/app/src/composables/use-item.ts +++ b/app/src/composables/use-item.ts @@ -194,6 +194,7 @@ export function useItem( for (const relation of relations) { const relatedPrimaryKeyField = fieldsStore.getPrimaryKeyFieldForCollection(relation.collection); + const existsJunctionRelated = relationsStore.relations.find((r) => { return r.collection === relation.collection && r.meta?.many_field === relation.meta?.junction_field; }); @@ -296,6 +297,7 @@ export function useItem( [`filter[${relatedPrimaryKeyField!.field}][_in]`]: existingIds.join(','), }, }); + existingItems = response.data.data; } diff --git a/app/src/composables/use-permissions.test.ts b/app/src/composables/use-permissions.test.ts index c6b7c1071b..72380f7288 100644 --- a/app/src/composables/use-permissions.test.ts +++ b/app/src/composables/use-permissions.test.ts @@ -27,6 +27,7 @@ const mockUser = { id: '00000000-0000-0000-0000-000000000000', }, }; + const mockReadPermissions = { role: '00000000-0000-0000-0000-000000000000', permissions: { @@ -49,6 +50,7 @@ const mockReadPermissions = { collection: 'test', action: 'read', }; + const mockFields: Field[] = [ { collection: 'test', @@ -116,6 +118,7 @@ describe('usePermissions', () => { const { fields } = usePermissions(ref('test'), ref(null), ref(false)); expect(fields.value.length).toBeGreaterThan(0); + for (const field of fields.value) { expect(mockReadPermissions.fields.includes(field.field)).toBe(true); } diff --git a/app/src/composables/use-preset.ts b/app/src/composables/use-preset.ts index 0aeec97638..01a3d8e9b7 100644 --- a/app/src/composables/use-preset.ts +++ b/app/src/composables/use-preset.ts @@ -56,11 +56,13 @@ export function usePreset( busy.value = true; const updatedValues = await presetsStore.savePreset(preset ? preset : localPreset.value); + localPreset.value = { ...localPreset.value, id: updatedValues.id, user: updatedValues.user, }; + bookmarkSaved.value = true; busy.value = false; return updatedValues; diff --git a/app/src/composables/use-relation-multiple.test.ts b/app/src/composables/use-relation-multiple.test.ts index 807a8f4a83..45b3557129 100644 --- a/app/src/composables/use-relation-multiple.test.ts +++ b/app/src/composables/use-relation-multiple.test.ts @@ -129,6 +129,7 @@ describe('test o2m relation', () => { ...workerData, { name: 'test5', facility: 1, $type: 'created', $index: 0 }, ]); + expect(wrapper.emitted()['update:value'][0]).toEqual([ { create: [ @@ -166,6 +167,7 @@ describe('test o2m relation', () => { ...workerData, { name: 'test5 edited', facility: 2, $type: 'created', $index: 0 }, ]); + expect(wrapper.emitted()['update:value'][1]).toEqual([ { create: [ @@ -209,6 +211,7 @@ describe('test o2m relation', () => { changes.splice(1, 1, { id: 2, name: 'test2-edited', facility: 1, $edits: 0, $type: 'updated', $index: 0 }); expect(wrapper.vm.displayItems).toEqual(changes); + expect(wrapper.emitted()['update:value'][0]).toEqual([ { create: [], @@ -255,6 +258,7 @@ describe('test o2m relation', () => { changes.splice(0, 1, { id: 1, name: 'test', facility: 1, $type: 'deleted', $index: 0 }); expect(wrapper.vm.displayItems).toEqual(changes); + expect(wrapper.emitted()['update:value'][2]).toEqual([ { create: [], diff --git a/app/src/composables/use-relation-multiple.ts b/app/src/composables/use-relation-multiple.ts index 7bdff15ed5..cbe7e1ca26 100644 --- a/app/src/composables/use-relation-multiple.ts +++ b/app/src/composables/use-relation-multiple.ts @@ -116,6 +116,7 @@ export function useRelationMultiple( const editsIndex = _value.value.update.findIndex( (edit) => typeof edit === 'object' && edit[targetPKField] === item[targetPKField] ); + const deleteIndex = _value.value.delete.findIndex((id) => id === item[targetPKField]); let updatedItem: Record = cloneDeep(item); @@ -157,6 +158,7 @@ export function useRelationMultiple( edit[relation.value.junctionField.field][relation.value.relatedPrimaryKeyField.field] === item[relation.value.junctionField.field][relation.value.relatedPrimaryKeyField.field] ); + case 'm2a': { const itemCollection = item[relation.value.collectionField.field]; const editCollection = edit[relation.value.collectionField.field]; @@ -171,6 +173,7 @@ export function useRelationMultiple( } } }); + if (!fetchedItem) return edit; return merge({}, fetchedItem, edit); }); @@ -275,6 +278,7 @@ export function useRelationMultiple( [info.relatedPrimaryKeyField.field]: item, }, }; + case 'm2a': { if (!collection) throw new Error('You need to provide a collection on an m2a'); @@ -315,6 +319,7 @@ export function useRelationMultiple( targetCollection = relation.value.junctionCollection.collection; fields.add(relation.value.junctionPrimaryKeyField.field); fields.add(relation.value.collectionField.field); + for (const collection of relation.value.allowedCollections) { const pkField = relation.value.relationPrimaryKeyFields[collection.collection]; fields.add(`${relation.value.junctionField.field}:${collection.collection}.${pkField.field}`); @@ -466,6 +471,7 @@ export function useRelationMultiple( return item[relation.value.relatedPrimaryKeyField.field]; case 'm2m': return item[relation.value.junctionField.field][relation.value.relatedPrimaryKeyField.field]; + case 'm2a': { const collection = item[relation.value.collectionField.field]; return item[relation.value.junctionPrimaryKeyField.field][ @@ -533,6 +539,7 @@ export function useRelationMultiple( return acc; }, []) ); + fields.add(relation.relatedPrimaryKeyField.field); const relatedPKField = relation.relatedPrimaryKeyField.field; @@ -606,6 +613,7 @@ export function useRelationMultiple( [relation.junctionField.field]: item, })) ); + return acc; }, [] as Record[]); } diff --git a/app/src/composables/use-translation-strings.ts b/app/src/composables/use-translation-strings.ts index e6731e697c..785a3ff64c 100644 --- a/app/src/composables/use-translation-strings.ts +++ b/app/src/composables/use-translation-strings.ts @@ -110,6 +110,7 @@ export function useTranslationStrings(): UsableTranslationStrings { try { const settingsStore = useSettingsStore(); await settingsStore.updateSettings({ translation_strings: payload }, false); + if (settingsStore.settings?.translation_strings) { translationStrings.value = settingsStore.settings.translation_strings.map((p: TranslationStringRaw) => ({ key: p.key, @@ -133,12 +134,14 @@ export function useTranslationStrings(): UsableTranslationStrings { function mergeTranslationStringsForLanguage(lang: Language) { if (!translationStrings?.value) return; + const localeMessages: Record = translationStrings.value.reduce((acc, cur) => { if (!cur.key || !cur.translations) return acc; const translationForCurrentLang = cur.translations.find((t) => t.language === lang); if (!translationForCurrentLang || !translationForCurrentLang.translation) return acc; return { ...acc, [cur.key]: getLiteralInterpolatedTranslation(translationForCurrentLang.translation, true) }; }, {}); + i18n.global.mergeLocaleMessage(lang, localeMessages); } diff --git a/app/src/directives/click-outside.ts b/app/src/directives/click-outside.ts index 6a90ed3fdd..cb627c7ded 100644 --- a/app/src/directives/click-outside.ts +++ b/app/src/directives/click-outside.ts @@ -81,6 +81,7 @@ export function processValue(bindingValue: BindingValue['value']): DirectiveConf if (isFunction) { const binding = bindingValue as Handler; + value = { handler: binding, middleware: () => true, diff --git a/app/src/directives/tooltip.ts b/app/src/directives/tooltip.ts index e6db3052c0..5e257c5cc2 100644 --- a/app/src/directives/tooltip.ts +++ b/app/src/directives/tooltip.ts @@ -51,6 +51,7 @@ export function createEnterHandler(element: HTMLElement, binding: DirectiveBindi updateTooltip(element, binding, tooltip); } else { clearTimeout(tooltipTimer); + tooltipTimer = window.setTimeout(() => { animateIn(tooltip); updateTooltip(element, binding, tooltip); diff --git a/app/src/displays/datetime/index.ts b/app/src/displays/datetime/index.ts index 3d6b3f630c..7051c3b9ce 100644 --- a/app/src/displays/datetime/index.ts +++ b/app/src/displays/datetime/index.ts @@ -51,6 +51,7 @@ export default defineDisplay({ }, options: ({ field }) => { const options = field.meta?.display_options || {}; + const fields = [ { field: 'relative', diff --git a/app/src/displays/formatted-json-value/formatted-json-value.vue b/app/src/displays/formatted-json-value/formatted-json-value.vue index 4d44045331..0f57535cbd 100644 --- a/app/src/displays/formatted-json-value/formatted-json-value.vue +++ b/app/src/displays/formatted-json-value/formatted-json-value.vue @@ -41,6 +41,7 @@ export default defineComponent({ }, setup(props) { const { t } = useI18n(); + const displayValue = computed(() => { if (!props.value) return null; diff --git a/app/src/interfaces/_system/system-display-template/system-display-template.vue b/app/src/interfaces/_system/system-display-template/system-display-template.vue index 989792f622..195b2498fd 100644 --- a/app/src/interfaces/_system/system-display-template/system-display-template.vue +++ b/app/src/interfaces/_system/system-display-template/system-display-template.vue @@ -57,6 +57,7 @@ export default defineComponent({ const collectionExists = !!collectionsStore.collections.find( (collection) => collection.collection === collectionName ); + if (collectionExists === false) return null; return collectionName; }); diff --git a/app/src/interfaces/collection-item-dropdown/collection-item-dropdown.vue b/app/src/interfaces/collection-item-dropdown/collection-item-dropdown.vue index cdaec061fe..9c375a3ead 100644 --- a/app/src/interfaces/collection-item-dropdown/collection-item-dropdown.vue +++ b/app/src/interfaces/collection-item-dropdown/collection-item-dropdown.vue @@ -95,6 +95,7 @@ const displayTemplate = computed(() => { return displayTemplate || `{{ ${primaryKey.value || ''} }}`; }); + const requiredFields = computed(() => { if (!displayTemplate.value || !unref(selectedCollection)) return []; return adjustFieldsForDisplays(getFieldsFromTemplate(displayTemplate.value), unref(selectedCollection)); diff --git a/app/src/interfaces/file-image/file-image.vue b/app/src/interfaces/file-image/file-image.vue index 098a19276d..4e1a9a5cf5 100644 --- a/app/src/interfaces/file-image/file-image.vue +++ b/app/src/interfaces/file-image/file-image.vue @@ -171,6 +171,7 @@ const editImageEditor = ref(false); async function imageErrorHandler() { if (!src.value) return; + try { await api.get(src.value); } catch (err: any) { diff --git a/app/src/interfaces/group-accordion/group-accordion.vue b/app/src/interfaces/group-accordion/group-accordion.vue index aae3b743b0..cda9e966e7 100644 --- a/app/src/interfaces/group-accordion/group-accordion.vue +++ b/app/src/interfaces/group-accordion/group-accordion.vue @@ -121,9 +121,11 @@ export default defineComponent({ (newVal, oldVal) => { if (!props.validationErrors) return; if (isEqual(newVal, oldVal)) return; + const includedFieldsWithErrors = props.validationErrors.filter((validationError) => groupFields.value.find((rootField) => rootField.field === validationError.field) ); + if (includedFieldsWithErrors.length > 0) selection.value = [includedFieldsWithErrors[0].field]; } ); @@ -154,6 +156,7 @@ export default defineComponent({ } } ); + watch( () => props.values, (newVal) => { diff --git a/app/src/interfaces/input-code/input-code.vue b/app/src/interfaces/input-code/input-code.vue index 0523569bb7..da2480a538 100644 --- a/app/src/interfaces/input-code/input-code.vue +++ b/app/src/interfaces/input-code/input-code.vue @@ -166,6 +166,7 @@ export default defineComponent({ parser.parseError = (str: string, hash: any) => { const loc = hash.loc; + found.push({ from: CodeMirror.Pos(loc.first_line - 1, loc.first_column), to: CodeMirror.Pos(loc.last_line - 1, loc.last_column), @@ -296,6 +297,7 @@ export default defineComponent({ async (altOptions) => { if (!altOptions || altOptions.size === 0) return; await getImports(altOptions); + for (const key in altOptions) { codemirror?.setOption(key as any, altOptions[key]); } diff --git a/app/src/interfaces/input-multiline/input-multiline.vue b/app/src/interfaces/input-multiline/input-multiline.vue index 6662198caf..4e4eb01122 100644 --- a/app/src/interfaces/input-multiline/input-multiline.vue +++ b/app/src/interfaces/input-multiline/input-multiline.vue @@ -83,6 +83,7 @@ export default defineComponent({ if (props.softLength) return 100 - (props.value.length / +props.softLength) * 100; return 100; }); + return { charsRemaining, percentageRemaining, diff --git a/app/src/interfaces/input-rich-text-html/input-rich-text-html.vue b/app/src/interfaces/input-rich-text-html/input-rich-text-html.vue index 5f97112b1d..8af61d235f 100644 --- a/app/src/interfaces/input-rich-text-html/input-rich-text-html.vue +++ b/app/src/interfaces/input-rich-text-html/input-rich-text-html.vue @@ -492,9 +492,11 @@ export default defineComponent({ editor.on('init', function () { editor.shortcuts.remove('meta+k'); + editor.addShortcut('meta+k', 'Insert Link', () => { editor.ui.registry.getAll().buttons.customlink.onAction(); }); + setCount(); }); diff --git a/app/src/interfaces/input-rich-text-html/useLink.ts b/app/src/interfaces/input-rich-text-html/useLink.ts index 1a56cf17d5..9fc573c2c1 100644 --- a/app/src/interfaces/input-rich-text-html/useLink.ts +++ b/app/src/interfaces/input-rich-text-html/useLink.ts @@ -26,12 +26,14 @@ type UsableLink = { export default function useLink(editor: Ref): UsableLink { const linkDrawerOpen = ref(false); + const defaultLinkSelection = { url: null, displayText: null, title: null, newTab: true, }; + const linkSelection = ref(defaultLinkSelection); const linkNode = ref(null); const currentSelectionNode = ref(null); diff --git a/app/src/interfaces/input-rich-text-html/useMedia.ts b/app/src/interfaces/input-rich-text-html/useMedia.ts index 8a67a14c42..714c31bd67 100644 --- a/app/src/interfaces/input-rich-text-html/useMedia.ts +++ b/app/src/interfaces/input-rich-text-html/useMedia.ts @@ -80,6 +80,7 @@ export default function useMedia(editor: Ref, imageToken: Ref = {}; + (displayItems.value ?? []).forEach((item: Record) => { props.fields.forEach((key) => { if (!contentWidth[key]) { @@ -543,6 +544,7 @@ function deleteItem(item: DisplayItem) { } const values = inject('values', ref>({})); + const customFilter = computed(() => { const filter: Filter = { _and: [], @@ -593,6 +595,7 @@ function getLinkForItem(item: DisplayItem) { relationInfo.value.junctionField.field, relationInfo.value.relatedPrimaryKeyField.field, ]); + return `/content/${relationInfo.value.relatedCollection.collection}/${encodeURIComponent(primaryKey)}`; } diff --git a/app/src/interfaces/list-o2m-tree-view/nested-draggable.vue b/app/src/interfaces/list-o2m-tree-view/nested-draggable.vue index 7033339284..19058ecd51 100644 --- a/app/src/interfaces/list-o2m-tree-view/nested-draggable.vue +++ b/app/src/interfaces/list-o2m-tree-view/nested-draggable.vue @@ -228,14 +228,17 @@ function change(event: ChangeEvent) { case 'created': create(cleanItem(event.added.element)); break; + case 'updated': { const pkField = relationInfo.value.relatedPrimaryKeyField.field; const exists = displayItems.value.find((item) => item[pkField] === event.added.element[pkField]); + // We have to make sure we remove the reverseJunctionField when we move it back to its initial position as otherwise it will be selected. update({ ...cleanItem(event.added.element), [relationInfo.value.reverseJunctionField.field]: exists ? undefined : primaryKey.value, }); + break; } diff --git a/app/src/interfaces/list-o2m/list-o2m.vue b/app/src/interfaces/list-o2m/list-o2m.vue index 5cea196e32..236b4b591e 100644 --- a/app/src/interfaces/list-o2m/list-o2m.vue +++ b/app/src/interfaces/list-o2m/list-o2m.vue @@ -362,6 +362,7 @@ watch( const relatedCollection = relationInfo.value.relatedCollection.collection; const contentWidth: Record = {}; + (displayItems.value ?? []).forEach((item: Record) => { props.fields.forEach((key) => { if (!contentWidth[key]) { @@ -505,6 +506,7 @@ function deleteItem(item: DisplayItem) { } const values = inject('values', ref>({})); + const customFilter = computed(() => { const filter: Filter = { _and: [], diff --git a/app/src/interfaces/map/map.vue b/app/src/interfaces/map/map.vue index 6d687ee03c..21425d0e89 100644 --- a/app/src/interfaces/map/map.vue +++ b/app/src/interfaces/map/map.vue @@ -153,6 +153,7 @@ export default defineComponent({ const basemaps = getBasemapSources(); const appStore = useAppStore(); const { basemap } = toRefs(appStore); + const style = computed(() => { const source = basemaps.find((source) => source.name == basemap.value) ?? basemaps[0]; return basemap.value, getStyleFromBasemapSource(source); @@ -258,11 +259,14 @@ export default defineComponent({ ...props.defaultView, ...(mapboxKey ? { accessToken: mapboxKey } : {}), }); + if (controls.geocoder) { map.addControl(controls.geocoder as any, 'top-right'); + controls.geocoder.on('result', (event: any) => { location.value = event.result.center; }); + controls.geocoder.on('clear', () => { location.value = null; }); @@ -272,6 +276,7 @@ export default defineComponent({ const { longitude, latitude } = event.coords; location.value = [longitude, latitude]; }); + map.addControl(controls.attribution, 'bottom-left'); map.addControl(controls.navigation, 'top-left'); map.addControl(controls.geolocate, 'top-left'); @@ -287,6 +292,7 @@ export default defineComponent({ map.on('draw.modechange', handleDrawModeChange); map.on('draw.selectionchange', handleSelectionChange); map.on('move', updateProjection); + for (const layer of activeLayers) { map.on('mousedown', layer, hideTooltip); map.on('mousemove', layer, updateTooltip); @@ -310,6 +316,7 @@ export default defineComponent({ if (!value) { controls.draw.deleteAll(); currentGeometry = null; + if (geometryType) { const snaked = snakeCase(geometryType.replace('Multi', '')); const mode = `draw_${snaked}` as any; @@ -343,6 +350,7 @@ export default defineComponent({ function fitDataBounds(options: CameraOptions & AnimationOptions) { if (map && currentGeometry) { const bbox = getBBox(currentGeometry); + map.fitBounds(bbox as LngLatBoundsLike, { padding: 80, maxZoom: 8, @@ -445,6 +453,7 @@ export default defineComponent({ const coordinates = geometries .filter(({ type }) => `Multi${type}` == geometryType) .map(({ coordinates }) => coordinates); + result = { type: geometryType, coordinates } as Geometry; } else { result = geometries[geometries.length - 1]; @@ -467,6 +476,7 @@ export default defineComponent({ function handleDrawUpdate() { currentGeometry = getCurrentGeometry(); + if (!currentGeometry) { controls.draw.deleteAll(); emit('input', null); diff --git a/app/src/interfaces/map/options.vue b/app/src/interfaces/map/options.vue index eb12e51990..71b747aaf5 100644 --- a/app/src/interfaces/map/options.vue +++ b/app/src/interfaces/map/options.vue @@ -78,6 +78,7 @@ export default defineComponent({ const basemaps = getBasemapSources(); const appStore = useAppStore(); const { basemap } = toRefs(appStore); + const style = computed(() => { const source = basemaps.find((source) => source.name == basemap.value) ?? basemaps[0]; return getStyleFromBasemapSource(source); @@ -90,6 +91,7 @@ export default defineComponent({ ...(defaultView.value || {}), ...(mapboxKey ? { accessToken: mapboxKey } : {}), }); + map.on('moveend', () => { defaultView.value = { center: map.getCenter(), @@ -99,6 +101,7 @@ export default defineComponent({ }; }); }); + onUnmounted(() => { map.remove(); }); diff --git a/app/src/interfaces/select-color/select-color.vue b/app/src/interfaces/select-color/select-color.vue index 3f551b8201..5ca5165fc2 100644 --- a/app/src/interfaces/select-color/select-color.vue +++ b/app/src/interfaces/select-color/select-color.vue @@ -286,6 +286,7 @@ function useColor() { let alpha = Math.round(255 * color.value.alpha()) .toString(16) .toUpperCase(); + alpha = alpha.padStart(2, '0'); return color.value.rgb().array().length === 4 ? `${color.value.hex()}${alpha}` : color.value.hex(); } diff --git a/app/src/interfaces/select-dropdown-m2o/select-dropdown-m2o.vue b/app/src/interfaces/select-dropdown-m2o/select-dropdown-m2o.vue index 0c362607ce..7bcf6cfc98 100644 --- a/app/src/interfaces/select-dropdown-m2o/select-dropdown-m2o.vue +++ b/app/src/interfaces/select-dropdown-m2o/select-dropdown-m2o.vue @@ -135,6 +135,7 @@ const customFilter = computed(() => { const { t } = useI18n(); const { collection, field } = toRefs(props); const { relationInfo } = useRelationM2O(collection, field); + const value = computed({ get: () => props.value ?? null, set: (value) => { diff --git a/app/src/interfaces/translations/translations.vue b/app/src/interfaces/translations/translations.vue index e1d4eb98cb..2fa914e091 100644 --- a/app/src/interfaces/translations/translations.vue +++ b/app/src/interfaces/translations/translations.vue @@ -161,12 +161,14 @@ const firstItem = computed(() => { return getItemEdits(item); }); + const secondItem = computed(() => { const item = getItemWithLang(displayItems.value, secondLang.value); if (item === undefined) return undefined; return getItemEdits(item); }); + const firstItemInitial = computed(() => getItemWithLang(fetchedItems.value, firstLang.value)); const secondItemInitial = computed(() => getItemWithLang(fetchedItems.value, secondLang.value)); diff --git a/app/src/layouts/calendar/index.ts b/app/src/layouts/calendar/index.ts index fe5d3ca9de..5ceed1c63c 100644 --- a/app/src/layouts/calendar/index.ts +++ b/app/src/layouts/calendar/index.ts @@ -85,14 +85,17 @@ export default defineLayout({ const viewInfo = syncRefProperty(layoutOptions, 'viewInfo', undefined); const startDateField = syncRefProperty(layoutOptions, 'startDateField', undefined); + const startDateFieldInfo = computed(() => { return fieldsInCollection.value.find((field: Field) => field.field === startDateField.value); }); const endDateField = syncRefProperty(layoutOptions, 'endDateField', undefined); + const endDateFieldInfo = computed(() => { return fieldsInCollection.value.find((field: Field) => field.field === endDateField.value); }); + const firstDay = syncRefProperty(layoutOptions, 'firstDay', undefined); const queryFields = computed(() => { diff --git a/app/src/layouts/calendar/options.vue b/app/src/layouts/calendar/options.vue index df0c594573..28a6dec37a 100644 --- a/app/src/layouts/calendar/options.vue +++ b/app/src/layouts/calendar/options.vue @@ -73,6 +73,7 @@ export default defineComponent({ const firstDayWritable = useSync(props, 'firstDay', emit); const firstDayOfWeekForDate = startOfWeek(new Date()); + const firstDayOptions: { text: string; value: number }[] = [...Array(7).keys()].map((_, i) => ({ text: localizedFormat(add(firstDayOfWeekForDate, { days: i }), 'EEEE'), value: i, diff --git a/app/src/layouts/cards/components/card.vue b/app/src/layouts/cards/components/card.vue index c173c31eec..5a90942595 100644 --- a/app/src/layouts/cards/components/card.vue +++ b/app/src/layouts/cards/components/card.vue @@ -116,9 +116,11 @@ export default defineComponent({ return { source, fileType }; }); + const showThumbnail = computed(() => { return imageInfo.value && imageInfo.value.fileType; }); + const selectionIcon = computed(() => { if (!props.item) return 'radio_button_unchecked'; diff --git a/app/src/layouts/map/components/map.vue b/app/src/layouts/map/components/map.vue index 1739a0bebb..3d5e2a85e5 100644 --- a/app/src/layouts/map/components/map.vue +++ b/app/src/layouts/map/components/map.vue @@ -82,29 +82,36 @@ export default defineComponent({ const { sidebarOpen, basemap } = toRefs(appStore); const mapboxKey = settingsStore.settings?.mapbox_key; const basemaps = getBasemapSources(); + const style = computed(() => { const source = basemaps.find((source) => source.name === basemap.value) ?? basemaps[0]; return getStyleFromBasemapSource(source); }); const attributionControl = new AttributionControl(); + const navigationControl = new NavigationControl({ showCompass: false, }); + const geolocateControl = new GeolocateControl(); + const fitDataControl = new ButtonControl('mapboxgl-ctrl-fitdata', () => { emit('fitdata'); }); + const boxSelectControl = new BoxSelectControl({ boxElementClass: 'map-selection-box', selectButtonClass: 'mapboxgl-ctrl-select', layers: ['__directus_polygons', '__directus_points', '__directus_lines'], }); + let geocoderControl: MapboxGeocoder | undefined; if (mapboxKey) { const marker = document.createElement('div'); marker.className = 'mapboxgl-user-location-dot mapboxgl-search-location-dot'; + geocoderControl = new MapboxGeocoder({ accessToken: mapboxKey, collapsed: true, @@ -118,6 +125,7 @@ export default defineComponent({ onMounted(() => { setupMap(); }); + onUnmounted(() => { map.remove(); }); @@ -161,10 +169,12 @@ export default defineComponent({ map.on('mouseleave', '__directus_clusters', hoverCluster); map.on('select.enable', () => (selectMode.value = true)); map.on('select.disable', () => (selectMode.value = false)); + map.on('select.end', (event: MapLayerMouseEvent) => { const ids = event.features?.map((f) => f.id); emit('featureselect', { ids, replace: !event.alt }); }); + map.on('moveend', () => { emit('moveend', { center: map.getCenter(), @@ -174,6 +184,7 @@ export default defineComponent({ bbox: map.getBounds().toArray().flat(), }); }); + startWatchers(); }); @@ -183,6 +194,7 @@ export default defineComponent({ if (!opened) setTimeout(() => map.resize(), 300); } ); + setTimeout(() => map.resize(), 300); } @@ -240,6 +252,7 @@ export default defineComponent({ } map.addSource('__directus', { ...newSource, data: props.data }); + map.once('sourcedata', () => { setTimeout(() => props.layers.forEach((layer) => map.addLayer(layer))); }); @@ -247,9 +260,11 @@ export default defineComponent({ function updateLayers(newLayers?: AnyLayer[], previousLayers?: AnyLayer[]) { const currentMapLayersId = new Set(map.getStyle().layers?.map(({ id }) => id)); + previousLayers?.forEach((layer) => { if (currentMapLayersId.has(layer.id)) map.removeLayer(layer.id); }); + newLayers?.forEach((layer) => { map.addLayer(layer); }); @@ -260,6 +275,7 @@ export default defineComponent({ map.setFeatureState({ id, source: '__directus' }, { selected: false }); map.removeFeatureState({ id, source: '__directus' }); }); + newSelection?.forEach((id) => { map.setFeatureState({ id, source: '__directus' }, { selected: true }); }); @@ -324,10 +340,13 @@ export default defineComponent({ const features = map.queryRenderedFeatures(event.point, { layers: ['__directus_clusters'], }); + const clusterId = features[0]?.properties?.cluster_id; const source = map.getSource('__directus') as GeoJSONSource; + source.getClusterExpansionZoom(clusterId, (err: any, zoom: number) => { if (err) return; + map.flyTo({ center: (features[0].geometry as GeoJSON.Point).coordinates as LngLatLike, zoom: zoom, diff --git a/app/src/layouts/map/index.ts b/app/src/layouts/map/index.ts index 318d39ced7..483a4aa28e 100644 --- a/app/src/layouts/map/index.ts +++ b/app/src/layouts/map/index.ts @@ -134,6 +134,7 @@ export default defineLayout({ function fitDataBounds() { shouldUpdateCamera.value = true; + if (isGeometryFieldNative.value) { return; } @@ -180,6 +181,7 @@ export default defineLayout({ geojsonError.value = null; geojson.value = toGeoJSON(items.value, geometryOptions.value); geojsonLoading.value = false; + if (!cameraOptions.value || shouldUpdateCamera.value) { geojsonBounds.value = geojson.value.bbox; } diff --git a/app/src/layouts/tabular/index.ts b/app/src/layouts/tabular/index.ts index 9c2632f5cd..1ecfe28f8b 100644 --- a/app/src/layouts/tabular/index.ts +++ b/app/src/layouts/tabular/index.ts @@ -157,6 +157,7 @@ export default defineLayout({ const limit = syncRefProperty(layoutQuery, 'limit', 25); const defaultSort = computed(() => (primaryKeyField.value ? [primaryKeyField.value?.field] : [])); const sort = syncRefProperty(layoutQuery, 'sort', defaultSort); + const fieldsDefaultValue = computed(() => { return fieldsInCollection.value .filter((field: Field) => !field.meta?.hidden) diff --git a/app/src/layouts/tabular/tabular.vue b/app/src/layouts/tabular/tabular.vue index 64f3d84e56..024b6b228a 100644 --- a/app/src/layouts/tabular/tabular.vue +++ b/app/src/layouts/tabular/tabular.vue @@ -259,6 +259,7 @@ useShortcut( }, table ); + const permissionsStore = usePermissionsStore(); const userStore = useUserStore(); diff --git a/app/src/main.ts b/app/src/main.ts index fea638962f..3ff1ad831e 100644 --- a/app/src/main.ts +++ b/app/src/main.ts @@ -18,6 +18,7 @@ init(); async function init() { console.log(DIRECTUS_LOGO); + console.info( `Hey! Interested in helping build this open-source data management platform?\nIf so, join our growing team of contributors at: https://directus.chat` ); diff --git a/app/src/modules/activity/routes/item.vue b/app/src/modules/activity/routes/item.vue index 3cb091504a..4e6a2caa9b 100644 --- a/app/src/modules/activity/routes/item.vue +++ b/app/src/modules/activity/routes/item.vue @@ -121,6 +121,7 @@ export default defineComponent({ }); item.value = response.data.data; + if (item.value) { if (te(`field_options.directus_activity.${item.value.action}`)) item.value.action_translated = t(`field_options.directus_activity.${item.value.action}`); diff --git a/app/src/modules/content/components/navigation-bookmark.vue b/app/src/modules/content/components/navigation-bookmark.vue index 1bef571f19..61918f8a79 100644 --- a/app/src/modules/content/components/navigation-bookmark.vue +++ b/app/src/modules/content/components/navigation-bookmark.vue @@ -126,11 +126,13 @@ const name = computed(() => translate(props.bookmark.bookmark)); function useEditBookmark() { const editActive = ref(false); + const editValue = reactive({ name: props.bookmark.bookmark, icon: props.bookmark?.icon ?? 'bookmark', color: props.bookmark?.color ?? null, }); + const editSaving = ref(false); return { editActive, editValue, editSave, editSaving, editCancel }; diff --git a/app/src/modules/content/components/navigation.vue b/app/src/modules/content/components/navigation.vue index bb9a98d6d3..001647e624 100644 --- a/app/src/modules/content/components/navigation.vue +++ b/app/src/modules/content/components/navigation.vue @@ -73,6 +73,7 @@ export default defineComponent({ const dense = computed(() => collectionsStore.visibleCollections.length > 5); const showSearch = computed(() => collectionsStore.visibleCollections.length > 20); + const hasHiddenCollections = computed( () => collectionsStore.allCollections.length > collectionsStore.visibleCollections.length ); diff --git a/app/src/modules/content/routes/collection.vue b/app/src/modules/content/routes/collection.vue index 3d512cfbbc..155c10cd1c 100644 --- a/app/src/modules/content/routes/collection.vue +++ b/app/src/modules/content/routes/collection.vue @@ -604,6 +604,7 @@ export default defineComponent({ icon: bookmark.icon, color: bookmark.color, }); + router.push(`/content/${newBookmark.collection}?bookmark=${newBookmark.id}`); bookmarkDialogActive.value = false; @@ -628,6 +629,7 @@ export default defineComponent({ const updatePermissions = permissionsStore.permissions.find( (permission) => permission.action === 'update' && permission.collection === collection.value ); + return !!updatePermissions; }); @@ -639,6 +641,7 @@ export default defineComponent({ const updatePermissions = permissionsStore.permissions.find( (permission) => permission.action === 'update' && permission.collection === collection.value ); + if (!updatePermissions) return false; if (!updatePermissions.fields) return false; if (updatePermissions.fields.includes('*')) return true; @@ -652,6 +655,7 @@ export default defineComponent({ const deletePermissions = permissionsStore.permissions.find( (permission) => permission.action === 'delete' && permission.collection === collection.value ); + return !!deletePermissions; }); @@ -662,6 +666,7 @@ export default defineComponent({ const createPermissions = permissionsStore.permissions.find( (permission) => permission.action === 'create' && permission.collection === collection.value ); + return !!createPermissions; }); diff --git a/app/src/modules/files/components/file-info-sidebar-detail.vue b/app/src/modules/files/components/file-info-sidebar-detail.vue index abbb366ae9..67e11b153b 100644 --- a/app/src/modules/files/components/file-info-sidebar-detail.vue +++ b/app/src/modules/files/components/file-info-sidebar-detail.vue @@ -286,6 +286,7 @@ export default defineComponent({ if (!props.file) return null; if (!props.file.folder) return; loading.value = true; + try { const response = await api.get(`/folders/${props.file.folder}`, { params: { diff --git a/app/src/modules/files/index.ts b/app/src/modules/files/index.ts index 7aaa15ec9c..a088b6e461 100644 --- a/app/src/modules/files/index.ts +++ b/app/src/modules/files/index.ts @@ -76,6 +76,7 @@ export default defineModule({ const permission = permissions.find( (permission) => permission.collection === 'directus_files' && permission.action === 'read' ); + return !!permission; }, }); diff --git a/app/src/modules/files/routes/collection.vue b/app/src/modules/files/routes/collection.vue index 5c171efd51..1c7d9e7489 100644 --- a/app/src/modules/files/routes/collection.vue +++ b/app/src/modules/files/routes/collection.vue @@ -317,6 +317,7 @@ export default defineComponent({ onBeforeRouteLeave(() => { selection.value = []; }); + onBeforeRouteUpdate(() => { selection.value = []; }); @@ -494,6 +495,7 @@ export default defineComponent({ const updatePermissions = permissionsStore.permissions.find( (permission) => permission.action === 'update' && permission.collection === 'directus_files' ); + return !!updatePermissions; }); @@ -504,6 +506,7 @@ export default defineComponent({ const deletePermissions = permissionsStore.permissions.find( (permission) => permission.action === 'delete' && permission.collection === 'directus_files' ); + return !!deletePermissions; }); @@ -514,6 +517,7 @@ export default defineComponent({ const createPermissions = permissionsStore.permissions.find( (permission) => permission.action === 'create' && permission.collection === 'directus_files' ); + return !!createPermissions; }); @@ -524,6 +528,7 @@ export default defineComponent({ const createPermissions = permissionsStore.permissions.find( (permission) => permission.action === 'create' && permission.collection === 'directus_folders' ); + return !!createPermissions; }); diff --git a/app/src/modules/files/routes/item.vue b/app/src/modules/files/routes/item.vue index 3ea3bc801e..e6c2cef4da 100644 --- a/app/src/modules/files/routes/item.vue +++ b/app/src/modules/files/routes/item.vue @@ -244,6 +244,7 @@ const { confirmLeave, leaveTo } = useEditsGuard(hasEdits); const confirmDelete = ref(false); const editActive = ref(false); + const fileSrc = computed(() => { if (item.value && item.value.modified_on) { return `assets/${props.primaryKey}?cache-buster=${item.value.modified_on}&key=system-large-contain`; diff --git a/app/src/modules/settings/routes/data-model/field-detail/store/alterations/files.ts b/app/src/modules/settings/routes/data-model/field-detail/store/alterations/files.ts index 8e4257e1ff..c2683d5920 100644 --- a/app/src/modules/settings/routes/data-model/field-detail/store/alterations/files.ts +++ b/app/src/modules/settings/routes/data-model/field-detail/store/alterations/files.ts @@ -165,8 +165,10 @@ function generateFields(updates: StateUpdates, state: State, { getCurrent }: Hel const junctionCurrent = getCurrent('relations.o2m.field'); const junctionRelated = getCurrent('relations.m2o.field'); const relatedCollection = getCurrent('relations.m2o.related_collection'); + const relatedPrimaryKeyField = fieldsStore.getPrimaryKeyFieldForCollection(relatedCollection) ?? getCurrent('collections.related.fields[0]'); + const sort = getCurrent('relations.o2m.meta.sort_field'); if (junctionCollection && junctionCurrent && fieldExists(junctionCollection, junctionCurrent) === false) { @@ -218,6 +220,7 @@ export function setDefaults(updates: StateUpdates, state: State, { getCurrent }: const fieldsStore = useFieldsStore(); const currentCollection = state.collection!; + const currentCollectionPrimaryKeyField = fieldsStore.getPrimaryKeyFieldForCollection(currentCollection)?.field ?? 'id'; diff --git a/app/src/modules/settings/routes/data-model/field-detail/store/alterations/m2a.ts b/app/src/modules/settings/routes/data-model/field-detail/store/alterations/m2a.ts index f1f9ff77fd..a783d434b6 100644 --- a/app/src/modules/settings/routes/data-model/field-detail/store/alterations/m2a.ts +++ b/app/src/modules/settings/routes/data-model/field-detail/store/alterations/m2a.ts @@ -96,6 +96,7 @@ export function setDefaults(updates: StateUpdates, state: State, { getCurrent }: const fieldsStore = useFieldsStore(); const currentCollection = state.collection!; + const currentCollectionPrimaryKeyField = fieldsStore.getPrimaryKeyFieldForCollection(currentCollection)?.field ?? 'id'; @@ -105,11 +106,13 @@ export function setDefaults(updates: StateUpdates, state: State, { getCurrent }: set(updates, 'relations.o2m.field', `${currentCollection}_${currentCollectionPrimaryKeyField}`); set(updates, 'relations.m2o.collection', junctionName); set(updates, 'relations.m2o.field', 'item'); + set( updates, 'relations.m2o.meta.one_allowed_collections', getCurrent('relations.m2o.meta.one_allowed_collections') ?? [] ); + set(updates, 'relations.m2o.meta.one_collection_field', 'collection'); } diff --git a/app/src/modules/settings/routes/data-model/field-detail/store/alterations/m2m.ts b/app/src/modules/settings/routes/data-model/field-detail/store/alterations/m2m.ts index 2e02718e1f..a22f6d3b38 100644 --- a/app/src/modules/settings/routes/data-model/field-detail/store/alterations/m2m.ts +++ b/app/src/modules/settings/routes/data-model/field-detail/store/alterations/m2m.ts @@ -125,6 +125,7 @@ export function autoGenerateJunctionFields(updates: StateUpdates, state: State, const currentCollection = state.collection!; const currentPrimaryKeyField = fieldsStore.getPrimaryKeyFieldForCollection(currentCollection)?.field ?? 'id'; + const relatedCollection = updates.relations?.m2o?.related_collection ?? getCurrent('relations.m2o.related_collection'); @@ -251,8 +252,10 @@ function generateFields(updates: StateUpdates, state: State, { getCurrent }: Hel const junctionRelated = getCurrent('relations.m2o.field'); const sort = getCurrent('relations.o2m.meta.sort_field'); const relatedCollection = getCurrent('relations.m2o.related_collection'); + const relatedPrimaryKeyField = fieldsStore.getPrimaryKeyFieldForCollection(relatedCollection) ?? getCurrent('collections.related.fields[0]'); + const existsJunctionRelated = relationsStore.relations.find( (relation) => relation.collection === junctionCollection && relation.field === junctionRelated ); diff --git a/app/src/modules/settings/routes/data-model/field-detail/store/alterations/translations.ts b/app/src/modules/settings/routes/data-model/field-detail/store/alterations/translations.ts index 27e8c66d23..075ecfd619 100644 --- a/app/src/modules/settings/routes/data-model/field-detail/store/alterations/translations.ts +++ b/app/src/modules/settings/routes/data-model/field-detail/store/alterations/translations.ts @@ -118,6 +118,7 @@ export function updateJunctionRelated(updates: StateUpdates, _state: State, { ge const fieldsStore = useFieldsStore(); const relatedCollection = getCurrent('relations.m2o.related_collection'); + const relatedCollectionPrimaryKeyField = fieldsStore.getPrimaryKeyFieldForCollection(relatedCollection)?.field ?? 'id'; @@ -289,8 +290,10 @@ function generateFields(updates: StateUpdates, state: State, { getCurrent }: Hel const junctionCurrent = getCurrent('relations.o2m.field'); const junctionRelated = getCurrent('relations.m2o.field'); const relatedCollection = getCurrent('relations.m2o.related_collection'); + const relatedPrimaryKeyField = fieldsStore.getPrimaryKeyFieldForCollection(relatedCollection) ?? getCurrent('collections.related.fields[0]'); + const sort = getCurrent('relations.o2m.sort_field'); if (junctionCollection && junctionCurrent && fieldExists(junctionCollection, junctionCurrent) === false) { @@ -342,6 +345,7 @@ export function setDefaults(updates: StateUpdates, state: State, { getCurrent }: const fieldsStore = useFieldsStore(); const currentCollection = state.collection!; + const currentCollectionPrimaryKeyField = fieldsStore.getPrimaryKeyFieldForCollection(currentCollection)?.field ?? 'id'; @@ -353,6 +357,7 @@ export function setDefaults(updates: StateUpdates, state: State, { getCurrent }: set(updates, 'relations.m2o.related_collection', getCurrent('relations.m2o.related_collection') ?? 'languages'); const languagesCollection = getCurrent('relations.m2o.related_collection'); + const languagesCollectionPrimaryKeyField = fieldsStore.getPrimaryKeyFieldForCollection(languagesCollection)?.field ?? 'id'; diff --git a/app/src/modules/settings/routes/data-model/field-detail/store/index.test.ts b/app/src/modules/settings/routes/data-model/field-detail/store/index.test.ts index 5b902adbaf..6f77514d64 100644 --- a/app/src/modules/settings/routes/data-model/field-detail/store/index.test.ts +++ b/app/src/modules/settings/routes/data-model/field-detail/store/index.test.ts @@ -36,17 +36,20 @@ describe('Actions', () => { describe('startEditing', () => { it('New Field', () => { const fieldDetailStore = useFieldDetailStore(); + const testValue: { collection: string; field: string; localType: 'presentation' } = { collection: 'collection_a', field: '+', localType: 'presentation', }; + fieldDetailStore.startEditing(testValue.collection, testValue.field, testValue.localType); expect(fieldDetailStore.collection).toEqual(testValue.collection); expect(fieldDetailStore.field.collection).toEqual(testValue.collection); expect(fieldDetailStore.editing).toEqual(testValue.field); expect(fieldDetailStore.localType).toEqual(testValue.localType); }); + it('Existing Field — M2O', () => { const mockedField = { collection: 'collection_a', @@ -58,6 +61,7 @@ describe('Actions', () => { }, name: 'Collection A Field', }; + const fieldsStore = useFieldsStore(); (fieldsStore.getField as Mock).mockReturnValue(mockedField); @@ -79,6 +83,7 @@ describe('Actions', () => { }, }, ]; + const relationsStore = useRelationsStore(); (relationsStore.getRelationsForField as Mock).mockReturnValue(mockedRelations); @@ -90,6 +95,7 @@ describe('Actions', () => { expect(fieldDetailStore.field.name).toEqual(mockedField.name); expect(fieldDetailStore.localType).toEqual('m2o'); expect(fieldDetailStore.relations.o2m).toEqual(undefined); + expect(fieldDetailStore.relations.m2o).toEqual( mockedRelations.find( (relation) => relation.collection === mockedField.collection && relation.field === mockedField.field @@ -97,6 +103,7 @@ describe('Actions', () => { ); }); }); + it.todo('Existing Field — M2M'); }); @@ -104,18 +111,22 @@ describe('Alterations', () => { describe('files', () => { it('autoGenerateJunctionRelation has changed', () => { const fieldDetailStore = useFieldDetailStore(); + const testValue: { collection: string; field: string; localType: 'files' } = { collection: 'collection_a', field: '+', localType: 'files', }; + fieldDetailStore.startEditing(testValue.collection, testValue.field, testValue.localType); const fieldsStore = useFieldsStore(); + (fieldsStore.getPrimaryKeyFieldForCollection as Mock).mockReturnValue({ collection: 'collection_a', field: 'id', }); + expect(fieldDetailStore.collection).toEqual(testValue.collection); expect(fieldDetailStore.field.collection).toEqual(testValue.collection); expect(fieldDetailStore.editing).toEqual(testValue.field); @@ -125,6 +136,7 @@ describe('Alterations', () => { expect(fieldDetailStore.relations.m2o?.field).toEqual('directus_files_id'); fieldDetailStore.update({ autoGenerateJunctionRelation: false }); + fieldDetailStore.update({ relations: { o2m: { @@ -132,6 +144,7 @@ describe('Alterations', () => { }, }, }); + fieldDetailStore.update({ autoGenerateJunctionRelation: true }); expect(fieldDetailStore.relations.o2m?.collection).toEqual('collection_a_files'); diff --git a/app/src/modules/settings/routes/data-model/field-detail/store/index.ts b/app/src/modules/settings/routes/data-model/field-detail/store/index.ts index f9cb06642a..6abd36efd2 100644 --- a/app/src/modules/settings/routes/data-model/field-detail/store/index.ts +++ b/app/src/modules/settings/routes/data-model/field-detail/store/index.ts @@ -115,6 +115,7 @@ export const useFieldDetailStore = defineStore({ this.loading = true; const response = await api.get(`/fields/${collection}/${field}`); const fetchedFieldMeta = response.data?.data?.meta; + this.$patch({ field: { meta: { @@ -169,6 +170,7 @@ export const useFieldDetailStore = defineStore({ for (const relation of Object.values(this.relations)) { if (!relation || !relation.collection || !relation.field) continue; + if ( // Duplicate checks for O2M & M2O (relation.collection === relation.related_collection && relation.field === relation.meta?.one_field) || diff --git a/app/src/modules/settings/routes/data-model/fields/components/field-select.vue b/app/src/modules/settings/routes/data-model/fields/components/field-select.vue index 1dd487a7d6..8d3aec80d4 100644 --- a/app/src/modules/settings/routes/data-model/fields/components/field-select.vue +++ b/app/src/modules/settings/routes/data-model/fields/components/field-select.vue @@ -196,6 +196,7 @@ export default defineComponent({ 'interface', computed(() => props.field.meta?.interface ?? null) ); + const interfaceName = computed(() => inter.value?.name ?? null); const hidden = computed(() => props.field.meta?.hidden === true); @@ -259,11 +260,13 @@ export default defineComponent({ const duplicateActive = ref(false); const duplicateName = ref(props.field.field + '_copy'); const duplicating = ref(false); + const collections = computed(() => collectionsStore.collections .map(({ collection }) => collection) .filter((collection) => collection.startsWith('directus_') === false) ); + const duplicateTo = ref(props.field.collection); return { diff --git a/app/src/modules/settings/routes/flows/components/arrows.vue b/app/src/modules/settings/routes/flows/components/arrows.vue index 8b778212e5..7c29322cc9 100644 --- a/app/src/modules/settings/routes/flows/components/arrows.vue +++ b/app/src/modules/settings/routes/flows/components/arrows.vue @@ -69,6 +69,7 @@ const arrows = computed(() => { if (props.arrowInfo?.id === panel.id && props.arrowInfo?.type === 'resolve') { const { x, y } = getPoints(panel, RESOLVE_OFFSET); + arrows.push({ id: panel.id + '_resolve', d: createLine(x, y, props.arrowInfo.pos.x, props.arrowInfo.pos.y), @@ -77,6 +78,7 @@ const arrows = computed(() => { }); } else if (resolveChild) { const { x, y, toX, toY } = getPoints(panel, RESOLVE_OFFSET, resolveChild); + arrows.push({ id: panel.id + '_resolve', d: createLine(x, y, toX as number, toY as number), @@ -85,6 +87,7 @@ const arrows = computed(() => { }); } else if (props.editMode && !props.arrowInfo && (panel.id === '$trigger' || props.hoveredPanel === panel.id)) { const { x: resolveX, y: resolveY } = getPoints(panel, RESOLVE_OFFSET); + arrows.push({ id: panel.id + '_resolve', d: createLine(resolveX, resolveY, resolveX + 3 * 20, resolveY), @@ -96,6 +99,7 @@ const arrows = computed(() => { if (props.arrowInfo?.id === panel.id && props.arrowInfo?.type === 'reject') { const { x, y } = getPoints(panel, REJECT_OFFSET); + arrows.push({ id: panel.id + '_reject', d: createLine(x, y, props.arrowInfo.pos.x, props.arrowInfo.pos.y), @@ -104,6 +108,7 @@ const arrows = computed(() => { }); } else if (rejectChild) { const { x, y, toX, toY } = getPoints(panel, REJECT_OFFSET, rejectChild); + arrows.push({ id: panel.id + '_reject', d: createLine(x, y, toX as number, toY as number), @@ -112,6 +117,7 @@ const arrows = computed(() => { }); } else if (props.editMode && !props.arrowInfo && panel.id !== '$trigger' && props.hoveredPanel === panel.id) { const { x: rejectX, y: rejectY } = getPoints(panel, REJECT_OFFSET); + arrows.push({ id: panel.id + '_reject', d: createLine(rejectX, rejectY, rejectX + 3 * 20, rejectY), @@ -182,6 +188,7 @@ const arrows = computed(() => { } const arrowSize = 8; + const arrow = `M ${points.at(-1)} L ${points .at(-1) ?.clone() diff --git a/app/src/modules/settings/routes/flows/components/operation-detail.vue b/app/src/modules/settings/routes/flows/components/operation-detail.vue index 973526782b..1a8ee7761a 100644 --- a/app/src/modules/settings/routes/flows/components/operation-detail.vue +++ b/app/src/modules/settings/routes/flows/components/operation-detail.vue @@ -183,6 +183,7 @@ const operationOptions = computed(() => { function saveOperation() { saving.value = true; + emit('save', { flow: props.primaryKey, name: operationName.value || generatedName.value, diff --git a/app/src/modules/settings/routes/flows/components/operation.vue b/app/src/modules/settings/routes/flows/components/operation.vue index ad9eb3d5d1..d6a7d84136 100644 --- a/app/src/modules/settings/routes/flows/components/operation.vue +++ b/app/src/modules/settings/routes/flows/components/operation.vue @@ -236,6 +236,7 @@ const pointermove = (event: PointerEvent) => { rafId = window.requestAnimationFrame(() => { moving.value = true; if (!down) return; + const arrowInfo: ArrowInfo = down === 'parent' ? { diff --git a/app/src/modules/settings/routes/flows/components/trigger-detail.vue b/app/src/modules/settings/routes/flows/components/trigger-detail.vue index 6306111d37..bfde2cbb02 100644 --- a/app/src/modules/settings/routes/flows/components/trigger-detail.vue +++ b/app/src/modules/settings/routes/flows/components/trigger-detail.vue @@ -61,6 +61,7 @@ function saveTrigger() { ...(props.flow ?? {}), ...flowEdits.value, }); + emit('update:open', false); } diff --git a/app/src/modules/settings/routes/flows/flow.vue b/app/src/modules/settings/routes/flows/flow.vue index 0acaed85a9..55476ff216 100644 --- a/app/src/modules/settings/routes/flows/flow.vue +++ b/app/src/modules/settings/routes/flows/flow.vue @@ -230,6 +230,7 @@ const flowsStore = useFlowsStore(); const stagedFlow = ref>({}); const fetchedFlow = ref(); + const flow = computed({ get() { if (!fetchedFlow.value) return undefined; @@ -261,6 +262,7 @@ async function loadCurrentFlow() { fields: ['*', 'operations.*'], }, }); + fetchedFlow.value = response.data.data; } catch (err: any) { unexpectedError(err); @@ -688,6 +690,7 @@ function getNearAttachment(pos: Vector2) { (panel.x - 1) * 20 + ATTACHMENT_OFFSET.x, (panel.y - 1) * 20 + ATTACHMENT_OFFSET.y ); + if (attachmentPos.distanceTo(pos) <= 40) return panel.id as string; } diff --git a/app/src/modules/settings/routes/flows/overview.vue b/app/src/modules/settings/routes/flows/overview.vue index a2a48e17cb..3c0d774461 100644 --- a/app/src/modules/settings/routes/flows/overview.vue +++ b/app/src/modules/settings/routes/flows/overview.vue @@ -245,6 +245,7 @@ async function toggleFlowStatusById(id: string, value: string) { await api.patch(`/flows/${id}`, { status: value === 'active' ? 'inactive' : 'active', }); + await flowsStore.hydrate(); } catch (error) { unexpectedError(error as Error); diff --git a/app/src/modules/settings/routes/presets/collection/collection.vue b/app/src/modules/settings/routes/presets/collection/collection.vue index 8d671672a5..e25c13dc3e 100644 --- a/app/src/modules/settings/routes/presets/collection/collection.vue +++ b/app/src/modules/settings/routes/presets/collection/collection.vue @@ -280,6 +280,7 @@ export default defineComponent({ const updatePermissions = permissionsStore.permissions.find( (permission) => permission.action === 'update' && permission.collection === collection.value ); + return !!updatePermissions; }); @@ -290,6 +291,7 @@ export default defineComponent({ const deletePermissions = permissionsStore.permissions.find( (permission) => permission.action === 'delete' && permission.collection === collection.value ); + return !!deletePermissions; }); @@ -300,6 +302,7 @@ export default defineComponent({ const createPermissions = permissionsStore.permissions.find( (permission) => permission.action === 'create' && permission.collection === collection.value ); + return !!createPermissions; }); diff --git a/app/src/modules/settings/routes/presets/item.vue b/app/src/modules/settings/routes/presets/item.vue index bf94e9779f..8f68ba43b8 100644 --- a/app/src/modules/settings/routes/presets/item.vue +++ b/app/src/modules/settings/routes/presets/item.vue @@ -305,6 +305,7 @@ function useValues() { layout_options: null, filter: null, }; + if (isNew.value === true) return defaultValues; if (preset.value === null) return defaultValues; diff --git a/app/src/modules/settings/routes/roles/item/item.vue b/app/src/modules/settings/routes/roles/item/item.vue index 4f2dc02474..0010d7ec63 100644 --- a/app/src/modules/settings/routes/roles/item/item.vue +++ b/app/src/modules/settings/routes/roles/item/item.vue @@ -193,6 +193,7 @@ export default defineComponent({ const usersCreatePermission = permissionsStore.permissions.find( (permission) => permission.collection === 'directus_users' && permission.action === 'create' ); + const rolesReadPermission = permissionsStore.permissions.find( (permission) => permission.collection === 'directus_roles' && permission.action === 'read' ); diff --git a/app/src/modules/settings/routes/roles/permissions-detail/permissions-detail.vue b/app/src/modules/settings/routes/roles/permissions-detail/permissions-detail.vue index 76e49dc5cf..eac7d13058 100644 --- a/app/src/modules/settings/routes/roles/permissions-detail/permissions-detail.vue +++ b/app/src/modules/settings/routes/roles/permissions-detail/permissions-detail.vue @@ -195,6 +195,7 @@ export default defineComponent({ deep: { users: { _limit: 0 } }, }, }); + role.value = response.data.data; } diff --git a/app/src/modules/users/composables/use-navigation.ts b/app/src/modules/users/composables/use-navigation.ts index c910f7c33f..8d2b5d297a 100644 --- a/app/src/modules/users/composables/use-navigation.ts +++ b/app/src/modules/users/composables/use-navigation.ts @@ -34,6 +34,7 @@ export default function useNavigation(): { roles: Ref; loadi fields: ['id', 'name', 'icon', 'admin_access'], }, }); + roles.value = rolesResponse.data.data; } catch (error: any) { unexpectedError(error); diff --git a/app/src/modules/users/routes/collection.vue b/app/src/modules/users/routes/collection.vue index 0be1f7d1b3..04b4eba381 100644 --- a/app/src/modules/users/routes/collection.vue +++ b/app/src/modules/users/routes/collection.vue @@ -244,6 +244,7 @@ const canInviteUsers = computed(() => { const usersCreatePermission = permissionsStore.permissions.find( (permission) => permission.collection === 'directus_users' && permission.action === 'create' ); + const rolesReadPermission = permissionsStore.permissions.find( (permission) => permission.collection === 'directus_roles' && permission.action === 'read' ); @@ -258,6 +259,7 @@ const { batchEditAllowed, batchDeleteAllowed, createAllowed } = usePermissions() onBeforeRouteLeave(() => { selection.value = []; }); + onBeforeRouteUpdate(() => { selection.value = []; }); @@ -340,6 +342,7 @@ function usePermissions() { const updatePermissions = permissionsStore.permissions.find( (permission) => permission.action === 'update' && permission.collection === 'directus_users' ); + return !!updatePermissions; }); @@ -350,6 +353,7 @@ function usePermissions() { const deletePermissions = permissionsStore.permissions.find( (permission) => permission.action === 'delete' && permission.collection === 'directus_users' ); + return !!deletePermissions; }); @@ -360,6 +364,7 @@ function usePermissions() { const createPermissions = permissionsStore.permissions.find( (permission) => permission.action === 'create' && permission.collection === 'directus_users' ); + return !!createPermissions; }); diff --git a/app/src/operations/trigger/index.ts b/app/src/operations/trigger/index.ts index 594700faa5..abad7b1beb 100644 --- a/app/src/operations/trigger/index.ts +++ b/app/src/operations/trigger/index.ts @@ -14,11 +14,13 @@ export default defineOperationApp({ ], options: (panel) => { const flowStore = useFlowsStore(); + const flowChoices = flowStore.flows .filter((flow) => flow.trigger === 'operation') .map((flow) => { return { text: flow.name, value: flow.id }; }); + return [ { field: 'flow', diff --git a/app/src/panels/relational-variable/panel-relational-variable.vue b/app/src/panels/relational-variable/panel-relational-variable.vue index fb0ef1961a..acd9ad9ba1 100644 --- a/app/src/panels/relational-variable/panel-relational-variable.vue +++ b/app/src/panels/relational-variable/panel-relational-variable.vue @@ -60,6 +60,7 @@ const props = withDefaults(defineProps(), {}); /*const emit = */ defineEmits(['input']); const insightsStore = useInsightsStore(); + const value = computed({ get() { const val = insightsStore.getVariable(props.field); @@ -76,6 +77,7 @@ const selectModalOpen = ref(false); function onSelection(data: (number | string)[]) { selectModalOpen.value = false; + if (!Array.isArray(data) || data.length === 0) { value.value = []; return; diff --git a/app/src/panels/relational-variable/use-display-items.ts b/app/src/panels/relational-variable/use-display-items.ts index e2972c485f..4d5fd10618 100644 --- a/app/src/panels/relational-variable/use-display-items.ts +++ b/app/src/panels/relational-variable/use-display-items.ts @@ -15,6 +15,7 @@ export default function useDisplayItems(collection: Ref, template: Ref fieldStore.getPrimaryKeyFieldForCollection(collection.value)?.field ?? ''); + const displayTemplate = computed(() => { if (template.value) return template.value; @@ -22,6 +23,7 @@ export default function useDisplayItems(collection: Ref, template: Ref { if (!displayTemplate.value || !collection.value) return []; return adjustFieldsForDisplays(getFieldsFromTemplate(displayTemplate.value), collection.value); diff --git a/app/src/panels/time-series/index.ts b/app/src/panels/time-series/index.ts index ae4fff53aa..920798a2ab 100644 --- a/app/src/panels/time-series/index.ts +++ b/app/src/panels/time-series/index.ts @@ -53,6 +53,7 @@ export default definePanel({ function getParsedOptionsFilter(filter: string | undefined) { if (!filter) return {}; + try { return JSON.parse(filter); } catch { diff --git a/app/src/router.ts b/app/src/router.ts index aee18ec92f..140da18c5b 100644 --- a/app/src/router.ts +++ b/app/src/router.ts @@ -92,6 +92,7 @@ export const onBeforeEach: NavigationGuard = async (to) => { // First load if (firstLoad) { firstLoad = false; + // Try retrieving a fresh access token on first load try { await refresh({ navigate: false }); @@ -111,8 +112,10 @@ export const onBeforeEach: NavigationGuard = async (to) => { if (to.meta?.public !== true) { if (appStore.hydrated === false) { appStore.hydrating = false; + if (appStore.authenticated === true) { await hydrate(); + if ( userStore.currentUser && to.fullPath === '/tfa-setup' && diff --git a/app/src/routes/tfa-setup.vue b/app/src/routes/tfa-setup.vue index 8724ab2125..77a2d0d875 100644 --- a/app/src/routes/tfa-setup.vue +++ b/app/src/routes/tfa-setup.vue @@ -94,6 +94,7 @@ export default defineComponent({ async function enable() { await enableTFA(); + if (error.value === null) { const redirectQuery = router.currentRoute.value.query.redirect as string; router.push(redirectQuery || (userStore.currentUser as User)?.last_page || '/login'); diff --git a/app/src/stores/collections.test.ts b/app/src/stores/collections.test.ts index 2784f864b6..8b4a686e24 100644 --- a/app/src/stores/collections.test.ts +++ b/app/src/stores/collections.test.ts @@ -35,6 +35,7 @@ test('parseField action should translate field name when translations are added ], }, }); + collectionsStore.collections = [mockCollectionWithTranslations].map(collectionsStore.prepareCollectionForApp); expect(collectionsStore.collections[0].name).toEqual('Collection A en-US'); expect(i18n.global.te(`collection_names.${mockCollection.collection}`)).toBe(true); @@ -49,6 +50,7 @@ test('parseField action should translate field name when translations are added ], }, }); + collectionsStore.collections = [mockCollectionWithMissingTranslations].map(collectionsStore.prepareCollectionForApp); expect(collectionsStore.collections[0].name).toEqual('A'); expect(i18n.global.te(`collection_names.${mockCollection.collection}`)).toBe(false); @@ -67,6 +69,7 @@ test('parseField action should translate field name when all translations are re ], }, }); + collectionsStore.collections = [mockCollectionWithTranslations].map(collectionsStore.prepareCollectionForApp); expect(collectionsStore.collections[0].name).toEqual('Collection A en-US'); expect(i18n.global.te(`collection_names.${mockCollection.collection}`)).toBe(true); @@ -76,6 +79,7 @@ test('parseField action should translate field name when all translations are re translations: null, }, }); + collectionsStore.collections = [mockCollectionWithoutTranslations].map(collectionsStore.prepareCollectionForApp); expect(collectionsStore.collections[0].name).toEqual('A'); expect(i18n.global.te(`collection_names.${mockCollection.collection}`)).toBe(false); diff --git a/app/src/stores/collections.ts b/app/src/stores/collections.ts index c820bedca3..25e4527253 100644 --- a/app/src/stores/collections.ts +++ b/app/src/stores/collections.ts @@ -153,6 +153,7 @@ export const useCollectionsStore = defineStore({ try { await api.patch(`/collections/${collection}`, updates); await this.hydrate(); + notify({ title: i18n.global.t('update_collection_success'), }); diff --git a/app/src/stores/fields.test.ts b/app/src/stores/fields.test.ts index 435e7cc754..edf76a243e 100644 --- a/app/src/stores/fields.test.ts +++ b/app/src/stores/fields.test.ts @@ -81,6 +81,7 @@ describe('parseField action', () => { ], }, }); + fieldsStore.fields = [mockFieldWithTranslations].map(fieldsStore.parseField); expect(fieldsStore.fields[0].name).toEqual('Name en-US'); expect(i18n.global.te(`fields.${mockField.collection}.${mockField.field}`)).toBe(true); @@ -95,6 +96,7 @@ describe('parseField action', () => { ], }, }); + fieldsStore.fields = [mockFieldWithoutTranslations].map(fieldsStore.parseField); expect(fieldsStore.fields[0].name).toEqual('Name'); expect(i18n.global.te(`fields.${mockField.collection}.${mockField.field}`)).toBe(false); @@ -113,6 +115,7 @@ describe('parseField action', () => { ], }, }); + fieldsStore.fields = [mockFieldWithTranslations].map(fieldsStore.parseField); expect(fieldsStore.fields[0].name).toEqual('name en-US'); expect(i18n.global.te(`fields.${mockField.collection}.${mockField.field}`)).toBe(true); @@ -122,6 +125,7 @@ describe('parseField action', () => { translations: null, }, }); + fieldsStore.fields = [mockFieldWithoutTranslations].map(fieldsStore.parseField); expect(fieldsStore.fields[0].name).toEqual('Name'); expect(i18n.global.te(`fields.${mockField.collection}.${mockField.field}`)).toBe(false); diff --git a/app/src/stores/fields.ts b/app/src/stores/fields.ts index 8cf57debc4..f417038c8a 100644 --- a/app/src/stores/fields.ts +++ b/app/src/stores/fields.ts @@ -350,6 +350,7 @@ export const useFieldsStore = defineStore({ } const relations = relationsStore.getRelationsForField(collection, field); + const relation = relations?.find((relation: Relation) => { return relation.field === field || relation.meta?.one_field === field; }); diff --git a/app/src/stores/insights.ts b/app/src/stores/insights.ts index f83982ad20..1f01b3d309 100644 --- a/app/src/stores/insights.ts +++ b/app/src/stores/insights.ts @@ -115,6 +115,7 @@ export const useInsightsStore = defineStore('insightsStore', () => { }), api.get('/panels', { params: { limit: -1, fields: ['*'], sort: ['dashboard'] } }), ]); + dashboards.value = dashboardsResponse.data.data; panels.value = panelsResponse.data.data; } catch { @@ -174,9 +175,11 @@ export const useInsightsStore = defineStore('insightsStore', () => { if (lastLoaded.length > MAX_CACHE_SIZE) { const removed = lastLoaded.shift(); + const removedPanels = unref(panels) .filter((panel) => panel.dashboard === removed) .map(({ id }) => id); + data.value = omit(data.value, ...removedPanels); } } @@ -348,6 +351,7 @@ export const useInsightsStore = defineStore('insightsStore', () => { if (panel) { const panelType = unref(panelTypes).find((panelType) => panelType.id === panel.type)!; + oldQuery = panelType.query?.( applyOptionsData(panel.options ?? {}, unref(variables), panelType.skipUndefinedKeys) ); @@ -374,9 +378,11 @@ export const useInsightsStore = defineStore('insightsStore', () => { // This panel has the edits applied const panel = unref(panelsWithEdits).find((panel) => panel.id === id)!; const panelType = unref(panelTypes).find((panelType) => panelType.id === panel.type)!; + const newQuery = panelType.query?.( applyOptionsData(panelEdits.options ?? {}, unref(variables), panelType.skipUndefinedKeys) ); + if (JSON.stringify(oldQuery) !== JSON.stringify(newQuery)) loadPanelData(panel); // Clear relational variable cache if collection was changed @@ -469,6 +475,7 @@ export const useInsightsStore = defineStore('insightsStore', () => { // Find all panels that are using this variable in their options const regex = new RegExp(`{{\\s*?${escapeStringRegexp(field)}\\s*?}}`); + const needReload = unref(panelsWithEdits).filter((panel) => { if (panel.id in unref(data) === false) return false; @@ -478,12 +485,15 @@ export const useInsightsStore = defineStore('insightsStore', () => { const panelType = unref(panelTypes).find((panelType) => panelType.id === panel.type); if (!panelType) return false; + const oldQuery = panelType.query?.( applyOptionsData(panel.options ?? {}, unref(variables), panelType.skipUndefinedKeys) ); + const newQuery = panelType.query?.( applyOptionsData(panel.options ?? {}, unref(newVariables), panelType.skipUndefinedKeys) ); + return JSON.stringify(oldQuery) !== JSON.stringify(newQuery); }); diff --git a/app/src/stores/settings.ts b/app/src/stores/settings.ts index ec68a67659..e8ebd642db 100644 --- a/app/src/stores/settings.ts +++ b/app/src/stores/settings.ts @@ -53,6 +53,7 @@ export const useSettingsStore = defineStore({ fields: ['translation_strings'], }, }); + const { translation_strings } = response.data.data; if (this.settings) this.settings.translation_strings = translation_strings; return translation_strings; diff --git a/app/src/utils/flatten-field-groups.test.ts b/app/src/utils/flatten-field-groups.test.ts index 571778ae50..c5b934861c 100644 --- a/app/src/utils/flatten-field-groups.test.ts +++ b/app/src/utils/flatten-field-groups.test.ts @@ -8,8 +8,10 @@ describe('utils/flatten-field-groups', () => { { name: 'ID', field: 'id', collection: 'test', key: 'id', path: 'id', type: 'integer' }, { name: 'Test Field', field: 'test', collection: 'test', key: 'test', path: 'test', type: 'string' }, ]; + expect(flattenFieldGroups(TreeWithoutGroups)).toEqual(TreeWithoutGroups); }); + it('Returns a tree without groups', () => { const TreeWithGroups: FieldNode[] = [ { name: 'ID', field: 'id', collection: 'test', key: 'id', path: 'id', type: 'integer' }, @@ -33,6 +35,7 @@ describe('utils/flatten-field-groups', () => { ], }, ]; + const TreeWithoutGroups: FieldNode[] = [ { name: 'ID', field: 'id', collection: 'test', key: 'id', path: 'id', type: 'integer' }, { @@ -44,8 +47,10 @@ describe('utils/flatten-field-groups', () => { type: 'string', }, ]; + expect(flattenFieldGroups(TreeWithGroups)).toEqual(TreeWithoutGroups); }); + it('Returns a tree without deeply nested groups', () => { const TreeWithNestedGroups: FieldNode[] = [ { name: 'ID', field: 'id', collection: 'test', key: 'id', path: 'id', type: 'integer' }, @@ -99,6 +104,7 @@ describe('utils/flatten-field-groups', () => { ], }, ]; + const TreeWithoutGroups: FieldNode[] = [ { name: 'ID', field: 'id', collection: 'test', key: 'id', path: 'id', type: 'integer' }, { @@ -118,6 +124,7 @@ describe('utils/flatten-field-groups', () => { type: 'string', }, ]; + expect(flattenFieldGroups(TreeWithNestedGroups)).toEqual(TreeWithoutGroups); }); }); diff --git a/app/src/utils/geometry/basemap.ts b/app/src/utils/geometry/basemap.ts index 62eaae2c8c..5c11b605d0 100644 --- a/app/src/utils/geometry/basemap.ts +++ b/app/src/utils/geometry/basemap.ts @@ -40,6 +40,7 @@ export function getStyleFromBasemapSource(basemap: BasemapSource): Style | strin const style: Style = { ...baseStyle }; const source: RasterSource = { type: 'raster' }; if (basemap.attribution) source.attribution = basemap.attribution; + if (basemap.type == 'raster') { source.tiles = expandUrl(basemap.url); source.tileSize = basemap.tileSize || 512; @@ -73,6 +74,7 @@ function expandUrl(url: string): string[] { } match = /\{(\d+)-(\d+)\}/.exec(url); + if (match) { // number range const stop = parseInt(match[2], 10); @@ -85,6 +87,7 @@ function expandUrl(url: string): string[] { } match = /\{(([a-z0-9]+)(,([a-z0-9]+))+)\}/.exec(url); + if (match) { // csv const subdomains = match[1].split(','); diff --git a/app/src/utils/geometry/controls.ts b/app/src/utils/geometry/controls.ts index 4818935256..0259deb0ca 100644 --- a/app/src/utils/geometry/controls.ts +++ b/app/src/utils/geometry/controls.ts @@ -70,9 +70,11 @@ export class BoxSelectControl { this.boxElement.className = options?.boxElementClass ?? 'selection-box'; this.groupElement = document.createElement('div'); this.groupElement.className = options?.groupElementClass ?? 'mapboxgl-ctrl mapboxgl-ctrl-group'; + this.selectButton = new ButtonControl(options?.selectButtonClass ?? 'ctrl-select', () => { this.activate(!this.shiftPressed); }); + this.groupElement.appendChild(this.selectButton.element); this.onKeyDownHandler = this.onKeyDown.bind(this); @@ -154,10 +156,12 @@ export class BoxSelectControl { onMouseMove(event: MouseEvent): void { this.lastPos = this.getMousePosition(event); + const minX = Math.min(this.startPos!.x, this.lastPos!.x), maxX = Math.max(this.startPos!.x, this.lastPos!.x), minY = Math.min(this.startPos!.y, this.lastPos!.y), maxY = Math.max(this.startPos!.y, this.lastPos!.y); + const transform = `translate(${minX}px, ${minY}px)`; const width = maxX - minX + 'px'; const height = maxY - minY + 'px'; @@ -166,6 +170,7 @@ export class BoxSelectControl { onMouseUp(event: MouseEvent): void { this.reset(); + if (!this.active()) { return; } @@ -173,6 +178,7 @@ export class BoxSelectControl { const features = this.map!.queryRenderedFeatures([this.startPos!, this.lastPos!], { layers: this.layers, }); + this.map!.fire('select.end', { features, alt: event.altKey }); } diff --git a/app/src/utils/geometry/index.ts b/app/src/utils/geometry/index.ts index b92d41a853..f2e6cee84d 100644 --- a/app/src/utils/geometry/index.ts +++ b/app/src/utils/geometry/index.ts @@ -25,9 +25,11 @@ export function expandBBox(bbox: BBox, coord: Coordinate): BBox { export function getBBox(object: AnyGeometry): BBox { let bbox: BBox = [Infinity, Infinity, -Infinity, -Infinity]; + coordEach(object as AllGeoJSON, (coord) => { bbox = expandBBox(bbox, coord as Coordinate); }); + return bbox; } @@ -118,6 +120,7 @@ export function toGeoJSON(entries: any[], options: GeometryOptions): FeatureColl export function flatten(geometry?: AnyGeometry): SimpleGeometry[] { if (!geometry) return []; + if (geometry.type == 'GeometryCollection') { return geometry.geometries.flatMap(flatten); } diff --git a/app/src/utils/get-default-values-from-fields.test.ts b/app/src/utils/get-default-values-from-fields.test.ts index 099ef6d732..6249b24b6e 100644 --- a/app/src/utils/get-default-values-from-fields.test.ts +++ b/app/src/utils/get-default-values-from-fields.test.ts @@ -31,6 +31,7 @@ test('Ignores PK default value', () => { name: 'ID', }, ]); + expect(values.value).toStrictEqual({}); }); @@ -45,6 +46,7 @@ test('Ignores schemaless fields', () => { name: 'Test', }, ]); + expect(values.value).toStrictEqual({}); }); @@ -132,6 +134,7 @@ test('Parses default values', () => { name: 'Test2', }, ]); + expect(values.value).toStrictEqual({ condition: 'test1', test1: '---', diff --git a/app/src/utils/get-local-type.test.ts b/app/src/utils/get-local-type.test.ts index 960099dba6..66d43c695d 100644 --- a/app/src/utils/get-local-type.test.ts +++ b/app/src/utils/get-local-type.test.ts @@ -27,6 +27,7 @@ test('Returns NULL for non-existing relations', () => { test('Returns GROUP for Alias', () => { const fieldsStore = useFieldsStore(); + (fieldsStore.getField as Mock).mockReturnValue({ collection: 'test_collection', field: 'test_fields', @@ -47,6 +48,7 @@ test('Returns GROUP for Alias', () => { test('Returns PRESENTATION for Alias', () => { const fieldsStore = useFieldsStore(); + (fieldsStore.getField as Mock).mockReturnValue({ collection: 'test_collection', field: 'test_fields', @@ -68,6 +70,7 @@ test('Returns PRESENTATION for Alias', () => { test('Returns STANDARD with no relations', () => { const fieldsStore = useFieldsStore(); + (fieldsStore.getField as Mock).mockReturnValue({ collection: 'test_collection', field: 'test_fields', @@ -89,6 +92,7 @@ test('Returns STANDARD with no relations', () => { test('Returns FILE for m2o relations to directus_files', () => { const fieldsStore = useFieldsStore(); + (fieldsStore.getField as Mock).mockReturnValue({ collection: 'test_collection', field: 'test_fields', @@ -102,6 +106,7 @@ test('Returns FILE for m2o relations to directus_files', () => { }); const relationsStore = useRelationsStore(); + (relationsStore.getRelationsForField as Mock).mockReturnValue([ { collection: 'test_collection', @@ -126,6 +131,7 @@ test('Returns FILE for m2o relations to directus_files', () => { test('Returns M2O', () => { const fieldsStore = useFieldsStore(); + (fieldsStore.getField as Mock).mockReturnValue({ collection: 'test_collection', field: 'test_fields', @@ -139,6 +145,7 @@ test('Returns M2O', () => { }); const relationsStore = useRelationsStore(); + (relationsStore.getRelationsForField as Mock).mockReturnValue([ { collection: 'test_collection', @@ -163,6 +170,7 @@ test('Returns M2O', () => { test('Returns O2M', () => { const fieldsStore = useFieldsStore(); + (fieldsStore.getField as Mock).mockReturnValue({ collection: 'test_collection', field: 'test_fields', @@ -176,6 +184,7 @@ test('Returns O2M', () => { }); const relationsStore = useRelationsStore(); + (relationsStore.getRelationsForField as Mock).mockReturnValue([ { collection: 'test_collection', @@ -200,6 +209,7 @@ test('Returns O2M', () => { test('Returns TRANSLATIONS for special M2M relations', () => { const fieldsStore = useFieldsStore(); + (fieldsStore.getField as Mock).mockReturnValue({ collection: 'test_collection', field: 'translations', @@ -215,6 +225,7 @@ test('Returns TRANSLATIONS for special M2M relations', () => { }); const relationsStore = useRelationsStore(); + (relationsStore.getRelationsForField as Mock).mockReturnValue([ { collection: 'test_collection_translations', @@ -256,6 +267,7 @@ test('Returns TRANSLATIONS for special M2M relations', () => { test('Returns M2A', () => { const fieldsStore = useFieldsStore(); + (fieldsStore.getField as Mock).mockReturnValue({ collection: 'collection_a', field: 'm2a', @@ -272,6 +284,7 @@ test('Returns M2A', () => { }); const relationsStore = useRelationsStore(); + (relationsStore.getRelationsForField as Mock).mockReturnValue([ { collection: 'test_collection_m2a', @@ -313,6 +326,7 @@ test('Returns M2A', () => { test('Returns M2O for searched relation', () => { const fieldsStore = useFieldsStore(); + (fieldsStore.getField as Mock).mockReturnValue({ collection: 'collection_a', field: 'relation', @@ -328,6 +342,7 @@ test('Returns M2O for searched relation', () => { }); const relationsStore = useRelationsStore(); + (relationsStore.getRelationsForField as Mock).mockReturnValue([ { collection: 'collection_a_collection_b', @@ -368,6 +383,7 @@ test('Returns M2O for searched relation', () => { test('Returns FILES for M2M relations to directus_files', () => { const fieldsStore = useFieldsStore(); + (fieldsStore.getField as Mock).mockReturnValue({ collection: 'collection_a', field: 'relation', @@ -383,6 +399,7 @@ test('Returns FILES for M2M relations to directus_files', () => { }); const relationsStore = useRelationsStore(); + (relationsStore.getRelationsForField as Mock).mockReturnValue([ { collection: 'collection_a_directus_files', @@ -423,6 +440,7 @@ test('Returns FILES for M2M relations to directus_files', () => { test('Returns M2M ', () => { const fieldsStore = useFieldsStore(); + (fieldsStore.getField as Mock).mockReturnValue({ collection: 'collection_a', field: 'relation', @@ -438,6 +456,7 @@ test('Returns M2M ', () => { }); const relationsStore = useRelationsStore(); + (relationsStore.getRelationsForField as Mock).mockReturnValue([ { collection: 'collection_a_collection_b', @@ -478,6 +497,7 @@ test('Returns M2M ', () => { test('Returns STANDARD as final fallback', () => { const fieldsStore = useFieldsStore(); + (fieldsStore.getField as Mock).mockReturnValue({ collection: 'test_collection', field: 'test', @@ -493,6 +513,7 @@ test('Returns STANDARD as final fallback', () => { }); const relationsStore = useRelationsStore(); + (relationsStore.getRelationsForField as Mock).mockReturnValue([ { doesnt: 'matter' }, { doesnt: 'matter' }, diff --git a/app/src/utils/jwt-payload.test.ts b/app/src/utils/jwt-payload.test.ts index 5ee1363c57..07cfd1d70b 100644 --- a/app/src/utils/jwt-payload.test.ts +++ b/app/src/utils/jwt-payload.test.ts @@ -5,6 +5,7 @@ import { jwtPayload } from '@/utils/jwt-payload'; test('Returns payload as JSON object from JWT', () => { const token = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c'; + const payload = jwtPayload(token); expect(payload).toEqual({ diff --git a/app/src/views/private/components/comments-sidebar-detail.vue b/app/src/views/private/components/comments-sidebar-detail.vue index bcac6e4583..58addd52e7 100644 --- a/app/src/views/private/components/comments-sidebar-detail.vue +++ b/app/src/views/private/components/comments-sidebar-detail.vue @@ -47,6 +47,7 @@ const props = defineProps<{ collection: string; primaryKey: string | number; }>(); + const { t } = useI18n(); const { activity, loading, refresh, count, userPreviews } = useActivity(props.collection, props.primaryKey); @@ -98,6 +99,7 @@ function useActivity(collection: string, primaryKey: string | number) { regex, (match) => `${userPreviews.value[match.substring(2)]}` ); + return { ...comment, display, diff --git a/app/src/views/private/components/drawer-item.vue b/app/src/views/private/components/drawer-item.vue index 35ef212f13..e63ce4a4d6 100644 --- a/app/src/views/private/components/drawer-item.vue +++ b/app/src/views/private/components/drawer-item.vue @@ -135,8 +135,10 @@ const fieldsStore = useFieldsStore(); const relationsStore = useRelationsStore(); const { internalActive } = useActiveState(); + const { junctionFieldInfo, relatedCollection, relatedCollectionInfo, setRelationEdits, relatedPrimaryKeyField } = useRelation(); + const { internalEdits, loading, initialValues } = useItem(); const { save, cancel } = useActions(); @@ -296,6 +298,7 @@ function useItem() { loading.value = true; const baseEndpoint = getEndpoint(props.collection); + const endpoint = props.collection.startsWith('directus_') ? `${baseEndpoint}/${props.primaryKey}` : `${baseEndpoint}/${encodeURIComponent(props.primaryKey)}`; @@ -325,6 +328,7 @@ function useItem() { loading.value = true; const baseEndpoint = getEndpoint(collection); + const endpoint = collection.startsWith('directus_') ? `${baseEndpoint}/${props.relatedPrimaryKey}` : `${baseEndpoint}/${encodeURIComponent(props.relatedPrimaryKey)}`; @@ -391,6 +395,7 @@ function useActions() { const fieldsToValidate = props.junctionField ? relatedCollectionFields.value : fieldsWithoutCircular.value; const defaultValues = getDefaultValuesFromFields(fieldsToValidate); const existingValues = props.junctionField ? initialValues?.value?.[props.junctionField] : initialValues?.value; + let errors = validateItem( merge({}, defaultValues.value, existingValues, editsToValidate), fieldsToValidate, diff --git a/app/src/views/private/components/folder-picker.vue b/app/src/views/private/components/folder-picker.vue index 1c84e06234..96c8dca114 100644 --- a/app/src/views/private/components/folder-picker.vue +++ b/app/src/views/private/components/folder-picker.vue @@ -70,6 +70,7 @@ export default defineComponent({ const loading = ref(false); const folders = ref([]); + const tree = computed(() => { return folders.value .filter((folder) => folder.parent === null) diff --git a/app/src/views/private/components/image-editor.vue b/app/src/views/private/components/image-editor.vue index 52583f27c2..c00f08e5b7 100644 --- a/app/src/views/private/components/image-editor.vue +++ b/app/src/views/private/components/image-editor.vue @@ -406,6 +406,7 @@ export default defineComponent({ }); const localCropping = ref(false); + const cropping = computed({ get() { return localCropping.value; diff --git a/app/src/views/private/components/render-template.vue b/app/src/views/private/components/render-template.vue index a50e93e968..3d98741ab5 100644 --- a/app/src/views/private/components/render-template.vue +++ b/app/src/views/private/components/render-template.vue @@ -76,6 +76,7 @@ const parts = computed(() => function handleArray(fieldKeyBefore: string, fieldKeyAfter: string) { const value = get(props.item, fieldKeyBefore); + const field = fieldsStore.getField(props.collection, fieldKeyBefore) || props.fields?.find((field) => field.field === fieldKeyBefore); diff --git a/app/src/views/private/components/revisions-drawer-updates-change.vue b/app/src/views/private/components/revisions-drawer-updates-change.vue index fc3e05455c..46f8820db5 100644 --- a/app/src/views/private/components/revisions-drawer-updates-change.vue +++ b/app/src/views/private/components/revisions-drawer-updates-change.vue @@ -47,6 +47,7 @@ export default defineComponent({ }, setup(props) { const { t } = useI18n(); + const changesFiltered = computed(() => { return (props.changes as Change[]).filter((change: any) => { if (props.updated) return true; diff --git a/app/src/views/private/components/revisions-drawer-updates.vue b/app/src/views/private/components/revisions-drawer-updates.vue index 505444d91d..6d0a3ce792 100644 --- a/app/src/views/private/components/revisions-drawer-updates.vue +++ b/app/src/views/private/components/revisions-drawer-updates.vue @@ -73,6 +73,7 @@ export default defineComponent({ if (isEqual(currentValue, previousValue)) { if (field?.meta?.special && field.meta.special.includes('conceal')) { updated = true; + changes = [ { updated: true, diff --git a/app/src/views/private/components/shares-sidebar-detail.vue b/app/src/views/private/components/shares-sidebar-detail.vue index 5218aa9966..e958676965 100644 --- a/app/src/views/private/components/shares-sidebar-detail.vue +++ b/app/src/views/private/components/shares-sidebar-detail.vue @@ -205,6 +205,7 @@ export default defineComponent({ sort: 'name', }, }); + count.value = response.data.data.length; shares.value = response.data.data; } catch (error: any) { diff --git a/app/src/views/private/components/sidebar-detail.vue b/app/src/views/private/components/sidebar-detail.vue index 47a664daee..609d590022 100644 --- a/app/src/views/private/components/sidebar-detail.vue +++ b/app/src/views/private/components/sidebar-detail.vue @@ -55,6 +55,7 @@ export default defineComponent({ value: props.title, group: 'sidebar-detail', }); + const appStore = useAppStore(); const { sidebarOpen } = toRefs(appStore); return { active, toggle, sidebarOpen }; diff --git a/app/src/views/private/components/user-popover.vue b/app/src/views/private/components/user-popover.vue index e2937a8388..824f4011fc 100644 --- a/app/src/views/private/components/user-popover.vue +++ b/app/src/views/private/components/user-popover.vue @@ -89,6 +89,7 @@ export default defineComponent({ fields: ['id', 'first_name', 'last_name', 'avatar.id', 'role.name', 'status', 'email'], }, }); + data.value = response.data.data; } catch (err: any) { error.value = err; diff --git a/app/src/views/private/private-view.vue b/app/src/views/private/private-view.vue index 416024366a..583d87022d 100644 --- a/app/src/views/private/private-view.vue +++ b/app/src/views/private/private-view.vue @@ -115,8 +115,10 @@ const { width: contentWidth } = useElementSize(contentEl); const { width: sidebarWidth } = useElementSize(sidebarEl); const moduleNavEl = ref(); + const { handleHover, onResizeHandlePointerDown, resetModuleNavWidth, onPointerMove, onPointerUp } = useModuleNavResize(); + useEventListener(window, 'pointermove', onPointerMove); useEventListener(window, 'pointerup', onPointerUp); @@ -224,6 +226,7 @@ function useModuleNavResize() { function onPointerUp() { if (dragging.value === true) { dragging.value = false; + if (rafId.value) { window.cancelAnimationFrame(rafId.value); } diff --git a/packages/composables/src/use-items.test.ts b/packages/composables/src/use-items.test.ts index 6a1955f962..70a5af728c 100644 --- a/packages/composables/src/use-items.test.ts +++ b/packages/composables/src/use-items.test.ts @@ -11,6 +11,7 @@ import { useCollection } from './use-collection.js'; const mockData = { id: 1 }; const mockCountData = { count: 2 }; const mockCountDistinctData = { countDistinct: { id: 3 } }; + const mockPrimaryKeyField: Field = { collection: 'test_collection', field: 'id', @@ -19,6 +20,7 @@ const mockPrimaryKeyField: Field = { schema: null, meta: null, }; + const mockApiGet = vi.fn(); const mockApiPost = vi.fn(); @@ -50,6 +52,7 @@ vi.mock('./use-system.js', () => ({ post: mockApiPost, })), })); + vi.mock('./use-collection.js'); afterEach(() => { diff --git a/packages/composables/src/use-items.ts b/packages/composables/src/use-items.ts index 43d10cc1e5..8dbfcaee24 100644 --- a/packages/composables/src/use-items.ts +++ b/packages/composables/src/use-items.ts @@ -63,6 +63,7 @@ export function useItems(collection: Ref, query: ComputedQuery): total: null, filter: null, }; + let loadingTimeout: NodeJS.Timeout | null = null; const fetchItems = throttle(getItems, 500); @@ -250,6 +251,7 @@ export function useItems(collection: Ref, query: ComputedQuery): const count = primaryKeyField.value ? Number(response.data.data[0].countDistinct[primaryKeyField.value.field]) : Number(response.data.data[0].count); + existingRequests.total = null; totalCount.value = count; @@ -287,6 +289,7 @@ export function useItems(collection: Ref, query: ComputedQuery): const count = primaryKeyField.value ? Number(response.data.data[0].countDistinct[primaryKeyField.value.field]) : Number(response.data.data[0].count); + existingRequests.filter = null; itemCount.value = count; diff --git a/packages/extensions-sdk/src/cli/commands/add.ts b/packages/extensions-sdk/src/cli/commands/add.ts index 01c1b4c27a..97b8968c27 100644 --- a/packages/extensions-sdk/src/cli/commands/add.ts +++ b/packages/extensions-sdk/src/cli/commands/add.ts @@ -114,6 +114,7 @@ export default async function add(): Promise { ]; const newExtensionOptions: ExtensionOptions = { ...extensionOptions, entries: newEntries }; + const newExtensionManifest = { ...extensionManifest, [EXTENSION_PKG_KEY]: newExtensionOptions, @@ -251,6 +252,7 @@ export default async function add(): Promise { host: extensionOptions.host, hidden: extensionOptions.hidden, }; + const newExtensionManifest = { ...extensionManifest, name: EXTENSION_NAME_REGEX.test(extensionName) ? extensionName : `directus-extension-${extensionName}`, diff --git a/packages/extensions-sdk/src/cli/commands/build.ts b/packages/extensions-sdk/src/cli/commands/build.ts index b4a93eab16..7b4d37108f 100644 --- a/packages/extensions-sdk/src/cli/commands/build.ts +++ b/packages/extensions-sdk/src/cli/commands/build.ts @@ -133,6 +133,7 @@ export default async function build(options: BuildOptions): Promise { ).join(', ')}.`, 'error' ); + process.exit(1); } @@ -146,6 +147,7 @@ export default async function build(options: BuildOptions): Promise { `Extension output file has to be specified using the ${chalk.blue('[-o, --output ]')} option.`, 'error' ); + process.exit(1); } @@ -160,6 +162,7 @@ export default async function build(options: BuildOptions): Promise { )}.`, 'error' ); + process.exit(1); } @@ -170,6 +173,7 @@ export default async function build(options: BuildOptions): Promise { )}.`, 'error' ); + process.exit(1); } @@ -192,6 +196,7 @@ export default async function build(options: BuildOptions): Promise { )}.`, 'error' ); + process.exit(1); } @@ -202,6 +207,7 @@ export default async function build(options: BuildOptions): Promise { )}.`, 'error' ); + process.exit(1); } @@ -335,6 +341,7 @@ async function buildHybridExtension({ minify, plugins, }); + const rollupOptionsApi = getRollupOptions({ mode: 'node', input: inputApi, @@ -343,6 +350,7 @@ async function buildHybridExtension({ minify, plugins, }); + const rollupOutputOptionsApp = getRollupOutputOptions({ mode: 'browser', output: outputApp, sourcemap }); const rollupOutputOptionsApi = getRollupOutputOptions({ mode: 'node', output: outputApi, sourcemap }); @@ -453,6 +461,7 @@ async function buildBundleExtension({ minify, plugins, }); + const rollupOptionsApi = getRollupOptions({ mode: 'node', input: { entry: entrypointApi }, @@ -461,6 +470,7 @@ async function buildBundleExtension({ minify, plugins, }); + const rollupOutputOptionsApp = getRollupOutputOptions({ mode: 'browser', output: outputApp, sourcemap }); const rollupOutputOptionsApi = getRollupOutputOptions({ mode: 'node', output: outputApi, sourcemap }); @@ -543,6 +553,7 @@ async function watchExtension(config: RollupConfig | RollupConfig[]) { } break; + case 'ERROR': { buildCount--; diff --git a/packages/extensions-sdk/src/cli/commands/create.ts b/packages/extensions-sdk/src/cli/commands/create.ts index aa6fa1f184..dbfb473271 100644 --- a/packages/extensions-sdk/src/cli/commands/create.ts +++ b/packages/extensions-sdk/src/cli/commands/create.ts @@ -40,6 +40,7 @@ export default async function create(type: string, name: string, options: Create ).join(', ')}.`, 'error' ); + process.exit(1); } @@ -124,6 +125,7 @@ async function createLocalExtension({ ).join(', ')}.`, 'error' ); + process.exit(1); } @@ -133,6 +135,7 @@ async function createLocalExtension({ await copyTemplate(type, targetPath, 'src', language); const host = `^${getSdkVersion()}`; + const options: ExtensionOptions = isIn(type, HYBRID_EXTENSION_TYPES) ? { type, @@ -146,6 +149,7 @@ async function createLocalExtension({ source: `src/index.${languageToShort(language)}`, host, }; + const packageManifest = getPackageManifest(name, options, await getExtensionDevDeps(type, language)); await fse.writeJSON(path.join(targetPath, 'package.json'), packageManifest, { spaces: '\t' }); diff --git a/packages/schema/src/dialects/cockroachdb.ts b/packages/schema/src/dialects/cockroachdb.ts index 0df6ef5053..016ca1a995 100644 --- a/packages/schema/src/dialects/cockroachdb.ts +++ b/packages/schema/src/dialects/cockroachdb.ts @@ -226,6 +226,7 @@ export default class CockroachDB implements SchemaInspector { .select<{ tablename: string }[]>('tablename') .from('pg_catalog.pg_tables') .whereIn('schemaname', this.explodedSchema); + return records.map(({ tablename }) => tablename); } @@ -283,6 +284,7 @@ export default class CockroachDB implements SchemaInspector { .from('information_schema.tables') .whereIn('table_schema', this.explodedSchema) .andWhere({ table_name: table }); + const record = await this.knex.select<{ exists: boolean }>(this.knex.raw('exists (?)', [subquery])).first(); return record?.exists || false; } @@ -509,6 +511,7 @@ export default class CockroachDB implements SchemaInspector { table_name: table, column_name: column, }); + const record = await this.knex.select<{ exists: boolean }>(this.knex.raw('exists (?)', [subquery])).first(); return record?.exists || false; } diff --git a/packages/schema/src/dialects/mssql.ts b/packages/schema/src/dialects/mssql.ts index 3a516ba5b9..4910a76caa 100644 --- a/packages/schema/src/dialects/mssql.ts +++ b/packages/schema/src/dialects/mssql.ts @@ -181,6 +181,7 @@ export default class MSSQL implements SchemaInspector { TABLE_CATALOG: this.knex.client.database(), TABLE_SCHEMA: this.schema, }); + return records.map(({ TABLE_NAME }) => TABLE_NAME); } @@ -234,6 +235,7 @@ export default class MSSQL implements SchemaInspector { TABLE_SCHEMA: this.schema, }) .first(); + return (result && result.count === 1) || false; } diff --git a/packages/schema/src/dialects/mysql.ts b/packages/schema/src/dialects/mysql.ts index 27467d626c..a9c239e35b 100644 --- a/packages/schema/src/dialects/mysql.ts +++ b/packages/schema/src/dialects/mysql.ts @@ -148,6 +148,7 @@ export default class MySQL implements SchemaInspector { TABLE_TYPE: 'BASE TABLE', TABLE_SCHEMA: this.knex.client.database(), }); + return records.map(({ TABLE_NAME }) => TABLE_NAME); } @@ -203,6 +204,7 @@ export default class MySQL implements SchemaInspector { table_name: table, }) .first(); + return (result && result.count === 1) || false; } @@ -293,6 +295,7 @@ export default class MySQL implements SchemaInspector { const first = records.findIndex((_column) => { return column.name === _column.name && column.table === _column.table; }); + return first === index; }); } @@ -310,6 +313,7 @@ export default class MySQL implements SchemaInspector { column_name: column, }) .first(); + return !!(result && result.count); } diff --git a/packages/schema/src/dialects/oracledb.ts b/packages/schema/src/dialects/oracledb.ts index 41428bae7d..c11d16f149 100644 --- a/packages/schema/src/dialects/oracledb.ts +++ b/packages/schema/src/dialects/oracledb.ts @@ -173,6 +173,7 @@ export default class oracleDB implements SchemaInspector { `) ) .from('USER_TABLES'); + return records.map(({ name }) => name); } @@ -213,6 +214,7 @@ export default class oracleDB implements SchemaInspector { .from('USER_TABLES') .where({ TABLE_NAME: table }) .first(); + return !!result?.count; } @@ -320,6 +322,7 @@ export default class oracleDB implements SchemaInspector { 'c.COLUMN_NAME': column, }) .first(); + return rawColumnToColumn(rawColumn); } @@ -356,6 +359,7 @@ export default class oracleDB implements SchemaInspector { HIDDEN_COLUMN: 'NO', }) .first(); + return !!result?.count; } diff --git a/packages/schema/src/dialects/sqlite.ts b/packages/schema/src/dialects/sqlite.ts index 11dbf194bc..de26598f06 100644 --- a/packages/schema/src/dialects/sqlite.ts +++ b/packages/schema/src/dialects/sqlite.ts @@ -48,6 +48,7 @@ export default class SQLite implements SchemaInspector { if (table in overview === false) { const primaryKeys = columns.filter((column) => column.pk !== 0); + overview[table] = { primary: primaryKeys.length !== 1 ? (undefined as any) : primaryKeys[0]!.name!, columns: {}, @@ -86,6 +87,7 @@ export default class SQLite implements SchemaInspector { .select('name') .from('sqlite_master') .whereRaw(`type = 'table' AND name NOT LIKE 'sqlite_%'`); + return records.map(({ name }) => name) as string[]; } @@ -222,9 +224,11 @@ export default class SQLite implements SchemaInspector { */ async hasColumn(table: string, column: string): Promise { let isColumn = false; + const results = await this.knex.raw( `SELECT COUNT(*) AS ct FROM pragma_table_xinfo('${table}') WHERE name='${column}'` ); + const resultsVal = results[0]['ct']; if (resultsVal !== 0) { diff --git a/packages/storage-driver-azure/src/index.test.ts b/packages/storage-driver-azure/src/index.test.ts index c341966599..ba40f430de 100644 --- a/packages/storage-driver-azure/src/index.test.ts +++ b/packages/storage-driver-azure/src/index.test.ts @@ -495,6 +495,7 @@ describe('#list', () => { await driver.list().next(); expect(driver['fullPath']).toHaveBeenCalledWith(''); + expect(mockListBlobsFlat).toHaveBeenCalledWith({ prefix: '', }); @@ -504,6 +505,7 @@ describe('#list', () => { await driver.list(sample.path.input).next(); expect(driver['fullPath']).toHaveBeenCalledWith(sample.path.input); + expect(mockListBlobsFlat).toHaveBeenCalledWith({ prefix: sample.path.inputFull, }); diff --git a/packages/storage-driver-cloudinary/src/index.test.ts b/packages/storage-driver-cloudinary/src/index.test.ts index 4c9dfc0a19..b312f785e6 100644 --- a/packages/storage-driver-cloudinary/src/index.test.ts +++ b/packages/storage-driver-cloudinary/src/index.test.ts @@ -259,6 +259,7 @@ describe('#toFormUrlEncoded', () => { describe('#getFullSignature', () => { let mockPayload: Record; + let mockCreateHash: { update: Mock; digest: Mock; @@ -337,6 +338,7 @@ describe('#getFullSignature', () => { describe('#getParameterSignature', () => { let mockHash: string; let result: string; + let mockCreateHash: { update: Mock; digest: Mock; @@ -596,6 +598,7 @@ describe('#read', () => { describe('#stat', () => { let mockResponse: { json: Mock; status: number }; + let mockResponseBody: { bytes: number; created_at: string; @@ -670,6 +673,7 @@ describe('#stat', () => { test('Throws error when status is >400', async () => { mockResponse.status = randNumber({ min: 400, max: 599 }); + try { await driver.stat(sample.path.input); } catch (err: any) { @@ -680,6 +684,7 @@ describe('#stat', () => { test('Returns size/modified from bytes/created_at from Cloudinary', async () => { const result = await driver.stat(sample.path.input); + expect(result).toStrictEqual({ size: sample.file.size, modified: sample.file.modified, @@ -711,6 +716,7 @@ describe('#exists', () => { describe('#move', () => { let mockResponse: { json: Mock; status: number }; + let mockResponseBody: { error?: { message?: string }; }; @@ -878,6 +884,7 @@ describe('#write', () => { await driver.write(sample.path.input, stream); expect(driver['uploadChunk']).toHaveBeenCalledOnce(); + expect(driver['uploadChunk']).toHaveBeenCalledWith({ resourceType: sample.resourceType, blob: new Blob(chunks), @@ -989,12 +996,15 @@ describe('#write', () => { describe('#uploadChunk', () => { let mockResponse: { json: Mock; status: number }; + let mockResponseBody: { error?: { message?: string }; }; + let mockFormData: { set: Mock; }; + let input: Parameters<(typeof driver)['uploadChunk']>[0]; beforeEach(() => { @@ -1063,6 +1073,7 @@ describe('#uploadChunk', () => { test('Throws an error when the response statusCode is >=400', async () => { mockResponse.status = randNumber({ min: 400, max: 599 }); + try { await driver['uploadChunk'](input); } catch (err: any) { @@ -1189,6 +1200,7 @@ describe('#list', () => { test('Fetches search api results', async () => { await driver.list(sample.path.input).next(); + expect(fetch).toHaveBeenCalledWith( `https://api.cloudinary.com/v1_1/${sample.config.cloudName}/resources/search?expression=${sample.publicId.input}*&next_cursor=`, { @@ -1226,6 +1238,7 @@ describe('#list', () => { } expect(fetch).toHaveBeenCalledTimes(2); + expect(fetch).toHaveBeenCalledWith( `https://api.cloudinary.com/v1_1/${sample.config.cloudName}/resources/search?expression=${sample.publicId.input}*&next_cursor=${mockNextCursor}`, { diff --git a/packages/storage-driver-gcs/src/index.test.ts b/packages/storage-driver-gcs/src/index.test.ts index 62bc1ad688..7b6fe27f88 100644 --- a/packages/storage-driver-gcs/src/index.test.ts +++ b/packages/storage-driver-gcs/src/index.test.ts @@ -213,6 +213,7 @@ describe('#write', () => { let mockWriteStream: PassThrough; let mockCreateWriteStream: Mock; let mockSave: Mock; + let mockFile: { createWriteStream: Mock; save: Mock; diff --git a/packages/storage-driver-s3/src/index.test.ts b/packages/storage-driver-s3/src/index.test.ts index 401e6b07c2..f19b6a7222 100644 --- a/packages/storage-driver-s3/src/index.test.ts +++ b/packages/storage-driver-s3/src/index.test.ts @@ -314,6 +314,7 @@ describe('#read', () => { await driver.read(sample.path.input); expect(driver['fullPath']).toHaveBeenCalledWith(sample.path.input); + expect(GetObjectCommand).toHaveBeenCalledWith({ Key: sample.path.inputFull, Bucket: sample.config.bucket, @@ -394,6 +395,7 @@ describe('#stat', () => { await driver.stat(sample.path.input); expect(driver['fullPath']).toHaveBeenCalledWith(sample.path.input); + expect(HeadObjectCommand).toHaveBeenCalledWith({ Key: sample.path.inputFull, Bucket: sample.config.bucket, diff --git a/packages/storage/src/index.test.ts b/packages/storage/src/index.test.ts index d6490e4ed3..5c1ae84321 100644 --- a/packages/storage/src/index.test.ts +++ b/packages/storage/src/index.test.ts @@ -63,6 +63,7 @@ describe('#registerLocation', () => { describe('#location', () => { test(`Throws error if location is used that wasn't registered`, () => { const manager = new StorageManager(); + expect(() => manager.location('missing')).toThrowErrorMatchingInlineSnapshot( '"Location \\"missing\\" doesn\'t exist."' ); diff --git a/packages/update-check/src/index.test.ts b/packages/update-check/src/index.test.ts index 53359968a8..689f5379e7 100644 --- a/packages/update-check/src/index.test.ts +++ b/packages/update-check/src/index.test.ts @@ -50,6 +50,7 @@ test('Throws error if response is not ok', async () => { await isUpToDate(sample.name, sample.version); } catch (err: any) { expect(err).toBeInstanceOf(Error); + expect(err.message).toBe( `Couldn't find latest version for package "${sample.name}": ${mockResponse.status} ${mockResponse.statusText}` ); diff --git a/packages/utils/shared/abbreviate-number.test.ts b/packages/utils/shared/abbreviate-number.test.ts index 7f1a84dbe2..e58f550fb2 100644 --- a/packages/utils/shared/abbreviate-number.test.ts +++ b/packages/utils/shared/abbreviate-number.test.ts @@ -5,9 +5,11 @@ describe('when no unit is given', () => { it('when under 1000', () => { expect(abbreviateNumber(0.78, 2)).toBe('0.78'); }); + it('when a number over 1000 is given', () => { expect(abbreviateNumber(7008, 0)).toBe('7K'); }); + it('when negative number is given', () => { expect(abbreviateNumber(-7008, 0)).toBe('-7K'); }); @@ -17,9 +19,11 @@ describe('when unit M is given', () => { it('when under 1000', () => { expect(abbreviateNumber(0.78, 2, ['M'])).toBe('0.78'); }); + it('when over 1000', () => { expect(abbreviateNumber(8000, 0, ['M'])).toBe('8M'); }); + it('when negative', () => { expect(abbreviateNumber(-7008, 0, ['M'])).toBe('-7M'); }); @@ -29,9 +33,11 @@ describe('when multiple units(["M","T"]) are given', () => { it('when under 1000', () => { expect(abbreviateNumber(0.78, 2, ['M', 'T'])).toBe('0.78'); }); + it('when number is over 1000', () => { expect(abbreviateNumber(7008, 0, ['M', 'T'])).toBe('7M'); }); + it('returns a string representation of the rounded number when negative', () => { expect(abbreviateNumber(-7008, 0, ['M', 'T'])).toBe('-7M'); }); diff --git a/packages/utils/shared/add-field-flag.test.ts b/packages/utils/shared/add-field-flag.test.ts index 40ba485486..e5ae7923a2 100644 --- a/packages/utils/shared/add-field-flag.test.ts +++ b/packages/utils/shared/add-field-flag.test.ts @@ -8,7 +8,9 @@ describe('addFieldFlag', () => { field: 'some_field', type: 'string', }; + addFieldFlag(field, 'cast-timestamp'); + expect(field.meta).toEqual({ special: ['cast-timestamp'], }); @@ -20,7 +22,9 @@ describe('addFieldFlag', () => { type: 'string', meta: { special: null }, }; + addFieldFlag(field, 'cast-timestamp'); + expect(field.meta).toEqual({ special: ['cast-timestamp'], }); @@ -32,7 +36,9 @@ describe('addFieldFlag', () => { type: 'string', meta: { special: ['cast-datetime'] }, }; + addFieldFlag(field, 'cast-timestamp'); + expect(field.meta).toEqual({ special: ['cast-datetime', 'cast-timestamp'], }); @@ -44,7 +50,9 @@ describe('addFieldFlag', () => { type: 'string', meta: { special: ['cast-datetime', 'cast-timestamp'] }, }; + addFieldFlag(field, 'cast-datetime'); + expect(field.meta).toEqual({ special: ['cast-datetime', 'cast-timestamp'], }); diff --git a/packages/utils/shared/adjust-date.test.ts b/packages/utils/shared/adjust-date.test.ts index efdde45d68..cee07191c7 100644 --- a/packages/utils/shared/adjust-date.test.ts +++ b/packages/utils/shared/adjust-date.test.ts @@ -3,6 +3,7 @@ import { adjustDate } from './adjust-date.js'; describe('Adjust a given date by a given change in duration.', () => { const date = new Date('2021-09-20T21:06:51.517Z'); + it('returns undefined when the adjustment isnt in a supported format', () => { expect(adjustDate(date, '-ms1')).toBe(undefined); }); diff --git a/packages/utils/shared/compress.test.ts b/packages/utils/shared/compress.test.ts index e2263785de..5f77d41ca5 100644 --- a/packages/utils/shared/compress.test.ts +++ b/packages/utils/shared/compress.test.ts @@ -37,9 +37,11 @@ const geoJSON = { }; const dateString = '2022-02-14T01:02:11.000Z'; + const dateInput = { date_created: new Date(dateString), }; + const dateOutput = { date_created: dateString, }; diff --git a/packages/utils/shared/deep-map.test.ts b/packages/utils/shared/deep-map.test.ts index 61fd8f8604..39b02275b3 100644 --- a/packages/utils/shared/deep-map.test.ts +++ b/packages/utils/shared/deep-map.test.ts @@ -10,11 +10,13 @@ describe('deepMap', () => { const mockObject = { _and: [{ field: { _eq: 'field' } }] }; expect(deepMap(mockObject, mockIterator)).toStrictEqual({ _and: [{ field: { _eq: 'Test field' } }] }); }); + it('returns object param when passed neither an object or an array.', () => { const mockObject = 'test string'; expect(deepMap(mockObject, mockIterator)).toBe(mockObject); }); + it('returns an array of the iterators vals', () => { const mockObject = ['test', 'test2']; diff --git a/packages/utils/shared/define-extension.test.ts b/packages/utils/shared/define-extension.test.ts index 0640a66c87..af51e7af9e 100644 --- a/packages/utils/shared/define-extension.test.ts +++ b/packages/utils/shared/define-extension.test.ts @@ -34,7 +34,9 @@ describe('define-extensions', () => { types: types, options: null, }; + const displayConfig = { id: '1', name: 'test', icon: 'icon', component: mockComponent, types: types, options: null }; + const layoutConfig = { id: '1', name: 'test', @@ -43,12 +45,14 @@ describe('define-extensions', () => { slots: { options: mockComponent, sidebar: mockComponent, actions: mockComponent }, setup: mockRecord, }; + const moduleConfig = { id: '1', name: 'test', icon: 'icon', routes: [], }; + const panelConfig = { id: '1', name: 'test', @@ -72,6 +76,7 @@ describe('define-extensions', () => { overview: null, options: null, }; + const operationApiConfig = { id: '1', handler: mockHandler, diff --git a/packages/utils/shared/generate-joi.test.ts b/packages/utils/shared/generate-joi.test.ts index de546fa570..75f66dea5b 100644 --- a/packages/utils/shared/generate-joi.test.ts +++ b/packages/utils/shared/generate-joi.test.ts @@ -33,217 +33,260 @@ describe(`generateJoi`, () => { }) .unknown() .describe(); + expect(generateJoi(mockFieldFilter).describe()).toStrictEqual(mockSchema); }); it(`returns the correct schema with an option of "requireAll" true`, () => { const mockFieldFilter = { field: { _eq: 'field' } } as FieldFilter; const mockOptions = { requireAll: true } as JoiOptions; + const mockSchema = Joi.object({ field: Joi.any().equal('field').required(), }) .unknown() .describe(); + expect(generateJoi(mockFieldFilter, mockOptions).describe()).toStrictEqual(mockSchema); }); it(`returns the correct schema for an _eq match`, () => { const mockFieldFilter = { field: { _eq: 'field' } } as FieldFilter; + const mockSchema = Joi.object({ field: Joi.any().equal('field'), }) .unknown() .describe(); + expect(generateJoi(mockFieldFilter).describe()).toStrictEqual(mockSchema); }); it(`returns the correct schema for a _neq match`, () => { const mockFieldFilter = { field: { _neq: 'field' } } as FieldFilter; + const mockSchema = Joi.object({ field: Joi.any().not('field'), }) .unknown() .describe(); + expect(generateJoi(mockFieldFilter).describe()).toStrictEqual(mockSchema); }); it(`returns the correct schema for an integer _eq match`, () => { const mockFieldFilter = { field: { _eq: '123' } } as FieldFilter; + const mockSchema = Joi.object({ field: Joi.any().equal('123', 123), }) .unknown() .describe(); + expect(generateJoi(mockFieldFilter).describe()).toStrictEqual(mockSchema); }); it(`returns the correct schema for an integer _neq match`, () => { const mockFieldFilter = { field: { _neq: '123' } } as FieldFilter; + const mockSchema = Joi.object({ field: Joi.any().not('123', 123), }) .unknown() .describe(); + expect(generateJoi(mockFieldFilter).describe()).toStrictEqual(mockSchema); }); it(`returns the correct schema for a float _eq match`, () => { const mockFieldFilter = { field: { _eq: '123.456' } } as FieldFilter; + const mockSchema = Joi.object({ field: Joi.any().equal('123.456', 123.456), }) .unknown() .describe(); + expect(generateJoi(mockFieldFilter).describe()).toStrictEqual(mockSchema); }); it(`returns the correct schema for a float _neq match`, () => { const mockFieldFilter = { field: { _neq: '123.456' } } as FieldFilter; + const mockSchema = Joi.object({ field: Joi.any().not('123.456', 123.456), }) .unknown() .describe(); + expect(generateJoi(mockFieldFilter).describe()).toStrictEqual(mockSchema); }); it(`returns the correct schema for a null _eq match`, () => { const mockFieldFilter = { field: { _eq: null } } as FieldFilter; + const mockSchema = Joi.object({ field: Joi.any().equal(null), }) .unknown() .describe(); + expect(generateJoi(mockFieldFilter).describe()).toStrictEqual(mockSchema); }); it(`returns the correct schema for a null _neq match`, () => { const mockFieldFilter = { field: { _neq: null } } as FieldFilter; + const mockSchema = Joi.object({ field: Joi.any().not(null), }) .unknown() .describe(); + expect(generateJoi(mockFieldFilter).describe()).toStrictEqual(mockSchema); }); it(`returns the correct schema for an empty string _eq match`, () => { const mockFieldFilter = { field: { _eq: '' } } as FieldFilter; + const mockSchema = Joi.object({ field: Joi.any().equal(''), }) .unknown() .describe(); + expect(generateJoi(mockFieldFilter).describe()).toStrictEqual(mockSchema); }); it(`returns the correct schema for an empty string _neq match`, () => { const mockFieldFilter = { field: { _neq: '' } } as FieldFilter; + const mockSchema = Joi.object({ field: Joi.any().not(''), }) .unknown() .describe(); + expect(generateJoi(mockFieldFilter).describe()).toStrictEqual(mockSchema); }); it(`returns the correct schema for a true _eq match`, () => { const mockFieldFilter = { field: { _eq: true } } as FieldFilter; + const mockSchema = Joi.object({ field: Joi.any().equal(true), }) .unknown() .describe(); + expect(generateJoi(mockFieldFilter).describe()).toStrictEqual(mockSchema); }); it(`returns the correct schema for a true _neq match`, () => { const mockFieldFilter = { field: { _neq: true } } as FieldFilter; + const mockSchema = Joi.object({ field: Joi.any().not(true), }) .unknown() .describe(); + expect(generateJoi(mockFieldFilter).describe()).toStrictEqual(mockSchema); }); it(`returns the correct schema for a false _eq match`, () => { const mockFieldFilter = { field: { _eq: false } } as FieldFilter; + const mockSchema = Joi.object({ field: Joi.any().equal(false), }) .unknown() .describe(); + expect(generateJoi(mockFieldFilter).describe()).toStrictEqual(mockSchema); }); it(`returns the correct schema for a false _neq match`, () => { const mockFieldFilter = { field: { _neq: false } } as FieldFilter; + const mockSchema = Joi.object({ field: Joi.any().not(false), }) .unknown() .describe(); + expect(generateJoi(mockFieldFilter).describe()).toStrictEqual(mockSchema); }); it(`returns the correct schema for an _ncontains contain match`, () => { const mockFieldFilter = { field: { _ncontains: 'field' } } as FieldFilter; + const mockSchema = Joi.object({ field: (Joi.string() as StringSchema).ncontains('field'), }) .unknown() .describe(); + expect(generateJoi(mockFieldFilter).describe()).toStrictEqual(mockSchema); }); it(`returns the correct schema for an _ncontains with null value`, () => { const mockFieldFilter = { field: { _ncontains: null } } as FieldFilter; + const mockSchema = Joi.object({ field: Joi.any().equal(true), }) .unknown() .describe(); + expect(generateJoi(mockFieldFilter).describe()).toStrictEqual(mockSchema); }); it(`returns the correct schema for an _contains contain match`, () => { const mockFieldFilter = { field: { _contains: 'field' } } as FieldFilter; + const mockSchema = Joi.object({ field: (Joi.string() as StringSchema).contains('field'), }) .unknown() .describe(); + expect(generateJoi(mockFieldFilter).describe()).toStrictEqual(mockSchema); }); it(`returns the correct schema for an _contains with null value`, () => { const mockFieldFilter = { field: { _contains: null } } as FieldFilter; + const mockSchema = Joi.object({ field: Joi.any().equal(true), }) .unknown() .describe(); + expect(generateJoi(mockFieldFilter).describe()).toStrictEqual(mockSchema); }); it(`returns the correct schema for an _icontains contain match`, () => { const mockFieldFilter = { field: { _icontains: 'field' } } as FieldFilter; + const mockSchema = Joi.object({ field: (Joi.string() as StringSchema).contains('field'), }) .unknown() .describe(); + expect(generateJoi(mockFieldFilter).describe()).toStrictEqual(mockSchema); }); it(`returns the correct schema for an _icontains with null value`, () => { const mockFieldFilter = { field: { _icontains: null } } as FieldFilter; + const mockSchema = Joi.object({ field: Joi.any().equal(true), }) .unknown() .describe(); + expect(generateJoi(mockFieldFilter).describe()).toStrictEqual(mockSchema); }); @@ -275,6 +318,7 @@ describe(`generateJoi`, () => { it(`returns the correct schema for a _starts_with match`, () => { const mockFieldFilter = { field: { _starts_with: 'field' } } as FieldFilter; + const mockSchema = Joi.object({ field: Joi.string().pattern(new RegExp(`^${escapeRegExp('field')}.*`), { name: 'starts_with', @@ -282,21 +326,25 @@ describe(`generateJoi`, () => { }) .unknown() .describe(); + expect(generateJoi(mockFieldFilter).describe()).toStrictEqual(mockSchema); }); it(`returns the correct schema for a _starts_with with null value`, () => { const mockFieldFilter = { field: { _starts_with: null } } as FieldFilter; + const mockSchema = Joi.object({ field: Joi.any().equal(true), }) .unknown() .describe(); + expect(generateJoi(mockFieldFilter).describe()).toStrictEqual(mockSchema); }); it(`returns the correct schema for a _nstarts_with with match`, () => { const mockFieldFilter = { field: { _nstarts_with: 'field' } } as FieldFilter; + const mockSchema = Joi.object({ field: Joi.string().pattern(new RegExp(`^${escapeRegExp('field')}.*`), { name: 'starts_with', @@ -305,21 +353,25 @@ describe(`generateJoi`, () => { }) .unknown() .describe(); + expect(generateJoi(mockFieldFilter).describe()).toStrictEqual(mockSchema); }); it(`returns the correct schema for a _nstarts_with with null value`, () => { const mockFieldFilter = { field: { _nstarts_with: null } } as FieldFilter; + const mockSchema = Joi.object({ field: Joi.any().equal(true), }) .unknown() .describe(); + expect(generateJoi(mockFieldFilter).describe()).toStrictEqual(mockSchema); }); it(`returns the correct schema for an ends_with match`, () => { const mockFieldFilter = { field: { _ends_with: 'field' } } as FieldFilter; + const mockSchema = Joi.object({ field: Joi.string().pattern(new RegExp(`.*field$`), { name: 'ends_with', @@ -327,21 +379,25 @@ describe(`generateJoi`, () => { }) .unknown() .describe(); + expect(generateJoi(mockFieldFilter).describe()).toStrictEqual(mockSchema); }); it(`returns the correct schema for an ends_with with null value`, () => { const mockFieldFilter = { field: { _ends_with: null } } as FieldFilter; + const mockSchema = Joi.object({ field: Joi.any().equal(true), }) .unknown() .describe(); + expect(generateJoi(mockFieldFilter).describe()).toStrictEqual(mockSchema); }); it(`returns the correct schema for a doesnt _nends_with match`, () => { const mockFieldFilter = { field: { _nends_with: 'field' } } as FieldFilter; + const mockSchema = Joi.object({ field: Joi.string().pattern(new RegExp(`.*field$`), { name: 'ends_with', @@ -350,336 +406,403 @@ describe(`generateJoi`, () => { }) .unknown() .describe(); + expect(generateJoi(mockFieldFilter).describe()).toStrictEqual(mockSchema); }); it(`returns the correct schema for a doesnt _nends_with with null value`, () => { const mockFieldFilter = { field: { _nends_with: null } } as FieldFilter; + const mockSchema = Joi.object({ field: Joi.any().equal(true), }) .unknown() .describe(); + expect(generateJoi(mockFieldFilter).describe()).toStrictEqual(mockSchema); }); it(`returns the correct schema for an _in match`, () => { const mockFieldFilter = { field: { _in: 'field' } } as FieldFilter; + const mockSchema = Joi.object({ field: Joi.any().equal(...'field'), }) .unknown() .describe(); + expect(generateJoi(mockFieldFilter).describe()).toStrictEqual(mockSchema); }); it(`returns the correct schema for an _in number array match`, () => { const mockFieldFilter = { field: { _in: [1] } } as FieldFilter; + const mockSchema = Joi.object({ field: Joi.any().equal(...[1]), }) .unknown() .describe(); + expect(generateJoi(mockFieldFilter).describe()).toStrictEqual(mockSchema); }); it(`returns the correct schema for an _in a string array match`, () => { const mockFieldFilter = { field: { _in: ['field', 'secondField'] } } as FieldFilter; + const mockSchema = Joi.object({ field: Joi.any().equal(...['field', 'secondField']), }) .unknown() .describe(); + expect(generateJoi(mockFieldFilter).describe()).toStrictEqual(mockSchema); }); it(`returns the correct schema for a _nin match`, () => { const mockFieldFilter = { field: { _nin: 'field' } } as FieldFilter; + const mockSchema = Joi.object({ field: Joi.any().not(...'field'), }) .unknown() .describe(); + expect(generateJoi(mockFieldFilter).describe()).toStrictEqual(mockSchema); }); it(`returns the correct schema for an _nin number array match`, () => { const mockFieldFilter = { field: { _nin: [1] } } as FieldFilter; + const mockSchema = Joi.object({ field: Joi.any().not(...[1]), }) .unknown() .describe(); + expect(generateJoi(mockFieldFilter).describe()).toStrictEqual(mockSchema); }); it(`returns the correct schema for an _nin a string array match`, () => { const mockFieldFilter = { field: { _nin: ['field', 'secondField'] } } as FieldFilter; + const mockSchema = Joi.object({ field: Joi.any().not(...['field', 'secondField']), }) .unknown() .describe(); + expect(generateJoi(mockFieldFilter).describe()).toStrictEqual(mockSchema); }); it(`returns the correct schema for an _gt number match`, () => { const mockFieldFilter = { field: { _gt: 1 } } as FieldFilter; + const mockSchema = Joi.object({ field: Joi.number().greater(1), }) .unknown() .describe(); + expect(generateJoi(mockFieldFilter).describe()).toStrictEqual(mockSchema); }); it(`returns the correct schema for an _gt date match`, () => { const mockFieldFilter = { field: { _gt: date } } as FieldFilter; + const mockSchema = Joi.object({ field: Joi.date().greater(date), }) .unknown() .describe(); + expect(generateJoi(mockFieldFilter).describe()).toStrictEqual(mockSchema); }); it(`returns the correct schema for an _gt string match`, () => { const mockFieldFilter = { field: { _gt: date.toISOString() } } as FieldFilter; + const mockSchema = Joi.object({ field: Joi.date().greater(date), }) .unknown() .describe(); + expect(generateJoi(mockFieldFilter).describe()).toStrictEqual(mockSchema); }); it(`returns the correct schema for an _gte number match`, () => { const mockFieldFilter = { field: { _gte: 1 } } as FieldFilter; + const mockSchema = Joi.object({ field: Joi.number().min(1), }) .unknown() .describe(); + expect(generateJoi(mockFieldFilter).describe()).toStrictEqual(mockSchema); }); it(`returns the correct schema for an _gte date match`, () => { const mockFieldFilter = { field: { _gte: date } } as FieldFilter; + const mockSchema = Joi.object({ field: Joi.date().min(date), }) .unknown() .describe(); + expect(generateJoi(mockFieldFilter).describe()).toStrictEqual(mockSchema); }); it(`returns the correct schema for an _gte string match`, () => { const mockFieldFilter = { field: { _gte: date.toISOString() } } as FieldFilter; + const mockSchema = Joi.object({ field: Joi.date().min(date), }) .unknown() .describe(); + expect(generateJoi(mockFieldFilter).describe()).toStrictEqual(mockSchema); }); it(`returns the correct schema for an _lt number match`, () => { const mockFieldFilter = { field: { _lt: 1 } } as FieldFilter; + const mockSchema = Joi.object({ field: Joi.number().less(1), }) .unknown() .describe(); + expect(generateJoi(mockFieldFilter).describe()).toStrictEqual(mockSchema); }); it(`returns the correct schema for an _lt date match`, () => { const mockFieldFilter = { field: { _lt: date } } as FieldFilter; + const mockSchema = Joi.object({ field: Joi.date().less(date), }) .unknown() .describe(); + expect(generateJoi(mockFieldFilter).describe()).toStrictEqual(mockSchema); }); it(`returns the correct schema for an _lt string match`, () => { const mockFieldFilter = { field: { _lt: date.toISOString() } } as FieldFilter; + const mockSchema = Joi.object({ field: Joi.date().less(date), }) .unknown() .describe(); + expect(generateJoi(mockFieldFilter).describe()).toStrictEqual(mockSchema); }); it(`returns the correct schema for an _lte number match`, () => { const mockFieldFilter = { field: { _lte: 1 } } as FieldFilter; + const mockSchema = Joi.object({ field: Joi.number().max(1), }) .unknown() .describe(); + expect(generateJoi(mockFieldFilter).describe()).toStrictEqual(mockSchema); }); it(`returns the correct schema for an _lte date match`, () => { const mockFieldFilter = { field: { _lte: date } } as FieldFilter; + const mockSchema = Joi.object({ field: Joi.date().max(date), }) .unknown() .describe(); + expect(generateJoi(mockFieldFilter).describe()).toStrictEqual(mockSchema); }); it(`returns the correct schema for an _lte string match`, () => { const mockFieldFilter = { field: { _lte: date.toISOString() } } as FieldFilter; + const mockSchema = Joi.object({ field: Joi.date().max(date), }) .unknown() .describe(); + expect(generateJoi(mockFieldFilter).describe()).toStrictEqual(mockSchema); }); it(`returns the correct schema for an _null match`, () => { const mockFieldFilter = { field: { _null: null } } as FieldFilter; + const mockSchema = Joi.object({ field: Joi.any().valid(null), }) .unknown() .describe(); + expect(generateJoi(mockFieldFilter).describe()).toStrictEqual(mockSchema); }); it(`returns the correct schema for an _nnull match`, () => { const mockFieldFilter = { field: { _nnull: null } } as FieldFilter; + const mockSchema = Joi.object({ field: Joi.any().invalid(null), }) .unknown() .describe(); + expect(generateJoi(mockFieldFilter).describe()).toStrictEqual(mockSchema); }); it(`returns the correct schema for an _empty match`, () => { const mockFieldFilter = { field: { _empty: '' } } as FieldFilter; + const mockSchema = Joi.object({ field: Joi.any().valid(''), }) .unknown() .describe(); + expect(generateJoi(mockFieldFilter).describe()).toStrictEqual(mockSchema); }); it(`returns the correct schema for an _nempty match`, () => { const mockFieldFilter = { field: { _nempty: '' } } as FieldFilter; + const mockSchema = Joi.object({ field: Joi.any().invalid(''), }) .unknown() .describe(); + expect(generateJoi(mockFieldFilter).describe()).toStrictEqual(mockSchema); }); it(`returns the correct schema for an _between number match`, () => { const mockFieldFilter = { field: { _between: [1, 3] } } as FieldFilter; + const mockSchema = Joi.object({ field: Joi.number().min(1).max(3), }) .unknown() .describe(); + expect(generateJoi(mockFieldFilter).describe()).toStrictEqual(mockSchema); }); it(`returns the correct schema for an _between float match`, () => { const mockFieldFilter = { field: { _between: [1.111, 3.333] } } as FieldFilter; + const mockSchema = Joi.object({ field: Joi.number().min(1.111).max(3.333), }) .unknown() .describe(); + expect(generateJoi(mockFieldFilter).describe()).toStrictEqual(mockSchema); }); it(`returns the correct schema for an _between date match`, () => { const mockFieldFilter = { field: { _between: [date, compareDate] } } as FieldFilter; + const mockSchema = Joi.object({ field: Joi.date().min(date).max(compareDate), }) .unknown() .describe(); + expect(generateJoi(mockFieldFilter).describe()).toStrictEqual(mockSchema); }); it(`returns the correct schema for an _nbetween number match`, () => { const mockFieldFilter = { field: { _nbetween: [1, 3] } } as FieldFilter; + const mockSchema = Joi.object({ field: Joi.number().less(1).greater(3), }) .unknown() .describe(); + expect(generateJoi(mockFieldFilter).describe()).toStrictEqual(mockSchema); }); it(`returns the correct schema for an _nbetween float match`, () => { const mockFieldFilter = { field: { _nbetween: [1.111, 3.333] } } as FieldFilter; + const mockSchema = Joi.object({ field: Joi.number().less(1.111).greater(3.333), }) .unknown() .describe(); + expect(generateJoi(mockFieldFilter).describe()).toStrictEqual(mockSchema); }); it(`returns the correct schema for an _nbetween date match`, () => { const mockFieldFilter = { field: { _nbetween: [date, compareDate] } } as FieldFilter; + const mockSchema = Joi.object({ field: Joi.date().less(date).greater(compareDate), }) .unknown() .describe(); + expect(generateJoi(mockFieldFilter).describe()).toStrictEqual(mockSchema); }); it(`returns the correct schema for an _submitted match`, () => { const mockFieldFilter = { field: { _submitted: '' } } as FieldFilter; + const mockSchema = Joi.object({ field: Joi.any().required(), }) .unknown() .describe(); + expect(generateJoi(mockFieldFilter).describe()).toStrictEqual(mockSchema); }); it(`returns the correct schema for an _regex match when wrapped`, () => { const mockFieldFilter = { field: { _regex: '/.*field$/' } } as FieldFilter; + const mockSchema = Joi.object({ field: Joi.string().regex(new RegExp(`.*field$`)), }) .unknown() .describe(); + expect(generateJoi(mockFieldFilter).describe()).toStrictEqual(mockSchema); }); it(`returns the correct schema for an _regex match when unwrapped`, () => { const mockFieldFilter = { field: { _regex: '.*field$' } } as FieldFilter; + const mockSchema = Joi.object({ field: Joi.string().regex(new RegExp(`.*field$`)), }) .unknown() .describe(); + expect(generateJoi(mockFieldFilter).describe()).toStrictEqual(mockSchema); }); it(`returns the correct schema for an _regex match with null value`, () => { const mockFieldFilter = { field: { _regex: null } } as FieldFilter; + const mockSchema = Joi.object({ field: Joi.any().equal(true), }) .unknown() .describe(); + expect(generateJoi(mockFieldFilter).describe()).toStrictEqual(mockSchema); }); }); diff --git a/packages/utils/shared/generate-joi.ts b/packages/utils/shared/generate-joi.ts index f76c59b038..bf5c6acd02 100644 --- a/packages/utils/shared/generate-joi.ts +++ b/packages/utils/shared/generate-joi.ts @@ -206,6 +206,7 @@ export function generateJoi(filter: FieldFilter | null, options?: JoiOptions): A if (operator === '_gt') { const isDate = compareValue instanceof Date || Number.isNaN(Number(compareValue)); + schema[key] = isDate ? getDateSchema().greater(compareValue as string | Date) : getNumberSchema().greater(Number(compareValue)); @@ -213,6 +214,7 @@ export function generateJoi(filter: FieldFilter | null, options?: JoiOptions): A if (operator === '_gte') { const isDate = compareValue instanceof Date || Number.isNaN(Number(compareValue)); + schema[key] = isDate ? getDateSchema().min(compareValue as string | Date) : getNumberSchema().min(Number(compareValue)); @@ -220,6 +222,7 @@ export function generateJoi(filter: FieldFilter | null, options?: JoiOptions): A if (operator === '_lt') { const isDate = compareValue instanceof Date || Number.isNaN(Number(compareValue)); + schema[key] = isDate ? getDateSchema().less(compareValue as string | Date) : getNumberSchema().less(Number(compareValue)); @@ -227,6 +230,7 @@ export function generateJoi(filter: FieldFilter | null, options?: JoiOptions): A if (operator === '_lte') { const isDate = compareValue instanceof Date || Number.isNaN(Number(compareValue)); + schema[key] = isDate ? getDateSchema().max(compareValue as string | Date) : getNumberSchema().max(Number(compareValue)); @@ -288,6 +292,7 @@ export function generateJoi(filter: FieldFilter | null, options?: JoiOptions): A } else { const wrapped = typeof compareValue === 'string' ? compareValue.startsWith('/') && compareValue.endsWith('/') : false; + schema[key] = getStringSchema().regex(new RegExp(wrapped ? (compareValue as any).slice(1, -1) : compareValue)); } } diff --git a/packages/utils/shared/get-endpoint.test.ts b/packages/utils/shared/get-endpoint.test.ts index 0861b7b4c4..6fca7428d4 100644 --- a/packages/utils/shared/get-endpoint.test.ts +++ b/packages/utils/shared/get-endpoint.test.ts @@ -5,6 +5,7 @@ describe('getEndpoint', () => { it('When a system collection is passed in', () => { expect(getEndpoint('directus_system_collection')).toBe('/system_collection'); }); + it('When a non-system collection is passed in', () => { expect(getEndpoint('user_collection')).toBe('/items/user_collection'); }); diff --git a/packages/utils/shared/get-relation-type.test.ts b/packages/utils/shared/get-relation-type.test.ts index 34feb43744..ea7579c5e0 100644 --- a/packages/utils/shared/get-relation-type.test.ts +++ b/packages/utils/shared/get-relation-type.test.ts @@ -18,6 +18,7 @@ describe('getRelationType', () => { one_collection_field: 'testField', }, } as Relation; + expect(getRelationType({ relation: mockRelation, collection: 'test', field: 'testField' })).toBe('m2a'); }); @@ -28,6 +29,7 @@ describe('getRelationType', () => { field: 'testField', meta: { one_field: 'testField' }, } as Relation; + expect(getRelationType({ relation: mockRelation, collection: 'test2', field: 'testField' })).toBe('o2m'); }); }); diff --git a/packages/utils/shared/is-valid-json.test.ts b/packages/utils/shared/is-valid-json.test.ts index a9106a541c..ebcc79c19b 100644 --- a/packages/utils/shared/is-valid-json.test.ts +++ b/packages/utils/shared/is-valid-json.test.ts @@ -6,6 +6,7 @@ describe('isValidJSON', () => { const result = isValidJSON(`{"name": "Directus"}`); expect(result).toEqual(true); }); + it('returns false if JSON is invalid', () => { const result = isValidJSON(`{"name: Directus"}`); expect(result).toEqual(false); diff --git a/packages/utils/shared/move-in-array.test.ts b/packages/utils/shared/move-in-array.test.ts index 96a0ee88d5..fa2565b11f 100644 --- a/packages/utils/shared/move-in-array.test.ts +++ b/packages/utils/shared/move-in-array.test.ts @@ -15,6 +15,7 @@ describe('moveInArray', () => { it('moves the item to the left to the specified index', () => { expect(moveInArray(testArray, 5, -3)).toStrictEqual([1, 2, 3, 6, 4, 5]); }); + it('returns the original array when passed the same toIndex and fromIndex', () => { expect(moveInArray(testArray, 0, 0)).toStrictEqual(testArray); }); diff --git a/packages/utils/shared/parse-filter.test.ts b/packages/utils/shared/parse-filter.test.ts index bcd31dcf42..413377b3dc 100644 --- a/packages/utils/shared/parse-filter.test.ts +++ b/packages/utils/shared/parse-filter.test.ts @@ -28,6 +28,7 @@ describe('', () => { }, ], } as Filter; + const mockResult = { _and: [ { @@ -37,6 +38,7 @@ describe('', () => { }, ], } as Filter; + const mockAccountability = { role: 'admin' }; expect(parseFilter(mockFilter, mockAccountability)).toStrictEqual(mockResult); }); @@ -51,6 +53,7 @@ describe('', () => { }, ], } as Filter; + const mockResult = { _and: [ { @@ -60,6 +63,7 @@ describe('', () => { }, ], } as Filter; + const mockAccountability = { role: 'admin' }; expect(parseFilter(mockFilter, mockAccountability)).toStrictEqual(mockResult); }); @@ -74,6 +78,7 @@ describe('', () => { }, ], } as Filter; + const mockResult = { _and: [ { @@ -83,6 +88,7 @@ describe('', () => { }, ], } as Filter; + const mockAccountability = { role: 'admin' }; expect(parseFilter(mockFilter, mockAccountability)).toStrictEqual(mockResult); }); @@ -97,6 +103,7 @@ describe('', () => { }, ], } as Filter; + const mockAccountability = { role: 'admin' }; expect(parseFilter(mockFilter, mockAccountability)).toStrictEqual(mockFilter); }); @@ -156,6 +163,7 @@ describe('', () => { }, ], } as Filter; + const mockResult = { _and: [ { @@ -165,6 +173,7 @@ describe('', () => { }, ], } as Filter; + const mockAccountability = { role: 'admin', user: 'user' }; expect(parseFilter(mockFilter, mockAccountability)).toStrictEqual(mockResult); }); @@ -179,6 +188,7 @@ describe('', () => { }, ], } as Filter; + const mockResult = { _and: [ { @@ -188,6 +198,7 @@ describe('', () => { }, ], } as Filter; + const mockAccountability = { role: 'admin' }; expect(parseFilter(mockFilter, mockAccountability)).toStrictEqual(mockResult); }); @@ -198,11 +209,13 @@ describe('', () => { _eq: '$NOW(-1 day)', }, } as Filter; + const mockResult = { date: { _eq: new Date('2021-09-22T21:11:45.992Z'), }, } as Filter; + const mockAccountability = { role: 'admin', user: 'user' }; expect(parseFilter(mockFilter, mockAccountability)).toStrictEqual(mockResult); }); diff --git a/packages/utils/shared/parse-filter.ts b/packages/utils/shared/parse-filter.ts index c57a59ed5f..99f4aced0e 100644 --- a/packages/utils/shared/parse-filter.ts +++ b/packages/utils/shared/parse-filter.ts @@ -43,6 +43,7 @@ function shiftLogicalOperatorsUp(filter: any): any { } else { const childKey = Object.keys(filter[key])[0]; if (!childKey) return filter; + if (logicalFilterOperators.includes(childKey)) { return { [childKey]: toArray(filter[key][childKey]).map((childFilter) => { diff --git a/packages/utils/shared/validate-payload.test.ts b/packages/utils/shared/validate-payload.test.ts index e2c405846e..c5e1b014ca 100644 --- a/packages/utils/shared/validate-payload.test.ts +++ b/packages/utils/shared/validate-payload.test.ts @@ -8,16 +8,19 @@ describe('validatePayload', () => { const mockPayload = { field: 'field' }; expect(validatePayload(mockFilter, mockPayload)).toStrictEqual([]); }); + it('returns an array of 1 when there errors with an _and operator', () => { const mockFilter = { _and: [{ field: { _eq: 'field' } }] } as Filter; const mockPayload = { field: 'test' }; expect(validatePayload(mockFilter, mockPayload)).toHaveLength(1); }); + it('returns an array of 1 when there errors with an _or operator', () => { const mockFilter = { _or: [{ field: { _eq: 'field' } }] } as Filter; const mockPayload = { field: 'test' }; expect(validatePayload(mockFilter, mockPayload)).toHaveLength(1); }); + it('returns an array of 1 when there errors with an _or containing _and operators', () => { const mockFilter = { _or: [ @@ -65,6 +68,7 @@ describe('validatePayload', () => { }) ).toHaveLength(0); }); + it('returns an empty array when there is no error for filter field that does not exist in payload ', () => { const mockFilter = { field: { _eq: 'field' } } as Filter; // intentionally empty payload to simulate "field" was never included in payload @@ -72,6 +76,7 @@ describe('validatePayload', () => { expect(validatePayload(mockFilter, mockPayload)).toHaveLength(0); }); + it('returns an array of 1 when there is required error for filter field that does not exist in payload and requireAll option flag is true', () => { const mockFilter = { field: { _eq: 'field' } } as Filter; // intentionally empty payload to simulate "field" was never included in payload diff --git a/tests/blackbox/common/common.test.ts b/tests/blackbox/common/common.test.ts index 42ae2c164d..7523572668 100644 --- a/tests/blackbox/common/common.test.ts +++ b/tests/blackbox/common/common.test.ts @@ -12,6 +12,7 @@ describe('Common', () => { it.each(vendors)('%s', async (vendor) => { // Setup const roleName = common.ROLE.ADMIN.NAME; + const options: common.OptionsCreateRole = { name: roleName, appAccessEnabled: true, @@ -48,6 +49,7 @@ describe('Common', () => { it.each(vendors)('%s', async (vendor) => { // Setup const roleName = common.ROLE.APP_ACCESS.NAME; + const options: common.OptionsCreateRole = { name: roleName, appAccessEnabled: true, @@ -56,6 +58,7 @@ describe('Common', () => { // Action await common.CreateRole(vendor, options); + const response = await request(getUrl(vendor)) .get(`/roles`) .query({ @@ -83,6 +86,7 @@ describe('Common', () => { it.each(vendors)('%s', async (vendor) => { // Setup const roleName = common.ROLE.API_ONLY.NAME; + const options: common.OptionsCreateRole = { name: roleName, appAccessEnabled: false, @@ -91,6 +95,7 @@ describe('Common', () => { // Action await common.CreateRole(vendor, options); + const response = await request(getUrl(vendor)) .get(`/roles`) .query({ @@ -124,6 +129,7 @@ describe('Common', () => { const password = common.USER.ADMIN.PASSWORD; const name = common.USER.ADMIN.NAME; const roleName = common.ROLE.ADMIN.NAME; + const options: common.OptionsCreateUser = { token, email, @@ -134,6 +140,7 @@ describe('Common', () => { // Action await common.CreateUser(vendor, options); + const response = await request(getUrl(vendor)) .get(`/users`) .query({ @@ -164,6 +171,7 @@ describe('Common', () => { const password = common.USER.APP_ACCESS.PASSWORD; const name = common.USER.APP_ACCESS.NAME; const roleName = common.ROLE.APP_ACCESS.NAME; + const options: common.OptionsCreateUser = { token, email, @@ -174,6 +182,7 @@ describe('Common', () => { // Action await common.CreateUser(vendor, options); + const response = await request(getUrl(vendor)) .get(`/users`) .query({ @@ -204,6 +213,7 @@ describe('Common', () => { const password = common.USER.API_ONLY.PASSWORD; const name = common.USER.API_ONLY.NAME; const roleName = common.ROLE.API_ONLY.NAME; + const options: common.OptionsCreateUser = { token, email, @@ -214,6 +224,7 @@ describe('Common', () => { // Action await common.CreateUser(vendor, options); + const response = await request(getUrl(vendor)) .get(`/users`) .query({ @@ -243,6 +254,7 @@ describe('Common', () => { const email = common.USER.NO_ROLE.EMAIL; const password = common.USER.NO_ROLE.PASSWORD; const name = common.USER.NO_ROLE.NAME; + const options: common.OptionsCreateUser = { token, email, @@ -252,6 +264,7 @@ describe('Common', () => { // Action await common.CreateUser(vendor, options); + const response = await request(getUrl(vendor)) .get(`/users`) .query({ @@ -284,15 +297,18 @@ describe('Common', () => { const options: common.OptionsCreateCollection = { collection: collectionName, }; + const options2: common.OptionsCreateCollection = { collection: collectionNameM2O, }; + const options3: common.OptionsCreateCollection = { collection: collectionNameO2M, }; // Action await common.CreateCollection(vendor, options); + const response = await request(getUrl(vendor)) .get(`/collections/${collectionName}`) .set('Authorization', `Bearer ${common.USER.TESTS_FLOW.TOKEN}`) @@ -300,6 +316,7 @@ describe('Common', () => { .expect(200); await common.CreateCollection(vendor, options2); + const response2 = await request(getUrl(vendor)) .get(`/collections/${collectionNameM2O}`) .set('Authorization', `Bearer ${common.USER.TESTS_FLOW.TOKEN}`) @@ -307,6 +324,7 @@ describe('Common', () => { .expect(200); await common.CreateCollection(vendor, options3); + const response3 = await request(getUrl(vendor)) .get(`/collections/${collectionNameO2M}`) .set('Authorization', `Bearer ${common.USER.TESTS_FLOW.TOKEN}`) @@ -323,6 +341,7 @@ describe('Common', () => { name: collectionName, }), }); + expect(response2.body.data).toEqual({ collection: collectionNameM2O, meta: expect.objectContaining({ @@ -332,6 +351,7 @@ describe('Common', () => { name: collectionNameM2O, }), }); + expect(response3.body.data).toEqual({ collection: collectionNameO2M, meta: expect.objectContaining({ @@ -355,6 +375,7 @@ describe('Common', () => { // Setup const fieldName = 'sample_field'; const fieldType = 'string'; + const options: common.OptionsCreateField = { collection: collectionName, field: fieldName, @@ -363,6 +384,7 @@ describe('Common', () => { // Action await common.CreateField(vendor, options); + const response = await request(getUrl(vendor)) .get(`/fields/${collectionName}/${fieldName}`) .set('Authorization', `Bearer ${common.USER.TESTS_FLOW.TOKEN}`) @@ -391,10 +413,12 @@ describe('Common', () => { // Setup const fieldName = 'm2o_field'; const primaryKeyType = 'integer'; + const collectionOptions: common.OptionsCreateCollection = { collection: collectionNameM2O, primaryKeyType, }; + await common.CreateCollection(vendor, collectionOptions); const options: common.OptionsCreateFieldM2O = { @@ -406,6 +430,7 @@ describe('Common', () => { // Action await common.CreateFieldM2O(vendor, options); + const response = await request(getUrl(vendor)) .get(`/fields/${collectionName}/${fieldName}`) .set('Authorization', `Bearer ${common.USER.TESTS_FLOW.TOKEN}`) @@ -437,10 +462,12 @@ describe('Common', () => { const fieldName = 'o2m_field'; const otherFieldName = 'm2o_field'; const primaryKeyType = 'integer'; + const collectionOptions: common.OptionsCreateCollection = { collection: collectionNameO2M, primaryKeyType, }; + await common.CreateCollection(vendor, collectionOptions); const options: common.OptionsCreateFieldO2M = { @@ -453,6 +480,7 @@ describe('Common', () => { // Action await common.CreateFieldO2M(vendor, options); + const response = await request(getUrl(vendor)) .get(`/fields/${collectionName}/${fieldName}`) .set('Authorization', `Bearer ${common.USER.TESTS_FLOW.TOKEN}`) @@ -488,6 +516,7 @@ describe('Common', () => { // Action const createdItem = await common.CreateItem(vendor, options); + const response = await request(getUrl(vendor)) .get(`/items/${collectionName}/${createdItem.id}`) .set('Authorization', `Bearer ${common.USER.TESTS_FLOW.TOKEN}`) @@ -521,6 +550,7 @@ describe('Common', () => { // Action const createdItem = await common.CreateItem(vendor, options); + const response = await request(getUrl(vendor)) .get(`/items/${collectionName}/${createdItem.id}`) .set('Authorization', `Bearer ${common.USER.TESTS_FLOW.TOKEN}`) @@ -559,6 +589,7 @@ describe('Common', () => { // Action const createdItem = await common.CreateItem(vendor, options); + const response = await request(getUrl(vendor)) .get(`/items/${collectionName}/${createdItem.id}`) .set('Authorization', `Bearer ${common.USER.TESTS_FLOW.TOKEN}`) @@ -586,6 +617,7 @@ describe('Common', () => { async (vendor) => { // Setup const fieldName = 'o2m_field'; + const options: common.OptionsDeleteField = { collection: collectionName, field: fieldName, @@ -593,6 +625,7 @@ describe('Common', () => { // Action await common.DeleteField(vendor, options); + const response = await request(getUrl(vendor)) .get(`/fields/${collectionName}`) .set('Authorization', `Bearer ${common.USER.TESTS_FLOW.TOKEN}`) @@ -614,6 +647,7 @@ describe('Common', () => { async (vendor) => { // Setup const fieldName = 'm2o_field'; + const options: common.OptionsDeleteField = { collection: collectionNameO2M, field: fieldName, @@ -621,6 +655,7 @@ describe('Common', () => { // Action await common.DeleteField(vendor, options); + const response = await request(getUrl(vendor)) .get(`/fields/${collectionNameO2M}`) .set('Authorization', `Bearer ${common.USER.TESTS_FLOW.TOKEN}`) @@ -642,6 +677,7 @@ describe('Common', () => { async (vendor) => { // Setup const fieldName = 'm2o_field'; + const options: common.OptionsDeleteField = { collection: collectionName, field: fieldName, @@ -649,6 +685,7 @@ describe('Common', () => { // Action await common.DeleteField(vendor, options); + const response = await request(getUrl(vendor)) .get(`/fields/${collectionName}`) .set('Authorization', `Bearer ${common.USER.TESTS_FLOW.TOKEN}`) @@ -670,6 +707,7 @@ describe('Common', () => { async (vendor) => { // Setup const fieldName = 'sample_field'; + const options: common.OptionsDeleteField = { collection: collectionName, field: fieldName, @@ -677,6 +715,7 @@ describe('Common', () => { // Action await common.DeleteField(vendor, options); + const response = await request(getUrl(vendor)) .get(`/fields/${collectionName}`) .set('Authorization', `Bearer ${common.USER.TESTS_FLOW.TOKEN}`) @@ -705,6 +744,7 @@ describe('Common', () => { // Action await common.DeleteCollection(vendor, options); + const response = await request(getUrl(vendor)) .get(`/collections`) .set('Authorization', `Bearer ${common.USER.TESTS_FLOW.TOKEN}`) diff --git a/tests/blackbox/common/config.ts b/tests/blackbox/common/config.ts index 54036d689e..583b2fdfb9 100644 --- a/tests/blackbox/common/config.ts +++ b/tests/blackbox/common/config.ts @@ -31,6 +31,7 @@ const knexConfig = { }; const allowedLogLevels = ['trace', 'debug', 'info', 'warn', 'error', 'fatal']; + const logLevel = process.env.TEST_SAVE_LOGS ? allowedLogLevels.includes(process.env.TEST_SAVE_LOGS) ? process.env.TEST_SAVE_LOGS diff --git a/tests/blackbox/common/functions.ts b/tests/blackbox/common/functions.ts index 6fc339cd04..61bcdd3cd8 100644 --- a/tests/blackbox/common/functions.ts +++ b/tests/blackbox/common/functions.ts @@ -151,6 +151,7 @@ export async function CreateCollection(vendor: string, options: Partial { // Assert expect(response.statusCode).toBe(200); + if (mode === 'cookie') { expect(response.body).toMatchObject({ data: { @@ -134,6 +135,7 @@ describe('Logger Redact Tests', () => { } expect(gqlResponse.statusCode).toBe(200); + expect(gqlResponse.body).toMatchObject({ data: { [mutationKey]: { @@ -217,6 +219,7 @@ describe('Logger Redact Tests', () => { // Assert expect(response.statusCode).toBe(200); + if (mode === 'cookie') { expect(response.body).toMatchObject({ data: { @@ -245,6 +248,7 @@ describe('Logger Redact Tests', () => { } expect(gqlResponse.statusCode).toBe(200); + expect(gqlResponse.body).toMatchObject({ data: { [mutationKey]: { diff --git a/tests/blackbox/query/filter/index.ts b/tests/blackbox/query/filter/index.ts index 421b647b75..af138eb813 100644 --- a/tests/blackbox/query/filter/index.ts +++ b/tests/blackbox/query/filter/index.ts @@ -55,6 +55,7 @@ const processSchemaFields = ( parentField?: string ) => { let filterOperatorList: ClientFilterOperator[] = []; + let targetSchema: { filterOperatorList: any; generateFilterForDataType: any; @@ -145,6 +146,7 @@ const processSchemaFields = ( const schemaValues = get(vendorSchemaValues, `${vendor}.${collection}.${filterKey}`); const possibleValues = Array.isArray(schemaValues) ? schemaValues : schema.possibleValues; + const generatedFilters = targetSchema.generateFilterForDataType( filterOperator, possibleValues diff --git a/tests/blackbox/routes/assets/concurrency.test.ts b/tests/blackbox/routes/assets/concurrency.test.ts index b968f966ff..a76ca75485 100644 --- a/tests/blackbox/routes/assets/concurrency.test.ts +++ b/tests/blackbox/routes/assets/concurrency.test.ts @@ -23,6 +23,7 @@ describe('/assets', () => { let spawnCount = 0; let hasErrors = false; let isSpawnRunning = false; + const insertResponse = await request(getUrl(vendor)) .post('/files') .set('Authorization', `Bearer ${common.USER.ADMIN.TOKEN}`) @@ -36,6 +37,7 @@ describe('/assets', () => { const url = `${getUrl(vendor)}/assets/${insertResponse.body.data.id}?access_token=${ common.USER.ADMIN.TOKEN }`; + const options = ['exec', 'autocannon', '-c', '100', url]; const child = spawn('pnpm', options); diff --git a/tests/blackbox/routes/assets/limit.test.ts b/tests/blackbox/routes/assets/limit.test.ts index 1c7a0a7d56..7bebd63dd7 100644 --- a/tests/blackbox/routes/assets/limit.test.ts +++ b/tests/blackbox/routes/assets/limit.test.ts @@ -7,11 +7,13 @@ import * as common from '@common/index'; const assetsDirectory = [__dirname, '..', '..', 'assets']; const storages = ['local', 'minio']; + const imageFile = { name: 'directus.png', type: 'image/png', filesize: '7136', }; + const imageFilePath = path.join(...assetsDirectory, imageFile.name); describe('/assets', () => { @@ -24,6 +26,7 @@ describe('/assets', () => { async (vendor) => { // Setup const count = Number(config.envs[vendor].ASSETS_TRANSFORM_MAX_CONCURRENT); + const uploadedFileID = ( await request(getUrl(vendor)) .post('/files') @@ -60,6 +63,7 @@ describe('/assets', () => { async (vendor) => { // Setup const attempts = 100; + const uploadedFileID = ( await request(getUrl(vendor)) .post('/files') @@ -82,6 +86,7 @@ describe('/assets', () => { // Assert const unavailableCount = responses.filter((response) => response.statusCode === 503).length; expect(unavailableCount).toBeGreaterThanOrEqual(1); + expect(responses.filter((response) => response.statusCode === 200).length).toBe( attempts - unavailableCount ); diff --git a/tests/blackbox/routes/assets/read.test.ts b/tests/blackbox/routes/assets/read.test.ts index ad66e2ab71..14d6d3b465 100644 --- a/tests/blackbox/routes/assets/read.test.ts +++ b/tests/blackbox/routes/assets/read.test.ts @@ -7,11 +7,13 @@ import * as common from '@common/index'; const assetsDirectory = [__dirname, '..', '..', 'assets']; const storages = ['local', 'minio']; + const imageFile = { name: 'directus.png', type: 'image/png', filesize: '7136', }; + const imageFilePath = path.join(...assetsDirectory, imageFile.name); describe('/assets', () => { diff --git a/tests/blackbox/routes/auth/login.test.ts b/tests/blackbox/routes/auth/login.test.ts index 291d97c4c5..171aa4fe32 100644 --- a/tests/blackbox/routes/auth/login.test.ts +++ b/tests/blackbox/routes/auth/login.test.ts @@ -35,6 +35,7 @@ describe('/auth', () => { // Assert expect(response.statusCode).toBe(200); + expect(response.body).toMatchObject({ data: { access_token: expect.any(String), @@ -44,6 +45,7 @@ describe('/auth', () => { }); expect(gqlResponse.statusCode).toBe(200); + expect(gqlResponse.body).toMatchObject({ data: { [mutationKey]: { @@ -58,6 +60,7 @@ describe('/auth', () => { }); }); }); + describe('when incorrect credentials are provided', () => { describe('returns code: UNAUTHORIZED for incorrect password', () => { common.TEST_USERS.forEach((userKey) => { @@ -115,6 +118,7 @@ describe('/auth', () => { }); }); }); + describe('returns code: UNAUTHORIZED for unregistered email', () => { common.TEST_USERS.forEach((userKey) => { describe(common.USER[userKey].NAME, () => { @@ -171,6 +175,7 @@ describe('/auth', () => { }); }); }); + describe('returns code: INVALID_CREDENTIALS for invalid email', () => { common.TEST_USERS.forEach((userKey) => { describe(common.USER[userKey].NAME, () => { @@ -227,6 +232,7 @@ describe('/auth', () => { }); }); }); + describe('returns message: "password is required" when no password is provided', () => { common.TEST_USERS.forEach((userKey) => { describe(common.USER[userKey].NAME, () => { diff --git a/tests/blackbox/routes/auth/refresh.test.ts b/tests/blackbox/routes/auth/refresh.test.ts index 74c7095193..1d2359e680 100644 --- a/tests/blackbox/routes/auth/refresh.test.ts +++ b/tests/blackbox/routes/auth/refresh.test.ts @@ -60,6 +60,7 @@ describe('Authentication Refresh Tests', () => { // Assert expect(response.statusCode).toBe(200); + if (mode === 'cookie') { expect(response.body).toMatchObject({ data: { @@ -78,6 +79,7 @@ describe('Authentication Refresh Tests', () => { } expect(gqlResponse.statusCode).toBe(200); + expect(gqlResponse.body).toMatchObject({ data: { [mutationKey]: { @@ -153,6 +155,7 @@ describe('Authentication Refresh Tests', () => { // Assert expect(response.statusCode).toBe(200); + if (mode === 'cookie') { expect(response.body).toMatchObject({ data: { @@ -171,6 +174,7 @@ describe('Authentication Refresh Tests', () => { } expect(gqlResponse.statusCode).toBe(200); + expect(gqlResponse.body).toMatchObject({ data: { [mutationKey]: { diff --git a/tests/blackbox/routes/auth/saml.test.ts b/tests/blackbox/routes/auth/saml.test.ts index e8b5ba9d56..ff39f6cf5f 100644 --- a/tests/blackbox/routes/auth/saml.test.ts +++ b/tests/blackbox/routes/auth/saml.test.ts @@ -115,6 +115,7 @@ describe('/auth/login/saml', () => { const samlLogin = await request(getUrl(vendor)) .get(`/auth/login/saml?redirect=${getUrl(vendor)}/admin/login?continue`) .expect(302); + const samlRedirectUrl = String(samlLogin.headers.location).split('/simplesaml/'); const authResponse = await request(samlRedirectUrl[0]) diff --git a/tests/blackbox/routes/collections/crud.test.ts b/tests/blackbox/routes/collections/crud.test.ts index 2e946e6025..cc84716592 100644 --- a/tests/blackbox/routes/collections/crud.test.ts +++ b/tests/blackbox/routes/collections/crud.test.ts @@ -50,12 +50,14 @@ describe.each(common.PRIMARY_KEY_TYPES)('/collections', (pkType) => { if (userKey === common.USER.ADMIN.KEY) { const responseData = JSON.parse(response.text); const tableNames = responseData.data.map((collection: Collection) => collection.collection).sort(); + const tableNames2 = gqlResponse.body.data['collections'] .map((collection: Collection) => collection.collection) .sort(); expect(response.statusCode).toBe(200); expect(responseData.data.length).toBeGreaterThanOrEqual(common.DEFAULT_DB_TABLES.length); + expect( common.DEFAULT_DB_TABLES.every((name: string) => { return tableNames.indexOf(name) !== -1; @@ -63,9 +65,11 @@ describe.each(common.PRIMARY_KEY_TYPES)('/collections', (pkType) => { ).toEqual(true); expect(gqlResponse.statusCode).toBe(200); + expect(gqlResponse.body.data['collections'].length).toBeGreaterThanOrEqual( common.DEFAULT_DB_TABLES.length ); + expect( common.DEFAULT_DB_TABLES.every((name: string) => { return tableNames2.indexOf(name) !== -1; @@ -74,9 +78,11 @@ describe.each(common.PRIMARY_KEY_TYPES)('/collections', (pkType) => { } else if (userKey === common.USER.APP_ACCESS.KEY) { const responseData = JSON.parse(response.text); const tableNames = responseData.data.map((collection: Collection) => collection.collection).sort(); + const tableNames2 = gqlResponse.body.data['collections'] .map((collection: Collection) => collection.collection) .sort(); + const appAccessPermissions = [ 'directus_activity', 'directus_collections', @@ -93,6 +99,7 @@ describe.each(common.PRIMARY_KEY_TYPES)('/collections', (pkType) => { expect(response.statusCode).toBe(200); expect(responseData.data.length).toBeGreaterThanOrEqual(appAccessPermissions.length); + expect( appAccessPermissions.every((name: string) => { return tableNames.indexOf(name) !== -1; @@ -101,6 +108,7 @@ describe.each(common.PRIMARY_KEY_TYPES)('/collections', (pkType) => { expect(gqlResponse.statusCode).toBe(200); expect(gqlResponse.body.data['collections'].length).toBeGreaterThanOrEqual(appAccessPermissions.length); + expect( appAccessPermissions.every((name: string) => { return tableNames2.indexOf(name) !== -1; @@ -141,6 +149,7 @@ describe.each(common.PRIMARY_KEY_TYPES)('/collections', (pkType) => { meta: { hidden: true, readonly: true, interface: 'input', special: ['uuid'] }, schema: { is_primary_key: true, length: 36, has_auto_increment: false }, }); + break; case 'string': fields.push({ @@ -149,6 +158,7 @@ describe.each(common.PRIMARY_KEY_TYPES)('/collections', (pkType) => { meta: { hidden: false, readonly: false, interface: 'input' }, schema: { is_primary_key: true, length: 255, has_auto_increment: false }, }); + break; case 'integer': fields.push({ @@ -157,6 +167,7 @@ describe.each(common.PRIMARY_KEY_TYPES)('/collections', (pkType) => { meta: { hidden: true, interface: 'input', readonly: true }, schema: { is_primary_key: true, has_auto_increment: true }, }); + break; } @@ -169,6 +180,7 @@ describe.each(common.PRIMARY_KEY_TYPES)('/collections', (pkType) => { // Assert if (userKey === common.USER.ADMIN.KEY) { expect(response.statusCode).toBe(200); + expect(response.body.data).toEqual({ collection: TEST_COLLECTION_NAME, meta: expect.objectContaining({ @@ -178,6 +190,7 @@ describe.each(common.PRIMARY_KEY_TYPES)('/collections', (pkType) => { name: TEST_COLLECTION_NAME, }), }); + expect(await db.schema.hasTable(TEST_COLLECTION_NAME)).toBe(true); } else { expect(response.statusCode).toBe(403); @@ -204,6 +217,7 @@ describe.each(common.PRIMARY_KEY_TYPES)('/collections', (pkType) => { // Assert if (userKey === common.USER.ADMIN.KEY) { expect(response.statusCode).toBe(200); + expect(response.body.data).toEqual({ collection: TEST_FOLDER_NAME, meta: expect.objectContaining({ @@ -211,6 +225,7 @@ describe.each(common.PRIMARY_KEY_TYPES)('/collections', (pkType) => { }), schema: null, }); + expect(await db.schema.hasTable(TEST_FOLDER_NAME)).toBe(false); } else { expect(response.statusCode).toBe(403); @@ -223,11 +238,13 @@ describe.each(common.PRIMARY_KEY_TYPES)('/collections', (pkType) => { describe('PATCH /', () => { let currentVendor = vendors[0]; + const collectionNames = [ `test_collections_crud_batch_update_${pkType}`, `test_collections_crud_batch_update2_${pkType}`, `test_collections_crud_batch_update3_${pkType}`, ]; + const newSortOrder = [3, 1, 2]; afterEach(async () => { @@ -272,8 +289,10 @@ describe.each(common.PRIMARY_KEY_TYPES)('/collections', (pkType) => { // Assert if (userKey === common.USER.ADMIN.KEY) { expect(response.statusCode).toBe(200); + for (let i = 0; i < collectionNames.length; i++) { const matchedIndex = findIndex(response.body.data, { collection: collectionNames[i] }); + expect(response.body.data[matchedIndex]).toEqual({ collection: collectionNames[i], meta: expect.objectContaining({ @@ -285,6 +304,7 @@ describe.each(common.PRIMARY_KEY_TYPES)('/collections', (pkType) => { name: collectionNames[i], }), }); + expect(await db.schema.hasTable(collectionNames[i])).toBe(true); } } else { @@ -368,6 +388,7 @@ describe.each(common.PRIMARY_KEY_TYPES)('/collections', (pkType) => { if (userKey === common.USER.ADMIN.KEY) { expect(response.statusCode).toBe(204); expect(response.body).toEqual({}); + expect(await db('directus_collections').select().where({ collection: TEST_FOLDER_NAME })).toHaveLength( 0 ); @@ -406,6 +427,7 @@ describe.each(common.PRIMARY_KEY_TYPES)('/collections', (pkType) => { // Assert expect(response.statusCode).toBe(200); expect(response.body.data.length).toBe(10); + for (const log of response.body.data) { expect(log.value).toBe('1'); } diff --git a/tests/blackbox/routes/collections/files.test.ts b/tests/blackbox/routes/collections/files.test.ts index 43bf5476a0..496c1d8a09 100644 --- a/tests/blackbox/routes/collections/files.test.ts +++ b/tests/blackbox/routes/collections/files.test.ts @@ -24,6 +24,7 @@ describe('/files', () => { // Assert expect(response.statusCode).toBe(200); + expect(response.body).toMatchObject({ data: { title: payload.title, @@ -33,6 +34,7 @@ describe('/files', () => { }); }); }); + describe('returns code: FAILED_VALIDATION when required property "storage" is not included', () => { it.each(vendors)('%s', async (vendor) => { // Setup @@ -46,6 +48,7 @@ describe('/files', () => { // Assert expect(response.statusCode).toBe(400); + expect(response.body).toMatchObject({ errors: [ { diff --git a/tests/blackbox/routes/collections/schema-cache.test.ts b/tests/blackbox/routes/collections/schema-cache.test.ts index 7cd92836f8..2b7a228c98 100644 --- a/tests/blackbox/routes/collections/schema-cache.test.ts +++ b/tests/blackbox/routes/collections/schema-cache.test.ts @@ -92,17 +92,20 @@ describe('Schema Caching Tests', () => { await request(getUrl(vendor, env1)) .post(`/utils/cache/clear`) .set('Authorization', `Bearer ${common.USER.ADMIN.TOKEN}`); + await request(getUrl(vendor, env1)).get(`/fields`).set('Authorization', `Bearer ${common.USER.ADMIN.TOKEN}`); await request(getUrl(vendor, env2)) .post(`/utils/cache/clear`) .set('Authorization', `Bearer ${common.USER.ADMIN.TOKEN}`); + await request(getUrl(vendor, env2)).get(`/fields`).set('Authorization', `Bearer ${common.USER.ADMIN.TOKEN}`); // Action const responseBefore = await request(getUrl(vendor, env1)) .get(`/collections/${newCollectionName}`) .set('Authorization', `Bearer ${common.USER.ADMIN.TOKEN}`); + const responseBefore2 = await request(getUrl(vendor, env2)) .get(`/collections/${newCollectionName}`) .set('Authorization', `Bearer ${common.USER.ADMIN.TOKEN}`); @@ -114,6 +117,7 @@ describe('Schema Caching Tests', () => { const responseAfter = await request(getUrl(vendor, env1)) .get(`/collections/${newCollectionName}`) .set('Authorization', `Bearer ${common.USER.ADMIN.TOKEN}`); + const responseAfter2 = await request(getUrl(vendor, env2)) .get(`/collections/${newCollectionName}`) .set('Authorization', `Bearer ${common.USER.ADMIN.TOKEN}`); @@ -137,17 +141,20 @@ describe('Schema Caching Tests', () => { await request(getUrl(vendor, env3)) .post(`/utils/cache/clear`) .set('Authorization', `Bearer ${common.USER.ADMIN.TOKEN}`); + await request(getUrl(vendor, env3)).get(`/fields`).set('Authorization', `Bearer ${common.USER.ADMIN.TOKEN}`); await request(getUrl(vendor, env4)) .post(`/utils/cache/clear`) .set('Authorization', `Bearer ${common.USER.ADMIN.TOKEN}`); + await request(getUrl(vendor, env4)).get(`/fields`).set('Authorization', `Bearer ${common.USER.ADMIN.TOKEN}`); // Action const responseBefore = await request(getUrl(vendor, env3)) .get(`/collections/${newCollectionName}`) .set('Authorization', `Bearer ${common.USER.ADMIN.TOKEN}`); + const responseBefore2 = await request(getUrl(vendor, env4)) .get(`/collections/${newCollectionName}`) .set('Authorization', `Bearer ${common.USER.ADMIN.TOKEN}`); @@ -159,6 +166,7 @@ describe('Schema Caching Tests', () => { const responseAfter = await request(getUrl(vendor, env3)) .get(`/collections/${newCollectionName}`) .set('Authorization', `Bearer ${common.USER.ADMIN.TOKEN}`); + const responseAfter2 = await request(getUrl(vendor, env4)) .get(`/collections/${newCollectionName}`) .set('Authorization', `Bearer ${common.USER.ADMIN.TOKEN}`); diff --git a/tests/blackbox/routes/fields/change-fields.test.ts b/tests/blackbox/routes/fields/change-fields.test.ts index ceefb87637..b046aadcdd 100644 --- a/tests/blackbox/routes/fields/change-fields.test.ts +++ b/tests/blackbox/routes/fields/change-fields.test.ts @@ -75,6 +75,7 @@ describe.each(common.PRIMARY_KEY_TYPES)('/fields', (pkType) => { it.each(vendors)('%s', async (vendor) => { // Setup const fieldName = 'flag_image'; + const response = await request(getUrl(vendor)) .get(`/items/${localCollectionStates}`) .set('Authorization', `Bearer ${common.USER.ADMIN.TOKEN}`); @@ -145,6 +146,7 @@ describe.each(common.PRIMARY_KEY_TYPES)('/fields', (pkType) => { // Assert expect(response.statusCode).toEqual(200); expect(response2.statusCode).toEqual(200); + expect(response2.body.data).toEqual( expect.objectContaining({ field: fieldName, @@ -176,6 +178,7 @@ describe.each(common.PRIMARY_KEY_TYPES)('/fields', (pkType) => { // Assert expect(response.statusCode).toEqual(200); expect(response2.statusCode).toEqual(200); + expect(response2.body.data).toEqual( expect.objectContaining({ field: fieldName, @@ -218,6 +221,7 @@ describe.each(common.PRIMARY_KEY_TYPES)('/fields', (pkType) => { // Assert expect(response.statusCode).toEqual(200); expect(response2.statusCode).toEqual(200); + expect(response2.body.data).toEqual( expect.objectContaining({ field: fieldName, diff --git a/tests/blackbox/routes/fields/crud.test.ts b/tests/blackbox/routes/fields/crud.test.ts index 21f6be0529..c62fec4853 100644 --- a/tests/blackbox/routes/fields/crud.test.ts +++ b/tests/blackbox/routes/fields/crud.test.ts @@ -54,12 +54,14 @@ describe.each(common.PRIMARY_KEY_TYPES)('/fields', (pkType) => { if (userKey === common.USER.ADMIN.KEY) { const responseData = JSON.parse(response.text); const tableNames = sortedUniq(responseData.data.map((field: FieldRaw) => field.collection)); + const tableNames2 = sortedUniq( gqlResponse.body.data['fields'].map((field: FieldRaw) => field.collection) ); expect(response.statusCode).toBe(200); expect(tableNames.length).toBeGreaterThanOrEqual(common.DEFAULT_DB_TABLES.length); + expect( common.DEFAULT_DB_TABLES.every((name: string) => { return tableNames.indexOf(name) !== -1; @@ -68,6 +70,7 @@ describe.each(common.PRIMARY_KEY_TYPES)('/fields', (pkType) => { expect(gqlResponse.statusCode).toBe(200); expect(tableNames2.length).toBeGreaterThanOrEqual(common.DEFAULT_DB_TABLES.length); + expect( common.DEFAULT_DB_TABLES.every((name: string) => { return tableNames2.indexOf(name) !== -1; @@ -76,9 +79,11 @@ describe.each(common.PRIMARY_KEY_TYPES)('/fields', (pkType) => { } else if (userKey === common.USER.APP_ACCESS.KEY) { const responseData = JSON.parse(response.text); const tableNames = sortedUniq(responseData.data.map((field: FieldRaw) => field.collection)); + const tableNames2 = sortedUniq( gqlResponse.body.data['fields'].map((field: FieldRaw) => field.collection) ); + const appAccessPermissions = [ 'directus_activity', 'directus_collections', @@ -95,6 +100,7 @@ describe.each(common.PRIMARY_KEY_TYPES)('/fields', (pkType) => { expect(response.statusCode).toBe(200); expect(tableNames.length).toBeGreaterThanOrEqual(appAccessPermissions.length); + expect( appAccessPermissions.every((name: string) => { return tableNames.indexOf(name) !== -1; @@ -103,6 +109,7 @@ describe.each(common.PRIMARY_KEY_TYPES)('/fields', (pkType) => { expect(gqlResponse.statusCode).toBe(200); expect(tableNames2.length).toBeGreaterThanOrEqual(appAccessPermissions.length); + expect( appAccessPermissions.every((name: string) => { return tableNames2.indexOf(name) !== -1; @@ -153,6 +160,7 @@ describe.each(common.PRIMARY_KEY_TYPES)('/fields', (pkType) => { // Assert if (userKey === common.USER.ADMIN.KEY) { expect(response.statusCode).toBe(200); + expect(response.body.data).toEqual({ collection: TEST_COLLECTION_NAME, field: TEST_FIELD_NAME, @@ -166,6 +174,7 @@ describe.each(common.PRIMARY_KEY_TYPES)('/fields', (pkType) => { }), type: 'string', }); + expect(await db.schema.hasColumn(TEST_COLLECTION_NAME, TEST_FIELD_NAME)).toBe(true); } else { expect(response.statusCode).toBe(403); @@ -197,6 +206,7 @@ describe.each(common.PRIMARY_KEY_TYPES)('/fields', (pkType) => { // Assert if (userKey === common.USER.ADMIN.KEY) { expect(response.statusCode).toBe(200); + expect(response.body.data).toEqual({ collection: TEST_COLLECTION_NAME, field: TEST_ALIAS_FIELD_NAME, @@ -209,6 +219,7 @@ describe.each(common.PRIMARY_KEY_TYPES)('/fields', (pkType) => { schema: null, type: 'alias', }); + expect(await db.schema.hasColumn(TEST_COLLECTION_NAME, TEST_ALIAS_FIELD_NAME)).toBe(false); } else { expect(response.statusCode).toBe(403); @@ -234,9 +245,11 @@ describe.each(common.PRIMARY_KEY_TYPES)('/fields', (pkType) => { afterEach(async () => { const db = databases.get(currentVendor)!; + await db('directus_fields') .update({ note: null }) .where({ collection: TEST_COLLECTION_NAME, field: TEST_FIELD_NAME }); + await db('directus_fields') .update({ note: null }) .where({ collection: TEST_COLLECTION_NAME, field: TEST_ALIAS_FIELD_NAME }); @@ -258,6 +271,7 @@ describe.each(common.PRIMARY_KEY_TYPES)('/fields', (pkType) => { // Assert if (userKey === common.USER.ADMIN.KEY) { expect(response.statusCode).toBe(200); + expect(response.body.data).toEqual({ collection: TEST_COLLECTION_NAME, field: TEST_FIELD_NAME, @@ -300,6 +314,7 @@ describe.each(common.PRIMARY_KEY_TYPES)('/fields', (pkType) => { // Assert if (userKey === common.USER.ADMIN.KEY) { expect(response.statusCode).toBe(200); + expect(response.body.data).toEqual({ collection: TEST_COLLECTION_NAME, field: TEST_ALIAS_FIELD_NAME, @@ -325,9 +340,11 @@ describe.each(common.PRIMARY_KEY_TYPES)('/fields', (pkType) => { afterEach(async () => { const db = databases.get(currentVendor)!; + await db('directus_fields') .update({ note: null }) .where({ collection: TEST_COLLECTION_NAME, field: TEST_FIELD_NAME }); + await db('directus_fields') .update({ note: null }) .where({ collection: TEST_COLLECTION_NAME, field: TEST_ALIAS_FIELD_NAME }); @@ -356,6 +373,7 @@ describe.each(common.PRIMARY_KEY_TYPES)('/fields', (pkType) => { // Assert if (userKey === common.USER.ADMIN.KEY) { expect(response.statusCode).toBe(200); + expect(response.body.data).toEqual([ { collection: TEST_COLLECTION_NAME, @@ -487,6 +505,7 @@ describe.each(common.PRIMARY_KEY_TYPES)('/fields', (pkType) => { // Assert expect(response.statusCode).toBe(200); expect(response.body.data.length).toBe(4); + for (const log of response.body.data) { expect(log.value).toBe('1'); } diff --git a/tests/blackbox/routes/files/storage.test.ts b/tests/blackbox/routes/files/storage.test.ts index ca25c2416c..ac5a837ebe 100644 --- a/tests/blackbox/routes/files/storage.test.ts +++ b/tests/blackbox/routes/files/storage.test.ts @@ -7,6 +7,7 @@ import * as common from '@common/index'; const assetsDirectory = [__dirname, '..', '..', 'assets']; const storages = ['local', 'minio']; + const imageFile = { name: 'directus.png', type: 'image/png', @@ -14,6 +15,7 @@ const imageFile = { title: 'Directus', description: 'The Directus Logo', }; + const imageFilePath = path.join(...assetsDirectory, imageFile.name); describe('/files', () => { @@ -34,6 +36,7 @@ describe('/files', () => { // Assert expect(response.statusCode).toBe(200); + expect(response.body.data).toEqual( expect.objectContaining({ filesize: imageFile.filesize, diff --git a/tests/blackbox/routes/items/conceal-filter.seed.ts b/tests/blackbox/routes/items/conceal-filter.seed.ts index 7da673b61e..f053fa9bd8 100644 --- a/tests/blackbox/routes/items/conceal-filter.seed.ts +++ b/tests/blackbox/routes/items/conceal-filter.seed.ts @@ -86,6 +86,7 @@ export const seedDBStructure = () => { export const seedDBValues = async () => { let isSeeded = true; + await Promise.all( vendors.map(async (vendor) => { for (const pkType of PRIMARY_KEY_TYPES) { diff --git a/tests/blackbox/routes/items/conceal-filter.test.ts b/tests/blackbox/routes/items/conceal-filter.test.ts index 84792b1d07..a00c853136 100644 --- a/tests/blackbox/routes/items/conceal-filter.test.ts +++ b/tests/blackbox/routes/items/conceal-filter.test.ts @@ -26,6 +26,7 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { const response = await request(getUrl(vendor)) .get(`/items/${localCollectionFirst}`) .set('Authorization', `Bearer ${common.USER.ADMIN.TOKEN}`); + const response2 = await request(getUrl(vendor)) .get(`/items/${localCollectionSecond}`) .set('Authorization', `Bearer ${common.USER.ADMIN.TOKEN}`); @@ -47,18 +48,21 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { filter: JSON.stringify({ string_field: { _null: true } }), }) .set('Authorization', `Bearer ${common.USER.ADMIN.TOKEN}`); + const response2 = await request(getUrl(vendor)) .get(`/items/${localCollectionFirst}`) .query({ filter: JSON.stringify({ string_field: { _nnull: true } }), }) .set('Authorization', `Bearer ${common.USER.ADMIN.TOKEN}`); + const response3 = await request(getUrl(vendor)) .get(`/items/${localCollectionSecond}`) .query({ filter: JSON.stringify({ string_field: { _null: true } }), }) .set('Authorization', `Bearer ${common.USER.ADMIN.TOKEN}`); + const response4 = await request(getUrl(vendor)) .get(`/items/${localCollectionSecond}`) .query({ @@ -87,18 +91,21 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { filter: JSON.stringify({ string_field: { _contains: 'a' } }), }) .set('Authorization', `Bearer ${common.USER.ADMIN.TOKEN}`); + const response2 = await request(getUrl(vendor)) .get(`/items/${localCollectionFirst}`) .query({ filter: JSON.stringify({ string_field: { _eq: 'b' } }), }) .set('Authorization', `Bearer ${common.USER.ADMIN.TOKEN}`); + const response3 = await request(getUrl(vendor)) .get(`/items/${localCollectionSecond}`) .query({ filter: JSON.stringify({ string_field: { _starts_with: 'c' } }), }) .set('Authorization', `Bearer ${common.USER.ADMIN.TOKEN}`); + const response4 = await request(getUrl(vendor)) .get(`/items/${localCollectionSecond}`) .query({ @@ -125,6 +132,7 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { }), }) .set('Authorization', `Bearer ${common.USER.ADMIN.TOKEN}`); + const response2 = await request(getUrl(vendor)) .get(`/items/${localCollectionFirst}`) .query({ @@ -133,6 +141,7 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { }), }) .set('Authorization', `Bearer ${common.USER.ADMIN.TOKEN}`); + const response3 = await request(getUrl(vendor)) .get(`/items/${localCollectionSecond}`) .query({ @@ -141,6 +150,7 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { }), }) .set('Authorization', `Bearer ${common.USER.ADMIN.TOKEN}`); + const response4 = await request(getUrl(vendor)) .get(`/items/${localCollectionSecond}`) .query({ @@ -173,6 +183,7 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { }), }) .set('Authorization', `Bearer ${common.USER.ADMIN.TOKEN}`); + const response2 = await request(getUrl(vendor)) .get(`/items/${localCollectionFirst}`) .query({ @@ -181,6 +192,7 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { }), }) .set('Authorization', `Bearer ${common.USER.ADMIN.TOKEN}`); + const response3 = await request(getUrl(vendor)) .get(`/items/${localCollectionSecond}`) .query({ @@ -189,6 +201,7 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { }), }) .set('Authorization', `Bearer ${common.USER.ADMIN.TOKEN}`); + const response4 = await request(getUrl(vendor)) .get(`/items/${localCollectionSecond}`) .query({ diff --git a/tests/blackbox/routes/items/hash-filter.seed.ts b/tests/blackbox/routes/items/hash-filter.seed.ts index 7c69707a80..9640d7b4af 100644 --- a/tests/blackbox/routes/items/hash-filter.seed.ts +++ b/tests/blackbox/routes/items/hash-filter.seed.ts @@ -86,6 +86,7 @@ export const seedDBStructure = () => { export const seedDBValues = async () => { let isSeeded = true; + await Promise.all( vendors.map(async (vendor) => { for (const pkType of PRIMARY_KEY_TYPES) { diff --git a/tests/blackbox/routes/items/hash-filter.test.ts b/tests/blackbox/routes/items/hash-filter.test.ts index ff365af260..6ef0d01d90 100644 --- a/tests/blackbox/routes/items/hash-filter.test.ts +++ b/tests/blackbox/routes/items/hash-filter.test.ts @@ -26,6 +26,7 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { const response = await request(getUrl(vendor)) .get(`/items/${localCollectionFirst}`) .set('Authorization', `Bearer ${common.USER.ADMIN.TOKEN}`); + const response2 = await request(getUrl(vendor)) .get(`/items/${localCollectionSecond}`) .set('Authorization', `Bearer ${common.USER.ADMIN.TOKEN}`); @@ -47,18 +48,21 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { filter: JSON.stringify({ hash_field: { _null: true } }), }) .set('Authorization', `Bearer ${common.USER.ADMIN.TOKEN}`); + const response2 = await request(getUrl(vendor)) .get(`/items/${localCollectionFirst}`) .query({ filter: JSON.stringify({ hash_field: { _nnull: true } }), }) .set('Authorization', `Bearer ${common.USER.ADMIN.TOKEN}`); + const response3 = await request(getUrl(vendor)) .get(`/items/${localCollectionSecond}`) .query({ filter: JSON.stringify({ hash_field: { _null: true } }), }) .set('Authorization', `Bearer ${common.USER.ADMIN.TOKEN}`); + const response4 = await request(getUrl(vendor)) .get(`/items/${localCollectionSecond}`) .query({ @@ -87,18 +91,21 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { filter: JSON.stringify({ hash_field: { _contains: 'a' } }), }) .set('Authorization', `Bearer ${common.USER.ADMIN.TOKEN}`); + const response2 = await request(getUrl(vendor)) .get(`/items/${localCollectionFirst}`) .query({ filter: JSON.stringify({ hash_field: { _eq: 'b' } }), }) .set('Authorization', `Bearer ${common.USER.ADMIN.TOKEN}`); + const response3 = await request(getUrl(vendor)) .get(`/items/${localCollectionSecond}`) .query({ filter: JSON.stringify({ hash_field: { _starts_with: 'c' } }), }) .set('Authorization', `Bearer ${common.USER.ADMIN.TOKEN}`); + const response4 = await request(getUrl(vendor)) .get(`/items/${localCollectionSecond}`) .query({ @@ -125,6 +132,7 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { }), }) .set('Authorization', `Bearer ${common.USER.ADMIN.TOKEN}`); + const response2 = await request(getUrl(vendor)) .get(`/items/${localCollectionFirst}`) .query({ @@ -133,6 +141,7 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { }), }) .set('Authorization', `Bearer ${common.USER.ADMIN.TOKEN}`); + const response3 = await request(getUrl(vendor)) .get(`/items/${localCollectionSecond}`) .query({ @@ -141,6 +150,7 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { }), }) .set('Authorization', `Bearer ${common.USER.ADMIN.TOKEN}`); + const response4 = await request(getUrl(vendor)) .get(`/items/${localCollectionSecond}`) .query({ @@ -173,6 +183,7 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { }), }) .set('Authorization', `Bearer ${common.USER.ADMIN.TOKEN}`); + const response2 = await request(getUrl(vendor)) .get(`/items/${localCollectionFirst}`) .query({ @@ -181,6 +192,7 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { }), }) .set('Authorization', `Bearer ${common.USER.ADMIN.TOKEN}`); + const response3 = await request(getUrl(vendor)) .get(`/items/${localCollectionSecond}`) .query({ @@ -189,6 +201,7 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { }), }) .set('Authorization', `Bearer ${common.USER.ADMIN.TOKEN}`); + const response4 = await request(getUrl(vendor)) .get(`/items/${localCollectionSecond}`) .query({ diff --git a/tests/blackbox/routes/items/m2a.test.ts b/tests/blackbox/routes/items/m2a.test.ts index 15ee4b3214..af7b6b9c05 100644 --- a/tests/blackbox/routes/items/m2a.test.ts +++ b/tests/blackbox/routes/items/m2a.test.ts @@ -86,6 +86,7 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { it.each(vendors)('%s', async (vendor) => { // Setup const shape = createShape(pkType); + const insertedShape = await CreateItem(vendor, { collection: localCollectionShapes, item: { @@ -143,6 +144,7 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { it.each(vendors)('%s', async (vendor) => { // Setup const shape = createShape(pkType); + const insertedShape = await CreateItem(vendor, { collection: localCollectionShapes, item: { @@ -196,6 +198,7 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { // Assert expect(response.statusCode).toEqual(200); expect(response.body.data.children).toHaveLength(4); + for (const child of response.body.data.children) { if (typeof child.item === 'object') { expect(child.item).toEqual( @@ -208,6 +211,7 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { expect(gqlResponse.statusCode).toEqual(200); expect(gqlResponse.body.data[localCollectionShapes][0].children).toHaveLength(4); + for (const child of gqlResponse.body.data[localCollectionShapes][0].children) { if (child.item.__typename === localCollectionCircles) { expect(child.item).toEqual( @@ -231,6 +235,7 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { it.each(vendors)('%s', async (vendor) => { // Setup const shape = createShape(pkType); + const insertedShape = await CreateItem(vendor, { collection: localCollectionShapes, item: { @@ -286,6 +291,7 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { // Assert expect(response.statusCode).toEqual(200); expect(response.body.data.children).toHaveLength(4); + for (const child of response.body.data.children) { if (typeof child.item === 'object') { expect(child.item).toEqual( @@ -300,6 +306,7 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { expect(gqlResponse.statusCode).toEqual(200); expect(gqlResponse.body.data[localCollectionShapes][0].children).toHaveLength(4); + for (const child of gqlResponse.body.data[localCollectionShapes][0].children) { if (child.item.__typename === localCollectionCircles) { expect(child.item).toEqual( @@ -329,6 +336,7 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { // Setup const shape = createShape(pkType); shape.name = 'shape-m2a-top-' + uuid(); + const insertedShape = await CreateItem(vendor, { collection: localCollectionShapes, item: shape, @@ -388,9 +396,11 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { expect(gqlResponse.statusCode).toBe(200); expect(gqlResponse.body.data[localCollectionShapes].length).toBe(1); + expect(gqlResponse.body.data[localCollectionShapes][0]).toMatchObject({ id: String(insertedShape.id), }); + expect(gqlResponse2.statusCode).toBe(200); expect(gqlResponse.body.data).toEqual(gqlResponse2.body.data); }); @@ -405,6 +415,7 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { square.name = 'square-m2a-' + uuid(); const shape = createShape(pkType); shape.name = 'shape-m2a-' + uuid(); + const insertedShape = await CreateItem(vendor, { collection: localCollectionShapes, item: { @@ -494,9 +505,11 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { expect(gqlResponse.statusCode).toBe(200); expect(gqlResponse.body.data[localCollectionShapes].length).toBe(1); + expect(gqlResponse.body.data[localCollectionShapes][0]).toMatchObject({ id: String(insertedShape.id), }); + expect(gqlResponse2.statusCode).toBe(200); expect(gqlResponse.body.data).toEqual(gqlResponse2.body.data); }); @@ -513,6 +526,7 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { square.name = 'square-m2a-top-fn-' + uuid(); const shape = createShape(pkType); shape.name = 'shape-m2a-top-fn-' + uuid(); + const insertedShape = await CreateItem(vendor, { collection: localCollectionShapes, item: { @@ -527,6 +541,7 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { const shape2 = createShape(pkType); shape2.name = 'shape-m2a-top-fn-' + uuid(); + const insertedShape2 = await CreateItem(vendor, { collection: localCollectionShapes, item: { @@ -615,15 +630,19 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { expect(gqlResponse.statusCode).toBe(200); expect(gqlResponse.body.data[localCollectionShapes].length).toBe(1); + expect(gqlResponse.body.data[localCollectionShapes][0]).toMatchObject({ id: String(insertedShape.id), }); + expect(gqlResponse.body.data[localCollectionShapes][0].children.length).toBe(1); expect(gqlResponse2.statusCode).toBe(200); expect(gqlResponse2.body.data[localCollectionShapes].length).toBe(1); + expect(gqlResponse2.body.data[localCollectionShapes][0]).toMatchObject({ id: String(insertedShape2.id), }); + expect(gqlResponse2.body.data[localCollectionShapes][0].children.length).toBe(2); }); }); @@ -640,6 +659,7 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { circle.test_datetime = new Date(new Date().setFullYear(year)).toISOString().slice(0, 19); const shape = createShape(pkType); shape.name = 'shape-m2a-fn-' + uuid(); + const insertedShape = await CreateItem(vendor, { collection: localCollectionShapes, item: { @@ -764,11 +784,14 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { expect(gqlResponse.statusCode).toBe(200); expect(gqlResponse.body.data[localCollectionShapes].length).toBe(1); + expect(gqlResponse.body.data[localCollectionShapes][0]).toMatchObject({ id: String(retrievedShapes[0][0].id), }); + expect(gqlResponse2.statusCode).toBe(200); expect(gqlResponse2.body.data[localCollectionShapes].length).toBe(1); + expect(gqlResponse2.body.data[localCollectionShapes][0]).toMatchObject({ id: String(retrievedShapes[1][0].id), }); @@ -849,6 +872,7 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { expect(gqlResponse.statusCode).toEqual(200); expect(gqlResponse.body.data[localCollectionShapes].length).toBe(5); expect(gqlResponse2.statusCode).toEqual(200); + expect(gqlResponse.body.data[localCollectionShapes]).toEqual( gqlResponse2.body.data[localCollectionShapes].reverse() ); @@ -916,11 +940,13 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { expect(response.body.data.length).toBe(expectedLength); expect(response2.statusCode).toEqual(200); expect(response.body.data).not.toEqual(response2.body.data); + expect( response.body.data.map((item: any) => { return parseInt(item.name.slice(-1)); }) ).toEqual(expectedAsc); + expect( response2.body.data.map((item: any) => { return parseInt(item.name.slice(-1)); @@ -930,17 +956,21 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { expect(gqlResponse.statusCode).toEqual(200); expect(gqlResponse.body.data[localCollectionShapes].length).toBe(expectedLength); expect(gqlResponse2.statusCode).toEqual(200); + expect(gqlResponse.body.data[localCollectionShapes]).not.toEqual( gqlResponse2.body.data[localCollectionShapes] ); + expect(gqlResponse.body.data[localCollectionShapes]).not.toEqual( gqlResponse2.body.data[localCollectionShapes] ); + expect( gqlResponse.body.data[localCollectionShapes].map((item: any) => { return parseInt(item.name.slice(-1)); }) ).toEqual(expectedAsc); + expect( gqlResponse2.body.data[localCollectionShapes].map((item: any) => { return parseInt(item.name.slice(-1)); @@ -961,6 +991,7 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { circle.name = 'circle-m2a-sort-' + val; const shape = createShape(pkType); shape.name = 'shape-m2a-sort-' + uuid(); + await CreateItem(vendor, { collection: localCollectionShapes, item: { @@ -1044,6 +1075,7 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { } lastIndex = -1; + for (const item of gqlResponse2.body.data[localCollectionShapes].reverse()) { const foundIndex = findIndex(gqlResponse.body.data[localCollectionShapes], { id: item.id }); if (foundIndex === -1) continue; @@ -1062,6 +1094,7 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { expect(response.body.data).toEqual(response2.body.data.reverse()); expect(gqlResponse.body.data[localCollectionShapes].length).toBe(5); + expect(gqlResponse.body.data[localCollectionShapes]).toEqual( gqlResponse2.body.data[localCollectionShapes].reverse() ); @@ -1192,11 +1225,13 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { expect(response.body.data.length).toBe(expectedLength); expect(response.body.data).not.toEqual(response2.body.data); + expect( response.body.data.map((item: any) => { return parseInt(item.children[0].item.name.slice(-1)); }) ).toEqual(expectedAsc); + expect( response2.body.data.map((item: any) => { return parseInt(item.children[0].item.name.slice(-1)); @@ -1204,14 +1239,17 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { ).toEqual(expectedDesc); expect(gqlResponse.body.data[localCollectionShapes].length).toBe(expectedLength); + expect(gqlResponse.body.data[localCollectionShapes]).not.toEqual( gqlResponse2.body.data[localCollectionShapes] ); + expect( gqlResponse.body.data[localCollectionShapes].map((item: any) => { return parseInt(item.children[0].item.name.slice(-1)); }) ).toEqual(expectedAsc); + expect( gqlResponse2.body.data[localCollectionShapes].map((item: any) => { return parseInt(item.children[0].item.name.slice(-1)); @@ -1233,9 +1271,11 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { for (const val of sortValues) { const shape = createShape(pkType); shape.name = 'shape-m2a-top-sort-fn-' + uuid(); + shape.test_datetime = new Date(new Date().setFullYear(parseInt(`202${val}`))) .toISOString() .slice(0, 19); + shapes.push(shape); } @@ -1298,6 +1338,7 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { expect(gqlResponse.statusCode).toEqual(200); expect(gqlResponse.body.data[localCollectionShapes].length).toBe(5); expect(gqlResponse2.statusCode).toEqual(200); + expect(gqlResponse.body.data[localCollectionShapes]).toEqual( gqlResponse2.body.data[localCollectionShapes].reverse() ); @@ -1369,11 +1410,13 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { expect(response.body.data.length).toBe(expectedLength); expect(response2.statusCode).toEqual(200); expect(response.body.data).not.toEqual(response2.body.data); + expect( response.body.data.map((item: any) => { return parseInt(item.test_datetime_year.toString().slice(-1)); }) ).toEqual(expectedAsc); + expect( response2.body.data.map((item: any) => { return parseInt(item.test_datetime_year.toString().slice(-1)); @@ -1383,14 +1426,17 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { expect(gqlResponse.statusCode).toEqual(200); expect(gqlResponse.body.data[localCollectionShapes].length).toBe(expectedLength); expect(gqlResponse2.statusCode).toEqual(200); + expect(gqlResponse.body.data[localCollectionShapes]).not.toEqual( gqlResponse2.body.data[localCollectionShapes] ); + expect( gqlResponse.body.data[localCollectionShapes].map((item: any) => { return parseInt(item.test_datetime_func.year.toString().slice(-1)); }) ).toEqual(expectedAsc); + expect( gqlResponse2.body.data[localCollectionShapes].map((item: any) => { return parseInt(item.test_datetime_func.year.toString().slice(-1)); @@ -1409,11 +1455,14 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { for (const val of sortValues) { const circle = createCircle(pkType); circle.name = 'circle-m2a-sort-fn-' + uuid(); + circle.test_datetime = new Date(new Date().setFullYear(parseInt(`202${val}`))) .toISOString() .slice(0, 19); + const shape = createCircle(pkType); shape.name = 'shape-m2a-sort-fn-' + uuid(); + await CreateItem(vendor, { collection: localCollectionShapes, item: { @@ -1495,6 +1544,7 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { } lastIndex = -1; + for (const item of gqlResponse2.body.data[localCollectionShapes].reverse()) { const foundIndex = findIndex(gqlResponse.body.data[localCollectionShapes], { id: item.id }); if (foundIndex === -1) continue; @@ -1513,6 +1563,7 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { expect(response.body.data).toEqual(response2.body.data.reverse()); expect(gqlResponse.body.data[localCollectionShapes].length).toBe(5); + expect(gqlResponse.body.data[localCollectionShapes]).toEqual( gqlResponse2.body.data[localCollectionShapes].reverse() ); @@ -1651,11 +1702,13 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { expect(response.body.data.length).toBe(expectedLength); expect(response.body.data).not.toEqual(response2.body.data); + expect( response.body.data.map((item: any) => { return parseInt(item.children[0].item.test_datetime_year.toString().slice(-1)); }) ).toEqual(expectedAsc); + expect( response2.body.data.map((item: any) => { return parseInt(item.children[0].item.test_datetime_year.toString().slice(-1)); @@ -1663,14 +1716,17 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { ).toEqual(expectedDesc); expect(gqlResponse.body.data[localCollectionShapes].length).toBe(expectedLength); + expect(gqlResponse.body.data[localCollectionShapes]).not.toEqual( gqlResponse2.body.data[localCollectionShapes] ); + expect( gqlResponse.body.data[localCollectionShapes].map((item: any) => { return parseInt(item.children[0].item.test_datetime_func.year.toString().slice(-1)); }) ).toEqual(expectedAsc); + expect( gqlResponse2.body.data[localCollectionShapes].map((item: any) => { return parseInt(item.children[0].item.test_datetime_func.year.toString().slice(-1)); @@ -1754,6 +1810,7 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { // Assert expect(response.statusCode).toBe(400); expect(response.body.errors).toBeDefined(); + expect(response.body.errors[0].message).toBe( `Exceeded max batch mutation limit of ${config.envs[vendor].MAX_BATCH_MUTATION}.` ); @@ -1775,6 +1832,7 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { for (let i = 0; i < count; i++) { shapes.push(createShape(pkType)); + shapes[i].children = Array(countNested) .fill(0) .map((_, index) => { @@ -1817,6 +1875,7 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { for (let i = 0; i < count; i++) { shapes.push(createShape(pkType)); + shapes[i].children = Array(countNested) .fill(0) .map((_, index) => { @@ -1837,6 +1896,7 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { // Assert expect(response.statusCode).toBe(400); expect(response.body.errors).toBeDefined(); + expect(response.body.errors[0].message).toBe( `Exceeded max batch mutation limit of ${config.envs[vendor].MAX_BATCH_MUTATION}.` ); @@ -1860,6 +1920,7 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { for (let i = 0; i < count; i++) { const shape: any = createShape(pkType); + shape.children = Array(countUpdate + countDelete) .fill(0) .map((_, index) => { @@ -1869,6 +1930,7 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { return { collection: localCollectionSquares, item: createSquare(pkType) }; } }); + shapesID.push((await CreateItem(vendor, { collection: localCollectionShapes, item: shape })).id); } @@ -1880,6 +1942,7 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { for (const shape of shapes) { const children = shape.children; + shape.children = { create: Array(countCreate) .fill(0) @@ -1928,6 +1991,7 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { for (let i = 0; i < count; i++) { const shape: any = createShape(pkType); + shape.children = Array(countUpdate + countDelete) .fill(0) .map((_, index) => { @@ -1937,6 +2001,7 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { return { collection: localCollectionSquares, item: createSquare(pkType) }; } }); + shapesID.push((await CreateItem(vendor, { collection: localCollectionShapes, item: shape })).id); } @@ -1948,6 +2013,7 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { for (const shape of shapes) { const children = shape.children; + shape.children = { create: Array(countCreate) .fill(0) @@ -1972,6 +2038,7 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { // Assert expect(response.statusCode).toBe(400); expect(response.body.errors).toBeDefined(); + expect(response.body.errors[0].message).toBe( `Exceeded max batch mutation limit of ${config.envs[vendor].MAX_BATCH_MUTATION}.` ); diff --git a/tests/blackbox/routes/items/m2m.test.ts b/tests/blackbox/routes/items/m2m.test.ts index 60c8a12bac..c7ce37ddf5 100644 --- a/tests/blackbox/routes/items/m2m.test.ts +++ b/tests/blackbox/routes/items/m2m.test.ts @@ -72,6 +72,7 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { it.each(vendors)('%s', async (vendor) => { // Setup const ingredient = createIngredient(pkType); + const insertedIngredient = await CreateItem(vendor, { collection: localCollectionIngredients, item: { @@ -115,12 +116,14 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { }); }); }); + describe('GET /:collection', () => { describe(`retrieves a food using the $FOLLOW filter`, () => { it.each(vendors)('%s', async (vendor) => { // Setup const ingredient = createIngredient(pkType); const food = createFood(pkType); + const insertedIngredient = await CreateItem(vendor, { collection: localCollectionIngredients, item: { @@ -157,6 +160,7 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { // Setup const ingredient = createIngredient(pkType); ingredient.name = 'ingredient-m2m-top-' + uuid(); + const insertedIngredient = await CreateItem(vendor, { collection: localCollectionIngredients, item: ingredient, @@ -216,9 +220,11 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { expect(gqlResponse.statusCode).toBe(200); expect(gqlResponse.body.data[localCollectionIngredients].length).toBe(1); + expect(gqlResponse.body.data[localCollectionIngredients][0]).toMatchObject({ id: String(insertedIngredient.id), }); + expect(gqlResponse2.statusCode).toBe(200); expect(gqlResponse.body.data).toEqual(gqlResponse2.body.data); }); @@ -231,6 +237,7 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { food.name = 'food-m2m-' + uuid(); const ingredient = createIngredient(pkType); ingredient.name = 'ingredient-m2m-' + uuid(); + const insertedIngredient = await CreateItem(vendor, { collection: localCollectionIngredients, item: { @@ -311,9 +318,11 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { expect(gqlResponse.statusCode).toBe(200); expect(gqlResponse.body.data[localCollectionIngredients].length).toBe(1); + expect(gqlResponse.body.data[localCollectionIngredients][0]).toMatchObject({ id: String(insertedIngredient.id), }); + expect(gqlResponse2.statusCode).toBe(200); expect(gqlResponse.body.data).toEqual(gqlResponse2.body.data); }); @@ -326,6 +335,7 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { // Setup const ingredient = createIngredient(pkType); ingredient.name = 'ingredient-m2m-top-fn-' + uuid(); + const insertedIngredient = await CreateItem(vendor, { collection: localCollectionIngredients, item: { @@ -340,6 +350,7 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { const ingredient2 = createIngredient(pkType); ingredient2.name = 'ingredient-m2m-top-fn-' + uuid(); + const insertedIngredient2 = await CreateItem(vendor, { collection: localCollectionIngredients, item: { @@ -424,15 +435,19 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { expect(gqlResponse.statusCode).toBe(200); expect(gqlResponse.body.data[localCollectionIngredients].length).toBe(1); + expect(gqlResponse.body.data[localCollectionIngredients][0]).toMatchObject({ id: String(insertedIngredient.id), }); + expect(gqlResponse.body.data[localCollectionIngredients][0].foods.length).toBe(1); expect(gqlResponse2.statusCode).toBe(200); expect(gqlResponse2.body.data[localCollectionIngredients].length).toBe(1); + expect(gqlResponse2.body.data[localCollectionIngredients][0]).toMatchObject({ id: String(insertedIngredient2.id), }); + expect(gqlResponse2.body.data[localCollectionIngredients][0].foods.length).toBe(2); }); }); @@ -449,6 +464,7 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { food.test_datetime = new Date(new Date().setFullYear(year)).toISOString().slice(0, 19); const ingredient = createIngredient(pkType); ingredient.name = 'ingredient-m2m-fn-' + uuid(); + const insertedIngredient = await CreateItem(vendor, { collection: localCollectionIngredients, item: { @@ -573,11 +589,14 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { expect(gqlResponse.statusCode).toBe(200); expect(gqlResponse.body.data[localCollectionIngredients].length).toBe(1); + expect(gqlResponse.body.data[localCollectionIngredients][0]).toMatchObject({ id: String(retrievedIngredients[0][0].id), }); + expect(gqlResponse2.statusCode).toBe(200); expect(gqlResponse2.body.data[localCollectionIngredients].length).toBe(1); + expect(gqlResponse2.body.data[localCollectionIngredients][0]).toMatchObject({ id: String(retrievedIngredients[1][0].id), }); @@ -658,6 +677,7 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { expect(gqlResponse.statusCode).toEqual(200); expect(gqlResponse.body.data[localCollectionIngredients].length).toBe(5); expect(gqlResponse2.statusCode).toEqual(200); + expect(gqlResponse.body.data[localCollectionIngredients]).toEqual( gqlResponse2.body.data[localCollectionIngredients].reverse() ); @@ -725,11 +745,13 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { expect(response.body.data.length).toBe(expectedLength); expect(response2.statusCode).toEqual(200); expect(response.body.data).not.toEqual(response2.body.data); + expect( response.body.data.map((item: any) => { return parseInt(item.name.slice(-1)); }) ).toEqual(expectedAsc); + expect( response2.body.data.map((item: any) => { return parseInt(item.name.slice(-1)); @@ -739,14 +761,17 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { expect(gqlResponse.statusCode).toEqual(200); expect(gqlResponse.body.data[localCollectionIngredients].length).toBe(expectedLength); expect(gqlResponse2.statusCode).toEqual(200); + expect(gqlResponse.body.data[localCollectionIngredients]).not.toEqual( gqlResponse2.body.data[localCollectionIngredients] ); + expect( gqlResponse.body.data[localCollectionIngredients].map((item: any) => { return parseInt(item.name.slice(-1)); }) ).toEqual(expectedAsc); + expect( gqlResponse2.body.data[localCollectionIngredients].map((item: any) => { return parseInt(item.name.slice(-1)); @@ -767,6 +792,7 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { food.name = 'food-m2m-sort-' + val; const ingredient = createIngredient(pkType); ingredient.name = 'ingredient-m2m-sort-' + uuid(); + await CreateItem(vendor, { collection: localCollectionIngredients, item: { @@ -848,6 +874,7 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { } lastIndex = -1; + for (const item of gqlResponse2.body.data[localCollectionIngredients].reverse()) { const foundIndex = findIndex(gqlResponse.body.data[localCollectionIngredients], { id: item.id }); if (foundIndex === -1) continue; @@ -866,6 +893,7 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { expect(response.body.data).toEqual(response2.body.data.reverse()); expect(gqlResponse.body.data[localCollectionIngredients].length).toBe(5); + expect(gqlResponse.body.data[localCollectionIngredients]).toEqual( gqlResponse2.body.data[localCollectionIngredients].reverse() ); @@ -992,11 +1020,13 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { expect(response.body.data.length).toBe(expectedLength); expect(response.body.data).not.toEqual(response2.body.data); + expect( response.body.data.map((item: any) => { return parseInt(item.foods[0][`${localCollectionFoods}_id`].name.slice(-1)); }) ).toEqual(expectedAsc); + expect( response2.body.data.map((item: any) => { return parseInt(item.foods[0][`${localCollectionFoods}_id`].name.slice(-1)); @@ -1004,14 +1034,17 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { ).toEqual(expectedDesc); expect(gqlResponse.body.data[localCollectionIngredients].length).toBe(expectedLength); + expect(gqlResponse.body.data[localCollectionIngredients]).not.toEqual( gqlResponse2.body.data[localCollectionIngredients] ); + expect( gqlResponse.body.data[localCollectionIngredients].map((item: any) => { return parseInt(item.foods[0][`${localCollectionFoods}_id`].name.slice(-1)); }) ).toEqual(expectedAsc); + expect( gqlResponse2.body.data[localCollectionIngredients].map((item: any) => { return parseInt(item.foods[0][`${localCollectionFoods}_id`].name.slice(-1)); @@ -1033,9 +1066,11 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { for (const val of sortValues) { const ingredient = createIngredient(pkType); ingredient.name = 'ingredient-m2m-top-sort-fn-' + uuid(); + ingredient.test_datetime = new Date(new Date().setFullYear(parseInt(`202${val}`))) .toISOString() .slice(0, 19); + ingredients.push(ingredient); } @@ -1098,6 +1133,7 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { expect(gqlResponse.statusCode).toEqual(200); expect(gqlResponse.body.data[localCollectionIngredients].length).toBe(5); expect(gqlResponse2.statusCode).toEqual(200); + expect(gqlResponse.body.data[localCollectionIngredients]).toEqual( gqlResponse2.body.data[localCollectionIngredients].reverse() ); @@ -1169,11 +1205,13 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { expect(response.body.data.length).toBe(expectedLength); expect(response2.statusCode).toEqual(200); expect(response.body.data).not.toEqual(response2.body.data); + expect( response.body.data.map((item: any) => { return parseInt(item.test_datetime_year.toString().slice(-1)); }) ).toEqual(expectedAsc); + expect( response2.body.data.map((item: any) => { return parseInt(item.test_datetime_year.toString().slice(-1)); @@ -1183,14 +1221,17 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { expect(gqlResponse.statusCode).toEqual(200); expect(gqlResponse.body.data[localCollectionIngredients].length).toBe(expectedLength); expect(gqlResponse2.statusCode).toEqual(200); + expect(gqlResponse.body.data[localCollectionIngredients]).not.toEqual( gqlResponse2.body.data[localCollectionIngredients] ); + expect( gqlResponse.body.data[localCollectionIngredients].map((item: any) => { return parseInt(item.test_datetime_func.year.toString().slice(-1)); }) ).toEqual(expectedAsc); + expect( gqlResponse2.body.data[localCollectionIngredients].map((item: any) => { return parseInt(item.test_datetime_func.year.toString().slice(-1)); @@ -1212,6 +1253,7 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { food.test_datetime = new Date(new Date().setFullYear(parseInt(`202${val}`))).toISOString().slice(0, 19); const ingredient = createIngredient(pkType); ingredient.name = 'ingredient-m2m-sort-fn-' + uuid(); + await CreateItem(vendor, { collection: localCollectionIngredients, item: { @@ -1293,6 +1335,7 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { } lastIndex = -1; + for (const item of gqlResponse2.body.data[localCollectionIngredients].reverse()) { const foundIndex = findIndex(gqlResponse.body.data[localCollectionIngredients], { id: item.id }); if (foundIndex === -1) continue; @@ -1311,6 +1354,7 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { expect(response.body.data).toEqual(response2.body.data.reverse()); expect(gqlResponse.body.data[localCollectionIngredients].length).toBe(5); + expect(gqlResponse.body.data[localCollectionIngredients]).toEqual( gqlResponse2.body.data[localCollectionIngredients].reverse() ); @@ -1441,11 +1485,13 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { expect(response.body.data.length).toBe(expectedLength); expect(response.body.data).not.toEqual(response2.body.data); + expect( response.body.data.map((item: any) => { return parseInt(item.foods[0][`${localCollectionFoods}_id`].test_datetime_year.toString().slice(-1)); }) ).toEqual(expectedAsc); + expect( response2.body.data.map((item: any) => { return parseInt(item.foods[0][`${localCollectionFoods}_id`].test_datetime_year.toString().slice(-1)); @@ -1453,9 +1499,11 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { ).toEqual(expectedDesc); expect(gqlResponse.body.data[localCollectionIngredients].length).toBe(expectedLength); + expect(gqlResponse.body.data[localCollectionIngredients]).not.toEqual( gqlResponse2.body.data[localCollectionIngredients] ); + expect( gqlResponse.body.data[localCollectionIngredients].map((item: any) => { return parseInt( @@ -1463,6 +1511,7 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { ); }) ).toEqual(expectedAsc); + expect( gqlResponse2.body.data[localCollectionIngredients].map((item: any) => { return parseInt( @@ -1514,6 +1563,7 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { it.each(vendors)('%s', async (vendor) => { // Setup const food = createFood(pkType); + const insertedFood = await CreateItem(vendor, { collection: localCollectionFoods, item: { @@ -1559,6 +1609,7 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { // Assert expect(response.statusCode).toEqual(200); + expect(response.body.data).toEqual( expect.objectContaining({ ingredients: expect.arrayContaining([ @@ -1578,6 +1629,7 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { ); expect(gqlResponse.statusCode).toEqual(200); + expect(gqlResponse.body.data).toEqual( expect.objectContaining({ [localCollectionFoods]: expect.arrayContaining([ @@ -1606,6 +1658,7 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { it.each(vendors)('%s', async (vendor) => { // Setup const food = createFood(pkType); + const insertedFood = await CreateItem(vendor, { collection: localCollectionFoods, item: { @@ -1666,6 +1719,7 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { it.each(vendors)('%s', async (vendor) => { // Setup const food = createFood(pkType); + const insertedFood = await CreateItem(vendor, { collection: localCollectionFoods, item: { @@ -1703,6 +1757,7 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { // Assert expect(response.statusCode).toEqual(200); + expect(response.body.data).toEqual( expect.objectContaining({ ingredients: expect.arrayContaining([ @@ -1728,6 +1783,7 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { // Setup const food = createFood(pkType); const ingredient = createIngredient(pkType); + const insertedFood = await CreateItem(vendor, { collection: localCollectionFoods, item: { @@ -1776,6 +1832,7 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { it.each(vendors)('%s', async (vendor) => { // Setup const food = createFood(pkType); + const insertedFood = await CreateItem(vendor, { collection: localCollectionFoods, item: { @@ -1841,6 +1898,7 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { // Assert expect(response.statusCode).toEqual(200); + expect(response.body.data).toEqual( expect.objectContaining({ ingredients: expect.arrayContaining([ @@ -1860,6 +1918,7 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { ); expect(gqlResponse.statusCode).toEqual(200); + expect(gqlResponse.body.data).toEqual( expect.objectContaining({ [localCollectionFoods]: expect.arrayContaining([ @@ -1889,6 +1948,7 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { // Setup const food = createFood(pkType); const ingredient = createIngredient(pkType); + const insertedFood = await CreateItem(vendor, { collection: localCollectionFoods, item: { @@ -1971,6 +2031,7 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { it.each(vendors)('%s', async (vendor) => { // Setup const food = createFood(pkType); + const insertedFood = await CreateItem(vendor, { collection: localCollectionFoods, item: { @@ -2020,6 +2081,7 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { // Assert expect(response.statusCode).toEqual(200); + expect(response.body.data).toEqual( expect.objectContaining({ ingredients: expect.arrayContaining([ @@ -2039,6 +2101,7 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { ); expect(gqlResponse.statusCode).toEqual(200); + expect(gqlResponse.body.data).toEqual( expect.objectContaining({ [localCollectionFoods]: expect.arrayContaining([ @@ -2067,6 +2130,7 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { it.each(vendors)('%s', async (vendor) => { // Setup const food = createFood(pkType); + const insertedFood = await CreateItem(vendor, { collection: localCollectionFoods, item: { @@ -2129,6 +2193,7 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { it.each(vendors)('%s', async (vendor) => { // Setup const food = createFood(pkType); + const insertedFood = await CreateItem(vendor, { collection: localCollectionFoods, item: { @@ -2156,6 +2221,7 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { // Assert expect(response.statusCode).toEqual(200); + expect(response.body.data).toEqual( expect.objectContaining({ ingredients: expect.arrayContaining([ @@ -2180,6 +2246,7 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { it.each(vendors)('%s', async (vendor) => { // Setup const food = createFood(pkType); + const insertedFood = await CreateItem(vendor, { collection: localCollectionFoods, item: { @@ -2332,12 +2399,14 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { // Assert expect(response.statusCode).toBe(400); expect(response.body.errors).toBeDefined(); + expect(response.body.errors[0].message).toBe( `Exceeded max batch mutation limit of ${config.envs[vendor].MAX_BATCH_MUTATION}.` ); expect(gqlResponse.statusCode).toBe(200); expect(gqlResponse.body.errors).toBeDefined(); + expect(gqlResponse.body.errors[0].message).toBe( `Exceeded max batch mutation limit of ${config.envs[vendor].MAX_BATCH_MUTATION}.` ); @@ -2360,6 +2429,7 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { for (let i = 0; i < count; i++) { foods.push(createFood(pkType)); + foods[i].ingredients = Array(countNested) .fill(0) .map(() => { @@ -2367,6 +2437,7 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { }); foods2.push(createFood(pkType)); + foods2[i].ingredients = Array(countNested) .fill(0) .map(() => { @@ -2421,6 +2492,7 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { for (let i = 0; i < count; i++) { foods.push(createFood(pkType)); + foods[i].ingredients = Array(countNested) .fill(0) .map(() => { @@ -2428,6 +2500,7 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { }); foods2.push(createFood(pkType)); + foods2[i].ingredients = Array(countNested) .fill(0) .map(() => { @@ -2457,12 +2530,14 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { // Assert expect(response.statusCode).toBe(400); expect(response.body.errors).toBeDefined(); + expect(response.body.errors[0].message).toBe( `Exceeded max batch mutation limit of ${config.envs[vendor].MAX_BATCH_MUTATION}.` ); expect(gqlResponse.statusCode).toBe(200); expect(gqlResponse.body.errors).toBeDefined(); + expect(gqlResponse.body.errors[0].message).toBe( `Exceeded max batch mutation limit of ${config.envs[vendor].MAX_BATCH_MUTATION}.` ); @@ -2487,19 +2562,23 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { for (let i = 0; i < count; i++) { const food: any = createFood(pkType); + food.ingredients = Array(countUpdate + countDelete) .fill(0) .map(() => { return { [`${localCollectionIngredients}_id`]: createIngredient(pkType) }; }); + foodsID.push((await CreateItem(vendor, { collection: localCollectionFoods, item: food })).id); const food2: any = createFood(pkType); + food2.ingredients = Array(countUpdate + countDelete) .fill(0) .map(() => { return { [`${localCollectionIngredients}_id`]: createIngredient(pkType) }; }); + foodsID2.push((await CreateItem(vendor, { collection: localCollectionFoods, item: food2 })).id); } @@ -2527,6 +2606,7 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { for (const food of foods) { const ingredients = food.ingredients; + food.ingredients = { create: Array(countCreate) .fill(0) @@ -2599,19 +2679,23 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { for (let i = 0; i < count; i++) { const food: any = createFood(pkType); + food.ingredients = Array(countUpdate + countDelete) .fill(0) .map(() => { return { [`${localCollectionIngredients}_id`]: createIngredient(pkType) }; }); + foodsID.push((await CreateItem(vendor, { collection: localCollectionFoods, item: food })).id); const food2: any = createFood(pkType); + food2.ingredients = Array(countUpdate + countDelete) .fill(0) .map(() => { return { [`${localCollectionIngredients}_id`]: createIngredient(pkType) }; }); + foodsID2.push((await CreateItem(vendor, { collection: localCollectionFoods, item: food2 })).id); } @@ -2639,6 +2723,7 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { for (const food of foods) { const ingredients = food.ingredients; + food.ingredients = { create: Array(countCreate) .fill(0) @@ -2683,12 +2768,14 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { // Assert expect(response.statusCode).toBe(400); expect(response.body.errors).toBeDefined(); + expect(response.body.errors[0].message).toBe( `Exceeded max batch mutation limit of ${config.envs[vendor].MAX_BATCH_MUTATION}.` ); expect(gqlResponse.statusCode).toBe(200); expect(gqlResponse.body.errors).toBeDefined(); + expect(gqlResponse.body.errors[0].message).toBe( `Exceeded max batch mutation limit of ${config.envs[vendor].MAX_BATCH_MUTATION}.` ); diff --git a/tests/blackbox/routes/items/m2o.test.ts b/tests/blackbox/routes/items/m2o.test.ts index b87e8a827e..f70328fb04 100644 --- a/tests/blackbox/routes/items/m2o.test.ts +++ b/tests/blackbox/routes/items/m2o.test.ts @@ -73,6 +73,7 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { collection: localCollectionCountries, item: createCountry(pkType), }); + const state = createState(pkType); state.country_id = insertedCountry.id; const insertedState = await CreateItem(vendor, { collection: localCollectionStates, item: state }); @@ -104,12 +105,14 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { expect(response.body.data).toMatchObject({ country_id: insertedCountry.id }); expect(gqlResponse.statusCode).toEqual(200); + expect(gqlResponse.body.data).toMatchObject({ [localCollectionStates]: [{ country_id: { id: String(insertedCountry.id) } }], }); }); }); }); + describe('GET /:collection', () => { describe('filters', () => { describe('on top level', () => { @@ -117,6 +120,7 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { // Setup const state = createState(pkType); state.name = 'state-m2o-top-' + uuid(); + const insertedState = await CreateItem(vendor, { collection: localCollectionStates, item: state, @@ -176,9 +180,11 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { expect(gqlResponse.statusCode).toBe(200); expect(gqlResponse.body.data[localCollectionStates].length).toBe(1); + expect(gqlResponse.body.data[localCollectionStates][0]).toMatchObject({ id: String(insertedState.id), }); + expect(gqlResponse2.statusCode).toBe(200); expect(gqlResponse.body.data).toEqual(gqlResponse2.body.data); }); @@ -189,10 +195,12 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { // Setup const country = createCountry(pkType); country.name = 'country-m2o-' + uuid(); + const insertedCountry = await CreateItem(vendor, { collection: localCollectionCountries, item: country, }); + const state = createState(pkType); state.name = 'state-m2o-' + uuid(); state.country_id = insertedCountry.id; @@ -248,9 +256,11 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { expect(gqlResponse.statusCode).toBe(200); expect(gqlResponse.body.data[localCollectionStates].length).toBe(1); + expect(gqlResponse.body.data[localCollectionStates][0]).toMatchObject({ id: String(insertedState.id), }); + expect(gqlResponse2.statusCode).toBe(200); expect(gqlResponse.body.data).toEqual(gqlResponse2.body.data); }); @@ -327,11 +337,14 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { expect(gqlResponse.statusCode).toBe(200); expect(gqlResponse.body.data[localCollectionStates].length).toBe(1); + expect(gqlResponse.body.data[localCollectionStates][0]).toMatchObject({ name: states[0].name, }); + expect(gqlResponse2.statusCode).toBe(200); expect(gqlResponse2.body.data[localCollectionStates].length).toBe(1); + expect(gqlResponse2.body.data[localCollectionStates][0]).toMatchObject({ name: states[1].name, }); @@ -348,10 +361,12 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { const country = createCountry(pkType); country.name = 'country-m2o-fn-' + uuid(); country.test_datetime = new Date(new Date().setFullYear(year)).toISOString().slice(0, 19); + const insertedCountry = await CreateItem(vendor, { collection: localCollectionCountries, item: country, }); + const state = createState(pkType); state.name = 'state-m2o-fn-' + uuid(); state.country_id = insertedCountry.id; @@ -422,11 +437,14 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { expect(gqlResponse.statusCode).toBe(200); expect(gqlResponse.body.data[localCollectionStates].length).toBe(1); + expect(gqlResponse.body.data[localCollectionStates][0]).toMatchObject({ name: states[0].name, }); + expect(gqlResponse2.statusCode).toBe(200); expect(gqlResponse2.body.data[localCollectionStates].length).toBe(1); + expect(gqlResponse2.body.data[localCollectionStates][0]).toMatchObject({ name: states[1].name, }); @@ -507,6 +525,7 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { expect(gqlResponse.statusCode).toEqual(200); expect(gqlResponse.body.data[localCollectionStates].length).toBe(5); expect(gqlResponse2.statusCode).toEqual(200); + expect(gqlResponse.body.data[localCollectionStates]).toEqual( gqlResponse2.body.data[localCollectionStates].reverse() ); @@ -574,11 +593,13 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { expect(response.body.data.length).toBe(expectedLength); expect(response2.statusCode).toEqual(200); expect(response.body.data).not.toEqual(response2.body.data); + expect( response.body.data.map((item: any) => { return parseInt(item.name.slice(-1)); }) ).toEqual(expectedAsc); + expect( response2.body.data.map((item: any) => { return parseInt(item.name.slice(-1)); @@ -588,14 +609,17 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { expect(gqlResponse.statusCode).toEqual(200); expect(gqlResponse.body.data[localCollectionStates].length).toBe(expectedLength); expect(gqlResponse2.statusCode).toEqual(200); + expect(gqlResponse.body.data[localCollectionStates]).not.toEqual( gqlResponse2.body.data[localCollectionStates] ); + expect( gqlResponse.body.data[localCollectionStates].map((item: any) => { return parseInt(item.name.slice(-1)); }) ).toEqual(expectedAsc); + expect( gqlResponse2.body.data[localCollectionStates].map((item: any) => { return parseInt(item.name.slice(-1)); @@ -614,10 +638,12 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { for (const val of sortValues) { const country = createCountry(pkType); country.name = 'country-m2o-sort-' + val; + const insertedCountry = await CreateItem(vendor, { collection: localCollectionCountries, item: country, }); + const state = createState(pkType); state.name = 'state-m2o-sort-' + uuid(); state.country_id = insertedCountry.id; @@ -678,6 +704,7 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { expect(gqlResponse.statusCode).toEqual(200); expect(gqlResponse.body.data[localCollectionStates].length).toBe(5); expect(gqlResponse2.statusCode).toEqual(200); + expect(gqlResponse.body.data[localCollectionStates]).toEqual( gqlResponse2.body.data[localCollectionStates].reverse() ); @@ -749,11 +776,13 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { expect(response.body.data.length).toBe(expectedLength); expect(response2.statusCode).toEqual(200); expect(response.body.data).not.toEqual(response2.body.data); + expect( response.body.data.map((item: any) => { return parseInt(item.country_id.name.slice(-1)); }) ).toEqual(expectedAsc); + expect( response2.body.data.map((item: any) => { return parseInt(item.country_id.name.slice(-1)); @@ -763,14 +792,17 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { expect(gqlResponse.statusCode).toEqual(200); expect(gqlResponse.body.data[localCollectionStates].length).toBe(expectedLength); expect(gqlResponse2.statusCode).toEqual(200); + expect(gqlResponse.body.data[localCollectionStates]).not.toEqual( gqlResponse2.body.data[localCollectionStates] ); + expect( gqlResponse.body.data[localCollectionStates].map((item: any) => { return parseInt(item.country_id.name.slice(-1)); }) ).toEqual(expectedAsc); + expect( gqlResponse2.body.data[localCollectionStates].map((item: any) => { return parseInt(item.country_id.name.slice(-1)); @@ -792,9 +824,11 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { for (const val of sortValues) { const state = createState(pkType); state.name = 'state-m2o-top-sort-fn-' + uuid(); + state.test_datetime = new Date(new Date().setFullYear(parseInt(`202${val}`))) .toISOString() .slice(0, 19); + states.push(state); } @@ -857,6 +891,7 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { expect(gqlResponse.statusCode).toEqual(200); expect(gqlResponse.body.data[localCollectionStates].length).toBe(5); expect(gqlResponse2.statusCode).toEqual(200); + expect(gqlResponse.body.data[localCollectionStates]).toEqual( gqlResponse2.body.data[localCollectionStates].reverse() ); @@ -928,11 +963,13 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { expect(response.body.data.length).toBe(expectedLength); expect(response2.statusCode).toEqual(200); expect(response.body.data).not.toEqual(response2.body.data); + expect( response.body.data.map((item: any) => { return parseInt(item.test_datetime_year.toString().slice(-1)); }) ).toEqual(expectedAsc); + expect( response2.body.data.map((item: any) => { return parseInt(item.test_datetime_year.toString().slice(-1)); @@ -942,14 +979,17 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { expect(gqlResponse.statusCode).toEqual(200); expect(gqlResponse.body.data[localCollectionStates].length).toBe(expectedLength); expect(gqlResponse2.statusCode).toEqual(200); + expect(gqlResponse.body.data[localCollectionStates]).not.toEqual( gqlResponse2.body.data[localCollectionStates] ); + expect( gqlResponse.body.data[localCollectionStates].map((item: any) => { return parseInt(item.test_datetime_func.year.toString().slice(-1)); }) ).toEqual(expectedAsc); + expect( gqlResponse2.body.data[localCollectionStates].map((item: any) => { return parseInt(item.test_datetime_func.year.toString().slice(-1)); @@ -968,13 +1008,16 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { for (const val of sortValues) { const country = createCountry(pkType); country.name = 'country-m2o-sort-fn-' + uuid(); + country.test_datetime = new Date(new Date().setFullYear(parseInt(`202${val}`))) .toISOString() .slice(0, 19); + const insertedCountry = await CreateItem(vendor, { collection: localCollectionCountries, item: country, }); + const state = createState(pkType); state.name = 'state-m2o-sort-fn-' + uuid(); state.country_id = insertedCountry.id; @@ -1035,6 +1078,7 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { expect(gqlResponse.statusCode).toEqual(200); expect(gqlResponse.body.data[localCollectionStates].length).toBe(5); expect(gqlResponse2.statusCode).toEqual(200); + expect(gqlResponse.body.data[localCollectionStates]).toEqual( gqlResponse2.body.data[localCollectionStates].reverse() ); @@ -1110,11 +1154,13 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { expect(response.body.data.length).toBe(expectedLength); expect(response2.statusCode).toEqual(200); expect(response.body.data).not.toEqual(response2.body.data); + expect( response.body.data.map((item: any) => { return parseInt(item.country_id.test_datetime_year.toString().slice(-1)); }) ).toEqual(expectedAsc); + expect( response2.body.data.map((item: any) => { return parseInt(item.country_id.test_datetime_year.toString().slice(-1)); @@ -1124,14 +1170,17 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { expect(gqlResponse.statusCode).toEqual(200); expect(gqlResponse.body.data[localCollectionStates].length).toBe(expectedLength); expect(gqlResponse2.statusCode).toEqual(200); + expect(gqlResponse.body.data[localCollectionStates]).not.toEqual( gqlResponse2.body.data[localCollectionStates] ); + expect( gqlResponse.body.data[localCollectionStates].map((item: any) => { return parseInt(item.country_id.test_datetime_func.year.toString().slice(-1)); }) ).toEqual(expectedAsc); + expect( gqlResponse2.body.data[localCollectionStates].map((item: any) => { return parseInt(item.country_id.test_datetime_func.year.toString().slice(-1)); @@ -1166,7 +1215,9 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { .post(`/items/${localCollectionStates}`) .send(states) .set('Authorization', `Bearer ${common.USER.ADMIN.TOKEN}`); + const mutationKey = `create_${localCollectionStates}_items`; + const gqlResponse = await requestGraphQL(getUrl(vendor), false, common.USER.ADMIN.TOKEN, { mutation: { [mutationKey]: { @@ -1210,7 +1261,9 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { .post(`/items/${localCollectionStates}`) .send(states) .set('Authorization', `Bearer ${common.USER.ADMIN.TOKEN}`); + const mutationKey = `create_${localCollectionStates}_items`; + const gqlResponse = await requestGraphQL(getUrl(vendor), false, common.USER.ADMIN.TOKEN, { mutation: { [mutationKey]: { @@ -1225,12 +1278,14 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { // Assert expect(response.statusCode).toBe(400); expect(response.body.errors).toBeDefined(); + expect(response.body.errors[0].message).toBe( `Exceeded max batch mutation limit of ${config.envs[vendor].MAX_BATCH_MUTATION}.` ); expect(gqlResponse.statusCode).toBe(200); expect(gqlResponse.body.errors).toBeDefined(); + expect(gqlResponse.body.errors[0].message).toBe( `Exceeded max batch mutation limit of ${config.envs[vendor].MAX_BATCH_MUTATION}.` ); @@ -1254,6 +1309,7 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { for (let i = 0; i < count; i++) { const state: any = createState(pkType); state.name = `max_batch_mutation_${i.toString().padStart(3, '0')}`; + if (i >= countCreate) { state.country_id = createCountry(pkType); } @@ -1262,6 +1318,7 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { const state2: any = createState(pkType); state2.name = `max_batch_mutation_gql_${i.toString().padStart(3, '0')}`; + if (i >= countCreate) { state2.country_id = createCountry(pkType); } @@ -1342,6 +1399,7 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { for (let i = 0; i < count; i++) { const state: any = createState(pkType); state.name = `max_batch_mutation_${i.toString().padStart(3, '0')}`; + if (i >= countCreate) { state.country_id = createCountry(pkType); } @@ -1350,6 +1408,7 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { const state2: any = createState(pkType); state2.name = `max_batch_mutation_gql_${i.toString().padStart(3, '0')}`; + if (i >= countCreate) { state2.country_id = createCountry(pkType); } @@ -1409,12 +1468,14 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { // Assert expect(response.statusCode).toBe(400); expect(response.body.errors).toBeDefined(); + expect(response.body.errors[0].message).toBe( `Exceeded max batch mutation limit of ${config.envs[vendor].MAX_BATCH_MUTATION}.` ); expect(gqlResponse.statusCode).toBe(200); expect(gqlResponse.body.errors).toBeDefined(); + expect(gqlResponse.body.errors[0].message).toBe( `Exceeded max batch mutation limit of ${config.envs[vendor].MAX_BATCH_MUTATION}.` ); @@ -1519,12 +1580,14 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { // Assert expect(response.statusCode).toBe(400); expect(response.body.errors).toBeDefined(); + expect(response.body.errors[0].message).toBe( `Exceeded max batch mutation limit of ${config.envs[vendor].MAX_BATCH_MUTATION}.` ); expect(gqlResponse.statusCode).toBe(200); expect(gqlResponse.body.errors).toBeDefined(); + expect(gqlResponse.body.errors[0].message).toBe( `Exceeded max batch mutation limit of ${config.envs[vendor].MAX_BATCH_MUTATION}.` ); diff --git a/tests/blackbox/routes/items/no-relation.test.ts b/tests/blackbox/routes/items/no-relation.test.ts index 6a4a8db8f5..76f4c80741 100644 --- a/tests/blackbox/routes/items/no-relation.test.ts +++ b/tests/blackbox/routes/items/no-relation.test.ts @@ -119,6 +119,7 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { } }); }); + describe('returns an error when an invalid table is used', () => { it.each(vendors)('%s', async (vendor) => { // Action @@ -159,6 +160,7 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { collection: localCollectionArtists, item: createArtist(pkType), }); + const body = { name: 'Tommy Cash' }; // Action @@ -186,12 +188,14 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { // Assert expect(response.statusCode).toEqual(200); + expect(response.body.data).toMatchObject({ id: insertedArtist.id, name: 'Tommy Cash', }); expect(gqlResponse.statusCode).toBe(200); + expect(gqlResponse.body.data[mutationKey]).toEqual({ id: String(insertedArtist.id), name: 'updated', @@ -483,6 +487,7 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { // Assert expect(response.statusCode).toEqual(200); + for (let row = 0; row < response.body.data.length; row++) { expect(response.body.data[row]).toMatchObject({ name: 'Johnny Cash', @@ -492,6 +497,7 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { expect(response.body.data.length).toBe(keys.length); expect(gqlResponse.statusCode).toEqual(200); + for (let row = 0; row < gqlResponse.body.data[mutationKey].length; row++) { expect(gqlResponse.body.data[mutationKey][row]).toMatchObject({ name: 'updated', @@ -583,6 +589,7 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { // Assert expect(response.statusCode).toBe(200); expect(response.body.data.length).toBe(2); + for (const log of response.body.data) { expect(log.value).toBe('1'); } @@ -606,6 +613,7 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { // Assert expect(response.statusCode).toBe(200); expect(response.body.data.length).toBe(10); + for (const log of response.body.data) { expect(log.value).toBe('1'); } @@ -678,6 +686,7 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { } await CreateItem(vendor, { collection: localCollectionArtists, item: artists1 }); + for (let i = 0; i < count; i++) { const artist = createArtist(pkType); artist.company = artistCompany; @@ -749,6 +758,7 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { .set('Authorization', `Bearer ${common.USER.ADMIN.TOKEN}`); const queryKey = `${localCollectionArtists}_aggregated`; + const gqlResponse = await requestGraphQL(getUrl(vendor), false, common.USER.ADMIN.TOKEN, { query: { [queryKey]: { @@ -835,6 +845,7 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { const artistName = 'offset-limit-sort-test'; const artists = []; const expectedResultAsc = Array.from(Array(count).keys()).slice(offset, offset + limit); + const expectedResultDesc = Array.from(Array(count).keys()) .sort((v) => -v) .slice(offset, offset + limit); @@ -913,6 +924,7 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { expect(gqlResponseAsc.statusCode).toBe(200); expect(gqlResponseAsc.body.data[localCollectionArtists].length).toEqual(limit); + expect( gqlResponseAsc.body.data[localCollectionArtists].map((v: any) => parseInt(v.name.split('-')[0])) ).toEqual(expectedResultAsc); @@ -923,6 +935,7 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { expect(gqlResponseDesc.statusCode).toBe(200); expect(gqlResponseDesc.body.data[localCollectionArtists].length).toEqual(limit); + expect( gqlResponseDesc.body.data[localCollectionArtists].map((v: any) => parseInt(v.name.split('-')[0])) ).toEqual(expectedResultDesc); @@ -1055,6 +1068,7 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { const artistName = 'offset-aggregation-limit-sort-test'; const artists = []; const expectedResultAsc = Array.from(Array(count).keys()).slice(offset, offset + limit); + const expectedResultDesc = Array.from(Array(count).keys()) .sort((v) => -v) .slice(offset, offset + limit); @@ -1149,6 +1163,7 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { expect(gqlResponseAsc.statusCode).toBe(200); expect(gqlResponseAsc.body.data[queryKey].length).toEqual(limit); + expect(gqlResponseAsc.body.data[queryKey].map((v: any) => parseInt(v.group.name.split('-')[0]))).toEqual( expectedResultAsc ); @@ -1159,6 +1174,7 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { expect(gqlResponseDesc.statusCode).toBe(200); expect(gqlResponseDesc.body.data[queryKey].length).toEqual(limit); + expect(gqlResponseDesc.body.data[queryKey].map((v: any) => parseInt(v.group.name.split('-')[0]))).toEqual( expectedResultDesc ); @@ -1248,12 +1264,14 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { // Assert expect(response.statusCode).toBe(400); expect(response.body.errors).toBeDefined(); + expect(response.body.errors[0].message).toBe( `Exceeded max batch mutation limit of ${config.envs[vendor].MAX_BATCH_MUTATION}.` ); expect(gqlResponse.statusCode).toBe(200); expect(gqlResponse.body.errors).toBeDefined(); + expect(gqlResponse.body.errors[0].message).toBe( `Exceeded max batch mutation limit of ${config.envs[vendor].MAX_BATCH_MUTATION}.` ); @@ -1277,6 +1295,7 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { artists.push( await CreateItem(vendor, { collection: localCollectionArtists, item: createArtist(pkType) }) ); + artists2.push( await CreateItem(vendor, { collection: localCollectionArtists, item: createArtist(pkType) }) ); @@ -1325,6 +1344,7 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { artists.push( await CreateItem(vendor, { collection: localCollectionArtists, item: createArtist(pkType) }) ); + artists2.push( await CreateItem(vendor, { collection: localCollectionArtists, item: createArtist(pkType) }) ); @@ -1352,12 +1372,14 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { // Assert expect(response.statusCode).toBe(400); expect(response.body.errors).toBeDefined(); + expect(response.body.errors[0].message).toBe( `Exceeded max batch mutation limit of ${config.envs[vendor].MAX_BATCH_MUTATION}.` ); expect(gqlResponse.statusCode).toBe(200); expect(gqlResponse.body.errors).toBeDefined(); + expect(gqlResponse.body.errors[0].message).toBe( `Exceeded max batch mutation limit of ${config.envs[vendor].MAX_BATCH_MUTATION}.` ); @@ -1381,6 +1403,7 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { artistIDs.push( (await CreateItem(vendor, { collection: localCollectionArtists, item: createArtist(pkType) })).id ); + artistIDs2.push( (await CreateItem(vendor, { collection: localCollectionArtists, item: createArtist(pkType) })).id ); @@ -1430,6 +1453,7 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { artistIDs.push( (await CreateItem(vendor, { collection: localCollectionArtists, item: createArtist(pkType) })).id ); + artistIDs2.push( (await CreateItem(vendor, { collection: localCollectionArtists, item: createArtist(pkType) })).id ); @@ -1458,12 +1482,14 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { // Assert expect(response.statusCode).toBe(400); expect(response.body.errors).toBeDefined(); + expect(response.body.errors[0].message).toBe( `Exceeded max batch mutation limit of ${config.envs[vendor].MAX_BATCH_MUTATION}.` ); expect(gqlResponse.statusCode).toBe(200); expect(gqlResponse.body.errors).toBeDefined(); + expect(gqlResponse.body.errors[0].message).toBe( `Exceeded max batch mutation limit of ${config.envs[vendor].MAX_BATCH_MUTATION}.` ); @@ -1537,6 +1563,7 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { // Assert expect(response.statusCode).toBe(400); expect(response.body.errors).toBeDefined(); + expect(response.body.errors[0].message).toBe( `Exceeded max batch mutation limit of ${config.envs[vendor].MAX_BATCH_MUTATION}.` ); @@ -1562,12 +1589,15 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { artistIDs.push( (await CreateItem(vendor, { collection: localCollectionArtists, item: createArtist(pkType) })).id ); + artistIDs2.push( (await CreateItem(vendor, { collection: localCollectionArtists, item: createArtist(pkType) })).id ); + artistIDs3.push( (await CreateItem(vendor, { collection: localCollectionArtists, item: createArtist(pkType) })).id ); + artistIDs4.push( (await CreateItem(vendor, { collection: localCollectionArtists, item: createArtist(pkType) })).id ); @@ -1638,12 +1668,15 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { artistIDs.push( (await CreateItem(vendor, { collection: localCollectionArtists, item: createArtist(pkType) })).id ); + artistIDs2.push( (await CreateItem(vendor, { collection: localCollectionArtists, item: createArtist(pkType) })).id ); + artistIDs3.push( (await CreateItem(vendor, { collection: localCollectionArtists, item: createArtist(pkType) })).id ); + artistIDs4.push( (await CreateItem(vendor, { collection: localCollectionArtists, item: createArtist(pkType) })).id ); @@ -1687,24 +1720,28 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { // Assert expect(response.statusCode).toBe(400); expect(response.body.errors).toBeDefined(); + expect(response.body.errors[0].message).toBe( `Exceeded max batch mutation limit of ${config.envs[vendor].MAX_BATCH_MUTATION}.` ); expect(response2.statusCode).toBe(400); expect(response2.body.errors).toBeDefined(); + expect(response2.body.errors[0].message).toBe( `Exceeded max batch mutation limit of ${config.envs[vendor].MAX_BATCH_MUTATION}.` ); expect(gqlResponse.statusCode).toBe(200); expect(gqlResponse.body.errors).toBeDefined(); + expect(gqlResponse.body.errors[0].message).toBe( `Exceeded max batch mutation limit of ${config.envs[vendor].MAX_BATCH_MUTATION}.` ); expect(gqlResponse2.statusCode).toBe(200); expect(gqlResponse2.body.errors).toBeDefined(); + expect(gqlResponse2.body.errors[0].message).toBe( `Exceeded max batch mutation limit of ${config.envs[vendor].MAX_BATCH_MUTATION}.` ); @@ -1775,6 +1812,7 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { // Assert expect(response.statusCode).toBe(400); expect(response.body.errors).toBeDefined(); + expect(response.body.errors[0].message).toBe( `Exceeded max batch mutation limit of ${config.envs[vendor].MAX_BATCH_MUTATION}.` ); diff --git a/tests/blackbox/routes/items/o2m.test.ts b/tests/blackbox/routes/items/o2m.test.ts index 5164c4b344..c6a549b269 100644 --- a/tests/blackbox/routes/items/o2m.test.ts +++ b/tests/blackbox/routes/items/o2m.test.ts @@ -87,6 +87,7 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { collection: localCollectionCountries, item: createCountry(pkType), }); + const state = createState(pkType); state.country_id = insertedCountry.id; const insertedState = await CreateItem(vendor, { collection: localCollectionStates, item: state }); @@ -118,6 +119,7 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { expect(response.body.data).toMatchObject({ states: [insertedState.id] }); expect(gqlResponse.statusCode).toEqual(200); + expect(gqlResponse.body.data).toMatchObject({ [localCollectionCountries]: [{ states: [{ id: String(insertedState.id) }] }], }); @@ -132,6 +134,7 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { // Setup const country = createCountry(pkType); country.name = 'country-o2m-top-' + uuid(); + const insertedCountry = await CreateItem(vendor, { collection: localCollectionCountries, item: country, @@ -191,9 +194,11 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { expect(gqlResponse.statusCode).toBe(200); expect(gqlResponse.body.data[localCollectionCountries].length).toBe(1); + expect(gqlResponse.body.data[localCollectionCountries][0]).toMatchObject({ id: String(insertedCountry.id), }); + expect(gqlResponse2.statusCode).toBe(200); expect(gqlResponse.body.data).toEqual(gqlResponse2.body.data); }); @@ -204,10 +209,12 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { // Setup const country = createCountry(pkType); country.name = 'country-o2m-' + uuid(); + const insertedCountry = await CreateItem(vendor, { collection: localCollectionCountries, item: country, }); + const state = createState(pkType); state.name = 'state-o2m-' + uuid(); state.country_id = insertedCountry.id; @@ -263,9 +270,11 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { expect(gqlResponse.statusCode).toBe(200); expect(gqlResponse.body.data[localCollectionCountries].length).toBe(1); + expect(gqlResponse.body.data[localCollectionCountries][0]).toMatchObject({ id: String(insertedCountry.id), }); + expect(gqlResponse2.statusCode).toBe(200); expect(gqlResponse.body.data).toEqual(gqlResponse2.body.data); }); @@ -637,6 +646,7 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { expect(gqlResponse.statusCode).toEqual(200); expect(gqlResponse.body.data[localCollectionCountries].length).toBe(5); expect(gqlResponse2.statusCode).toEqual(200); + expect(gqlResponse.body.data[localCollectionCountries]).toEqual( gqlResponse2.body.data[localCollectionCountries].reverse() ); @@ -702,11 +712,13 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { expect(response.body.data.length).toBe(expectedLength); expect(response2.statusCode).toEqual(200); expect(response.body.data).not.toEqual(response2.body.data); + expect( response.body.data.map((item: any) => { return parseInt(item.name.slice(-1)); }) ).toEqual(expectedAsc); + expect( response2.body.data.map((item: any) => { return parseInt(item.name.slice(-1)); @@ -716,14 +728,17 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { expect(gqlResponse.statusCode).toEqual(200); expect(gqlResponse.body.data[localCollectionCountries].length).toBe(expectedLength); expect(gqlResponse2.statusCode).toEqual(200); + expect(gqlResponse.body.data[localCollectionCountries]).not.toEqual( gqlResponse2.body.data[localCollectionCountries] ); + expect( gqlResponse.body.data[localCollectionCountries].map((item: any) => { return parseInt(item.name.slice(-1)); }) ).toEqual(expectedAsc); + expect( gqlResponse2.body.data[localCollectionCountries].map((item: any) => { return parseInt(item.name.slice(-1)); @@ -742,10 +757,12 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { for (const val of sortValues) { const country = createCountry(pkType); country.name = 'country-o2m-sort-' + uuid(); + const insertedCountry = await CreateItem(vendor, { collection: localCollectionCountries, item: country, }); + const state = createState(pkType); state.name = 'state-o2m-sort-' + val; state.country_id = insertedCountry.id; @@ -819,6 +836,7 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { } lastIndex = -1; + for (const item of gqlResponse2.body.data[localCollectionCountries].reverse()) { const foundIndex = findIndex(gqlResponse.body.data[localCollectionCountries], { id: item.id }); if (foundIndex === -1) continue; @@ -836,6 +854,7 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { expect(response.body.data.length).toBe(5); expect(response.body.data).toEqual(response2.body.data.reverse()); expect(gqlResponse.body.data[localCollectionCountries].length).toBe(5); + expect(gqlResponse.body.data[localCollectionCountries]).toEqual( gqlResponse2.body.data[localCollectionCountries].reverse() ); @@ -953,11 +972,13 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { expect(response.body.data.length).toBe(expectedLength); expect(response.body.data).not.toEqual(response2.body.data); + expect( response.body.data.map((item: any) => { return parseInt(item.states[0].name.slice(-1)); }) ).toEqual(expectedAsc); + expect( response2.body.data.map((item: any) => { return parseInt(item.states[0].name.slice(-1)); @@ -965,14 +986,17 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { ).toEqual(expectedDesc); expect(gqlResponse.body.data[localCollectionCountries].length).toBe(expectedLength); + expect(gqlResponse.body.data[localCollectionCountries]).not.toEqual( gqlResponse2.body.data[localCollectionCountries] ); + expect( gqlResponse.body.data[localCollectionCountries].map((item: any) => { return parseInt(item.states[0].name.slice(-1)); }) ).toEqual(expectedAsc); + expect( gqlResponse2.body.data[localCollectionCountries].map((item: any) => { return parseInt(item.states[0].name.slice(-1)); @@ -994,9 +1018,11 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { for (const val of sortValues) { const country = createCountry(pkType); country.name = 'country-o2m-top-sort-fn-' + uuid(); + country.test_datetime = new Date(new Date().setFullYear(parseInt(`202${val}`))) .toISOString() .slice(0, 19); + countries.push(country); } @@ -1059,6 +1085,7 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { expect(gqlResponse.statusCode).toEqual(200); expect(gqlResponse.body.data[localCollectionCountries].length).toBe(5); expect(gqlResponse2.statusCode).toEqual(200); + expect(gqlResponse.body.data[localCollectionCountries]).toEqual( gqlResponse2.body.data[localCollectionCountries].reverse() ); @@ -1130,11 +1157,13 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { expect(response.body.data.length).toBe(expectedLength); expect(response2.statusCode).toEqual(200); expect(response.body.data).not.toEqual(response2.body.data); + expect( response.body.data.map((item: any) => { return parseInt(item.test_datetime_year.toString().slice(-1)); }) ).toEqual(expectedAsc); + expect( response2.body.data.map((item: any) => { return parseInt(item.test_datetime_year.toString().slice(-1)); @@ -1144,14 +1173,17 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { expect(gqlResponse.statusCode).toEqual(200); expect(gqlResponse.body.data[localCollectionCountries].length).toBe(expectedLength); expect(gqlResponse2.statusCode).toEqual(200); + expect(gqlResponse.body.data[localCollectionCountries]).not.toEqual( gqlResponse2.body.data[localCollectionCountries] ); + expect( gqlResponse.body.data[localCollectionCountries].map((item: any) => { return parseInt(item.test_datetime_func.year.toString().slice(-1)); }) ).toEqual(expectedAsc); + expect( gqlResponse2.body.data[localCollectionCountries].map((item: any) => { return parseInt(item.test_datetime_func.year.toString().slice(-1)); @@ -1170,15 +1202,19 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { for (const val of sortValues) { const country = createCountry(pkType); country.name = 'country-o2m-sort-fn-' + uuid(); + const insertedCountry = await CreateItem(vendor, { collection: localCollectionCountries, item: country, }); + const state = createState(pkType); state.name = 'state-o2m-sort-fn-' + uuid(); + state.test_datetime = new Date(new Date().setFullYear(parseInt(`202${val}`))) .toISOString() .slice(0, 19); + state.country_id = insertedCountry.id; await CreateItem(vendor, { collection: localCollectionStates, item: state }); } @@ -1250,6 +1286,7 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { } lastIndex = -1; + for (const item of gqlResponse2.body.data[localCollectionCountries].reverse()) { const foundIndex = findIndex(gqlResponse.body.data[localCollectionCountries], { id: item.id }); if (foundIndex === -1) continue; @@ -1267,6 +1304,7 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { expect(response.body.data.length).toBe(5); expect(response.body.data).toEqual(response2.body.data.reverse()); expect(gqlResponse.body.data[localCollectionCountries].length).toBe(5); + expect(gqlResponse.body.data[localCollectionCountries]).toEqual( gqlResponse2.body.data[localCollectionCountries].reverse() ); @@ -1391,11 +1429,13 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { expect(response.body.data.length).toBe(expectedLength); expect(response.body.data).not.toEqual(response2.body.data); + expect( response.body.data.map((item: any) => { return parseInt(item.states[0].test_datetime_year.toString().slice(-1)); }) ).toEqual(expectedAsc); + expect( response2.body.data.map((item: any) => { return parseInt(item.states[0].test_datetime_year.toString().slice(-1)); @@ -1403,14 +1443,17 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { ).toEqual(expectedDesc); expect(gqlResponse.body.data[localCollectionCountries].length).toBe(expectedLength); + expect(gqlResponse.body.data[localCollectionCountries]).not.toEqual( gqlResponse2.body.data[localCollectionCountries] ); + expect( gqlResponse.body.data[localCollectionCountries].map((item: any) => { return parseInt(item.states[0].test_datetime_func.year.toString().slice(-1)); }) ).toEqual(expectedAsc); + expect( gqlResponse2.body.data[localCollectionCountries].map((item: any) => { return parseInt(item.states[0].test_datetime_func.year.toString().slice(-1)); @@ -1721,6 +1764,7 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { const country = createCountry(pkType); const states = []; const expectedResultAsc = Array.from(Array(count).keys()).slice(offset, offset + limit); + const expectedResultDesc = Array.from(Array(count).keys()) .sort((v) => -v) .slice(offset, offset + limit); @@ -1824,6 +1868,7 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { expect(responseAsc.statusCode).toBe(200); expect(responseAsc.body.data.length).toBe(1); expect(responseAsc.body.data[0].states.length).toBe(limit); + expect(responseAsc.body.data[0].states.map((v: any) => parseInt(v.name.split('-')[0]))).toEqual( expectedResultAsc ); @@ -1831,6 +1876,7 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { expect(gqlResponseAsc.statusCode).toBe(200); expect(gqlResponseAsc.body.data[localCollectionCountries].length).toEqual(1); expect(gqlResponseAsc.body.data[localCollectionCountries][0].states.length).toEqual(limit); + expect( gqlResponseAsc.body.data[localCollectionCountries][0].states.map((v: any) => parseInt(v.name.split('-')[0])) ).toEqual(expectedResultAsc); @@ -1838,6 +1884,7 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { expect(responseDesc.statusCode).toBe(200); expect(responseDesc.body.data.length).toBe(1); expect(responseDesc.body.data[0].states.length).toBe(limit); + expect(responseDesc.body.data[0].states.map((v: any) => parseInt(v.name.split('-')[0]))).toEqual( expectedResultAsc ); @@ -1845,6 +1892,7 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { expect(gqlResponseDesc.statusCode).toBe(200); expect(gqlResponseDesc.body.data[localCollectionCountries].length).toEqual(1); expect(gqlResponseDesc.body.data[localCollectionCountries][0].states.length).toEqual(limit); + expect( gqlResponseDesc.body.data[localCollectionCountries][0].states.map((v: any) => parseInt(v.name.split('-')[0]) @@ -1960,12 +2008,14 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { // Assert expect(response.statusCode).toBe(400); expect(response.body.errors).toBeDefined(); + expect(response.body.errors[0].message).toBe( `Exceeded max batch mutation limit of ${config.envs[vendor].MAX_BATCH_MUTATION}.` ); expect(gqlResponse.statusCode).toBe(200); expect(gqlResponse.body.errors).toBeDefined(); + expect(gqlResponse.body.errors[0].message).toBe( `Exceeded max batch mutation limit of ${config.envs[vendor].MAX_BATCH_MUTATION}.` ); @@ -1988,11 +2038,13 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { for (let i = 0; i < count; i++) { countries.push(createCountry(pkType)); + countries[i].states = Array(countNested) .fill(0) .map(() => createState(pkType)); countries2.push(createCountry(pkType)); + countries2[i].states = Array(countNested) .fill(0) .map(() => createState(pkType)); @@ -2046,11 +2098,13 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { for (let i = 0; i < count; i++) { countries.push(createCountry(pkType)); + countries[i].states = Array(countNested) .fill(0) .map(() => createState(pkType)); countries2.push(createCountry(pkType)); + countries2[i].states = Array(countNested) .fill(0) .map(() => createState(pkType)); @@ -2078,12 +2132,14 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { // Assert expect(response.statusCode).toBe(400); expect(response.body.errors).toBeDefined(); + expect(response.body.errors[0].message).toBe( `Exceeded max batch mutation limit of ${config.envs[vendor].MAX_BATCH_MUTATION}.` ); expect(gqlResponse.statusCode).toBe(200); expect(gqlResponse.body.errors).toBeDefined(); + expect(gqlResponse.body.errors[0].message).toBe( `Exceeded max batch mutation limit of ${config.envs[vendor].MAX_BATCH_MUTATION}.` ); @@ -2108,17 +2164,21 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { for (let i = 0; i < count; i++) { const country: any = createCountry(pkType); + country.states = Array(countUpdate + countDelete) .fill(0) .map(() => createState(pkType)); + countriesID.push( (await CreateItem(vendor, { collection: localCollectionCountries, item: country })).id ); const country2: any = createCountry(pkType); + country2.states = Array(countUpdate + countDelete) .fill(0) .map(() => createState(pkType)); + countriesID2.push( (await CreateItem(vendor, { collection: localCollectionCountries, item: country2 })).id ); @@ -2138,6 +2198,7 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { for (const country of countries) { const states = country.states; + country.states = { create: Array(countCreate) .fill(0) @@ -2206,17 +2267,21 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { for (let i = 0; i < count; i++) { const country: any = createCountry(pkType); + country.states = Array(countUpdate + countDelete) .fill(0) .map(() => createState(pkType)); + countriesID.push( (await CreateItem(vendor, { collection: localCollectionCountries, item: country })).id ); const country2: any = createCountry(pkType); + country2.states = Array(countUpdate + countDelete) .fill(0) .map(() => createState(pkType)); + countriesID2.push( (await CreateItem(vendor, { collection: localCollectionCountries, item: country2 })).id ); @@ -2236,6 +2301,7 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { for (const country of countries) { const states = country.states; + country.states = { create: Array(countCreate) .fill(0) @@ -2276,12 +2342,14 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { // Assert expect(response.statusCode).toBe(400); expect(response.body.errors).toBeDefined(); + expect(response.body.errors[0].message).toBe( `Exceeded max batch mutation limit of ${config.envs[vendor].MAX_BATCH_MUTATION}.` ); expect(gqlResponse.statusCode).toBe(200); expect(gqlResponse.body.errors).toBeDefined(); + expect(gqlResponse.body.errors[0].message).toBe( `Exceeded max batch mutation limit of ${config.envs[vendor].MAX_BATCH_MUTATION}.` ); diff --git a/tests/blackbox/routes/items/seed-all-field-types.ts b/tests/blackbox/routes/items/seed-all-field-types.ts index bef860be2a..766a026522 100644 --- a/tests/blackbox/routes/items/seed-all-field-types.ts +++ b/tests/blackbox/routes/items/seed-all-field-types.ts @@ -8,6 +8,7 @@ export function getTestsAllTypesSchema(): TestsFieldSchema { for (const key of Object.keys(SeedFunctions.generateValues)) { const field = `test_${key.toLowerCase()}`; + fieldSchema[field] = { field: field, type: key, @@ -89,6 +90,7 @@ export const seedAllFieldTypesValues = async (vendor: string, collection: string ? String(fieldSchema[key].possibleValues[i]) : fieldSchema[key].possibleValues[i], }); + generatedStringIdCounter++; } } else { @@ -136,6 +138,7 @@ export const seedO2MAliasAllFieldTypesValues = async ( quantity: 1, seed: `id-${generatedStringIdCounter}`, })[0]; + generatedStringIdCounter++; } @@ -185,6 +188,7 @@ export const seedM2MAliasAllFieldTypesValues = async ( const collectionItems = await ReadItem(vendor, { collection: collection, fields: ['*'] }); const otherCollectionItems = await ReadItem(vendor, { collection: otherCollection, fields: ['*'] }); const newCollectionKeys = collectionItems.map((i: any) => i.id).filter((i: any) => !possibleKeys.includes(i)); + const newOtherCollectionKeys = otherCollectionItems .map((i: any) => i.id) .filter((i: any) => !otherPossibleKeys.includes(i)); @@ -219,6 +223,7 @@ export const seedM2AAliasAllFieldTypesValues = async ( const collectionItems = await ReadItem(vendor, { collection: collection, fields: ['id'] }); const otherCollectionItems = await ReadItem(vendor, { collection: relatedCollection, fields: ['id'] }); const newCollectionKeys = collectionItems.map((i: any) => i.id).filter((i: any) => !possibleKeys.includes(i)); + const newOtherCollectionKeys = otherCollectionItems .map((i: any) => i.id) .filter((i: any) => !otherPossibleKeys.includes(i)); diff --git a/tests/blackbox/routes/items/seed-relational-fields.ts b/tests/blackbox/routes/items/seed-relational-fields.ts index d3b8a0ed6d..1c07553b0a 100644 --- a/tests/blackbox/routes/items/seed-relational-fields.ts +++ b/tests/blackbox/routes/items/seed-relational-fields.ts @@ -39,6 +39,7 @@ export const seedRelationalFields = async ( })[0], [testsSchema[key].field]: pk, }); + generatedStringIdCounter++; } } else { diff --git a/tests/blackbox/routes/items/singleton.test.ts b/tests/blackbox/routes/items/singleton.test.ts index c9f98c7095..22179f28ef 100644 --- a/tests/blackbox/routes/items/singleton.test.ts +++ b/tests/blackbox/routes/items/singleton.test.ts @@ -44,6 +44,7 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { expect(response.body.data).toMatchObject({ name: 'parent', o2m: expect.anything() }); expect(gqlResponse.statusCode).toEqual(200); + expect(gqlResponse.body.data).toMatchObject({ [localCollectionSingleton]: { name: 'parent', o2m: expect.anything() }, }); @@ -114,11 +115,13 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { // Assert expect(response.statusCode).toEqual(200); + expect(response.body.data).toMatchObject({ name: newName, }); expect(gqlResponse.statusCode).toBe(200); + expect(gqlResponse.body.data[mutationKey]).toEqual({ name: newName2, }); @@ -214,6 +217,7 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { expect(response.statusCode).toEqual(200); expect(response.body.data.o2m).toBeDefined(); expect(response.body.data.o2m.length).toBe(2); + expect(response.body.data.o2m.map((item: any) => item.name)).toEqual( expect.arrayContaining([o2mNameNew, o2mNameUpdated]) ); @@ -221,6 +225,7 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => { expect(gqlResponse.statusCode).toEqual(200); expect(gqlResponse.body.data[mutationKey].o2m).toBeDefined(); expect(gqlResponse.body.data[mutationKey].o2m.length).toBe(3); + expect(gqlResponse.body.data[mutationKey].o2m.map((item: any) => item.name)).toEqual( expect.arrayContaining([o2mNameNew2, o2mNameUpdated2]) ); diff --git a/tests/blackbox/routes/schema/schema.test.ts b/tests/blackbox/routes/schema/schema.test.ts index 9a6df235fd..0e5be5f6ff 100644 --- a/tests/blackbox/routes/schema/schema.test.ts +++ b/tests/blackbox/routes/schema/schema.test.ts @@ -49,9 +49,11 @@ describe('Schema Snapshots', () => { const response = await request(getUrl(vendor)) .get('/schema/snapshot') .set('Authorization', `Bearer ${common.USER.APP_ACCESS.TOKEN}`); + const response2 = await request(getUrl(vendor)) .get('/schema/snapshot') .set('Authorization', `Bearer ${common.USER.API_ONLY.TOKEN}`); + const response3 = await request(getUrl(vendor)) .get('/schema/snapshot') .set('Authorization', `Bearer ${common.USER.NO_ROLE.TOKEN}`); @@ -144,6 +146,7 @@ describe('Schema Snapshots', () => { it.each(vendors)('%s', async (vendor) => { // Action const currentVendor = vendor.replace(/[0-9]/g, ''); + const response = await request(getUrl(vendor)) .post('/schema/diff') .send({ @@ -156,6 +159,7 @@ describe('Schema Snapshots', () => { }) .set('Content-type', 'application/json') .set('Authorization', `Bearer ${common.USER.APP_ACCESS.TOKEN}`); + const response2 = await request(getUrl(vendor)) .post('/schema/diff') .send({ @@ -168,6 +172,7 @@ describe('Schema Snapshots', () => { }) .set('Content-type', 'application/json') .set('Authorization', `Bearer ${common.USER.API_ONLY.TOKEN}`); + const response3 = await request(getUrl(vendor)) .post('/schema/diff') .send({ @@ -212,7 +217,9 @@ describe('Schema Snapshots', () => { // Setup const collectionsCount = snapshotsCacheOriginal[vendor].collections.length - snapshotsCacheEmpty[vendor].collections.length; + const fieldsCount = snapshotsCacheOriginal[vendor].fields.length - snapshotsCacheEmpty[vendor].fields.length; + const relationsCount = snapshotsCacheOriginal[vendor].relations.length - snapshotsCacheEmpty[vendor].relations.length; @@ -243,11 +250,13 @@ describe('Schema Snapshots', () => { .send({ data: true }) .set('Content-type', 'application/json') .set('Authorization', `Bearer ${common.USER.APP_ACCESS.TOKEN}`); + const response2 = await request(getUrl(vendor)) .post('/schema/apply') .send({ data: true }) .set('Content-type', 'application/json') .set('Authorization', `Bearer ${common.USER.API_ONLY.TOKEN}`); + const response3 = await request(getUrl(vendor)) .post('/schema/apply') .send({ data: true }) @@ -606,7 +615,9 @@ describe('Schema Snapshots', () => { o2m: [{ id: pkType === 'string' ? uuid() : undefined }], }, }); + childrenIDs[pkType] = { id: item.id, m2o_id: item.all_id, o2m_id: item.o2m[0] }; + await common.CreateFieldM2O(vendor, { collection: `${collectionAll}_${pkType}`, field: tempRelationalField, @@ -629,6 +640,7 @@ describe('Schema Snapshots', () => { // Assert expect(response.statusCode).toEqual(204); + for (const pkType of PRIMARY_KEY_TYPES) { const item = ( await request(getUrl(vendor)) @@ -775,36 +787,43 @@ async function assertCollectionsDeleted(vendor: string, pkType: PrimaryKeyType) .get(`/items/${localJunctionSelfM2M}`) .set('Authorization', `Bearer ${common.USER.ADMIN.TOKEN}`) ); + responses.push( await request(getUrl(vendor)) .get(`/items/${localCollectionSelf}`) .set('Authorization', `Bearer ${common.USER.ADMIN.TOKEN}`) ); + responses.push( await request(getUrl(vendor)) .get(`/items/${localCollectionO2M2}`) .set('Authorization', `Bearer ${common.USER.ADMIN.TOKEN}`) ); + responses.push( await request(getUrl(vendor)) .get(`/items/${localCollectionO2M}`) .set('Authorization', `Bearer ${common.USER.ADMIN.TOKEN}`) ); + responses.push( await request(getUrl(vendor)) .get(`/items/${localJunctionM2AM2A2}`) .set('Authorization', `Bearer ${common.USER.ADMIN.TOKEN}`) ); + responses.push( await request(getUrl(vendor)) .get(`/items/${localJunctionAllM2A}`) .set('Authorization', `Bearer ${common.USER.ADMIN.TOKEN}`) ); + responses.push( await request(getUrl(vendor)) .get(`/items/${localCollectionM2A2}`) .set('Authorization', `Bearer ${common.USER.ADMIN.TOKEN}`) ); + responses.push( await request(getUrl(vendor)) .get(`/items/${localCollectionM2A}`) @@ -816,31 +835,37 @@ async function assertCollectionsDeleted(vendor: string, pkType: PrimaryKeyType) .get(`/items/${localJunctionM2MM2M2}`) .set('Authorization', `Bearer ${common.USER.ADMIN.TOKEN}`) ); + responses.push( await request(getUrl(vendor)) .get(`/items/${localJunctionAllM2M}`) .set('Authorization', `Bearer ${common.USER.ADMIN.TOKEN}`) ); + responses.push( await request(getUrl(vendor)) .get(`/items/${localCollectionM2M2}`) .set('Authorization', `Bearer ${common.USER.ADMIN.TOKEN}`) ); + responses.push( await request(getUrl(vendor)) .get(`/items/${localCollectionM2M}`) .set('Authorization', `Bearer ${common.USER.ADMIN.TOKEN}`) ); + responses.push( await request(getUrl(vendor)) .get(`/items/${localCollectionAll}`) .set('Authorization', `Bearer ${common.USER.ADMIN.TOKEN}`) ); + responses.push( await request(getUrl(vendor)) .get(`/items/${localCollectionM2O}`) .set('Authorization', `Bearer ${common.USER.ADMIN.TOKEN}`) ); + responses.push( await request(getUrl(vendor)) .get(`/items/${localCollectionM2O2}`) diff --git a/tests/blackbox/schema/timezone/timezone-changed-node-tz-america.test.ts b/tests/blackbox/schema/timezone/timezone-changed-node-tz-america.test.ts index d095f30429..a957e2e777 100644 --- a/tests/blackbox/schema/timezone/timezone-changed-node-tz-america.test.ts +++ b/tests/blackbox/schema/timezone/timezone-changed-node-tz-america.test.ts @@ -42,6 +42,7 @@ describe('schema', () => { for (let i = 0; i < 24; i++) { const hour = i < 10 ? '0' + i : String(i); + sampleDates.push( { date: `2022-01-05`, @@ -79,9 +80,11 @@ describe('schema', () => { let serverOutput = ''; server.stdout.on('data', (data) => (serverOutput += data.toString())); + server.on('exit', (code) => { if (code !== null) throw new Error(`Directus-${vendor} server failed: \n ${serverOutput}`); }); + promises.push(awaitDirectusConnection(newServerPort)); } @@ -129,34 +132,45 @@ describe('schema', () => { expect(responseObj.date).toBe(newDateString.substring(0, 10)); expect(responseObj.time).toBe(sampleDates[index]!.time); expect(responseObj.datetime).toBe(newDateTimeString.substring(0, 19)); + expect(responseObj.timestamp.substring(0, 19)).toBe( new Date(sampleDates[index]!.timestamp).toISOString().substring(0, 19) ); + const dateCreated = new Date(responseObj.date_created); + expect(dateCreated.toISOString()).toBe( validateDateDifference(currentTimestamp, dateCreated, 200000).toISOString() ); + continue; } else if (vendor === 'oracle') { expect(responseObj.date).toBe(sampleDates[index]!.date); expect(responseObj.datetime).toBe(sampleDates[index]!.datetime); + expect(responseObj.timestamp.substring(0, 19)).toBe( new Date(sampleDates[index]!.timestamp).toISOString().substring(0, 19) ); + const dateCreated = new Date(responseObj.date_created); + expect(dateCreated.toISOString()).toBe( validateDateDifference(currentTimestamp, dateCreated, 200000).toISOString() ); + continue; } expect(responseObj.date).toBe(sampleDates[index]!.date); expect(responseObj.time).toBe(sampleDates[index]!.time); expect(responseObj.datetime).toBe(sampleDates[index]!.datetime); + expect(responseObj.timestamp.substring(0, 19)).toBe( new Date(sampleDates[index]!.timestamp).toISOString().substring(0, 19) ); + const dateCreated = new Date(responseObj.date_created); + expect(dateCreated.toISOString()).toBe( validateDateDifference(currentTimestamp, dateCreated, 200000).toISOString() ); @@ -201,10 +215,13 @@ describe('schema', () => { if (vendor === 'oracle') { expect(responseObj.date).toBe(sampleDates[index]!.date); expect(responseObj.datetime).toBe(sampleDates[index]!.datetime); + expect(responseObj.timestamp.substring(0, 19)).toBe( new Date(sampleDates[index]!.timestamp).toISOString().substring(0, 19) ); + const dateCreated = new Date(responseObj.date_created); + expect(dateCreated.toISOString()).toBe( validateDateDifference( insertionStartTimestamp, @@ -212,6 +229,7 @@ describe('schema', () => { insertionEndTimestamp.getTime() - insertionStartTimestamp.getTime() ).toISOString() ); + expect(responseObj.date_updated).toBeNull(); continue; } @@ -219,10 +237,13 @@ describe('schema', () => { expect(responseObj.date).toBe(sampleDates[index]!.date); expect(responseObj.time).toBe(sampleDates[index]!.time); expect(responseObj.datetime).toBe(sampleDates[index]!.datetime); + expect(responseObj.timestamp.substring(0, 19)).toBe( new Date(sampleDates[index]!.timestamp).toISOString().substring(0, 19) ); + const dateCreated = new Date(responseObj.date_created); + expect(dateCreated.toISOString()).toBe( validateDateDifference( insertionStartTimestamp, @@ -230,6 +251,7 @@ describe('schema', () => { insertionEndTimestamp.getTime() - insertionStartTimestamp.getTime() + 1000 ).toISOString() ); + expect(responseObj.date_updated).toBeNull(); } }, @@ -276,6 +298,7 @@ describe('schema', () => { const dateCreated = new Date(responseObj.date_created); const dateUpdated = new Date(responseObj.date_updated); expect(dateUpdated.toISOString()).not.toBe(dateCreated.toISOString()); + expect(dateUpdated.toISOString()).toBe( validateDateDifference( updateStartTimestamp, diff --git a/tests/blackbox/schema/timezone/timezone-changed-node-tz-asia.test.ts b/tests/blackbox/schema/timezone/timezone-changed-node-tz-asia.test.ts index 1d6f77c499..49dcf3357d 100644 --- a/tests/blackbox/schema/timezone/timezone-changed-node-tz-asia.test.ts +++ b/tests/blackbox/schema/timezone/timezone-changed-node-tz-asia.test.ts @@ -39,6 +39,7 @@ describe('schema', () => { for (let i = 0; i < 24; i++) { const hour = i < 10 ? '0' + i : String(i); + sampleDates.push( { date: `2022-01-05`, @@ -76,9 +77,11 @@ describe('schema', () => { let serverOutput = ''; server.stdout.on('data', (data) => (serverOutput += data.toString())); + server.on('exit', (code) => { if (code !== null) throw new Error(`Directus-${vendor} server failed: \n ${serverOutput}`); }); + promises.push(awaitDirectusConnection(newServerPort)); } @@ -126,34 +129,45 @@ describe('schema', () => { expect(responseObj.date).toBe(newDateString.substring(0, 10)); expect(responseObj.time).toBe(sampleDates[index]!.time); expect(responseObj.datetime).toBe(newDateTimeString.substring(0, 19)); + expect(responseObj.timestamp.substring(0, 19)).toBe( new Date(sampleDates[index]!.timestamp).toISOString().substring(0, 19) ); + const dateCreated = new Date(responseObj.date_created); + expect(dateCreated.toISOString()).toBe( validateDateDifference(currentTimestamp, dateCreated, 400000).toISOString() ); + continue; } else if (vendor === 'oracle') { expect(responseObj.date).toBe(sampleDates[index]!.date); expect(responseObj.datetime).toBe(sampleDates[index]!.datetime); + expect(responseObj.timestamp.substring(0, 19)).toBe( new Date(sampleDates[index]!.timestamp).toISOString().substring(0, 19) ); + const dateCreated = new Date(responseObj.date_created); + expect(dateCreated.toISOString()).toBe( validateDateDifference(currentTimestamp, dateCreated, 400000).toISOString() ); + continue; } expect(responseObj.date).toBe(sampleDates[index]!.date); expect(responseObj.time).toBe(sampleDates[index]!.time); expect(responseObj.datetime).toBe(sampleDates[index]!.datetime); + expect(responseObj.timestamp.substring(0, 19)).toBe( new Date(sampleDates[index]!.timestamp).toISOString().substring(0, 19) ); + const dateCreated = new Date(responseObj.date_created); + expect(dateCreated.toISOString()).toBe( validateDateDifference(currentTimestamp, dateCreated, 200000).toISOString() ); @@ -186,34 +200,45 @@ describe('schema', () => { expect(responseObj.date).toBe(newDateString.substring(0, 10)); expect(responseObj.time).toBe(sampleDates[index]!.time); expect(responseObj.datetime).toBe(newDateTimeString.substring(0, 19)); + expect(responseObj.timestamp.substring(0, 19)).toBe( new Date(sampleDates[index]!.timestamp).toISOString().substring(0, 19) ); + const dateCreated = new Date(responseObj.date_created); + expect(dateCreated.toISOString()).toBe( validateDateDifference(currentTimestamp, dateCreated, 200000).toISOString() ); + continue; } else if (vendor === 'oracle') { expect(responseObj.date).toBe(sampleDates[index]!.date); expect(responseObj.datetime).toBe(sampleDates[index]!.datetime); + expect(responseObj.timestamp.substring(0, 19)).toBe( new Date(sampleDates[index]!.timestamp).toISOString().substring(0, 19) ); + const dateCreated = new Date(responseObj.date_created); + expect(dateCreated.toISOString()).toBe( validateDateDifference(currentTimestamp, dateCreated, 200000).toISOString() ); + continue; } expect(responseObj.date).toBe(sampleDates[index]!.date); expect(responseObj.time).toBe(sampleDates[index]!.time); expect(responseObj.datetime).toBe(sampleDates[index]!.datetime); + expect(responseObj.timestamp.substring(0, 19)).toBe( new Date(sampleDates[index]!.timestamp).toISOString().substring(0, 19) ); + const dateCreated = new Date(responseObj.date_created); + expect(dateCreated.toISOString()).toBe( validateDateDifference(currentTimestamp, dateCreated, 200000).toISOString() ); @@ -258,10 +283,13 @@ describe('schema', () => { if (vendor === 'oracle') { expect(responseObj.date).toBe(sampleDates[index]!.date); expect(responseObj.datetime).toBe(sampleDates[index]!.datetime); + expect(responseObj.timestamp.substring(0, 19)).toBe( new Date(sampleDates[index]!.timestamp).toISOString().substring(0, 19) ); + const dateCreated = new Date(responseObj.date_created); + expect(dateCreated.toISOString()).toBe( validateDateDifference( insertionStartTimestamp, @@ -269,6 +297,7 @@ describe('schema', () => { insertionEndTimestamp.getTime() - insertionStartTimestamp.getTime() ).toISOString() ); + expect(responseObj.date_updated).toBeNull(); continue; } @@ -276,10 +305,13 @@ describe('schema', () => { expect(responseObj.date).toBe(sampleDates[index]!.date); expect(responseObj.time).toBe(sampleDates[index]!.time); expect(responseObj.datetime).toBe(sampleDates[index]!.datetime); + expect(responseObj.timestamp.substring(0, 19)).toBe( new Date(sampleDates[index]!.timestamp).toISOString().substring(0, 19) ); + const dateCreated = new Date(responseObj.date_created); + expect(dateCreated.toISOString()).toBe( validateDateDifference( insertionStartTimestamp, @@ -287,6 +319,7 @@ describe('schema', () => { insertionEndTimestamp.getTime() - insertionStartTimestamp.getTime() + 1000 ).toISOString() ); + expect(responseObj.date_updated).toBeNull(); } }, @@ -333,6 +366,7 @@ describe('schema', () => { const dateCreated = new Date(responseObj.date_created); const dateUpdated = new Date(responseObj.date_updated); expect(dateUpdated.toISOString()).not.toBe(dateCreated.toISOString()); + expect(dateUpdated.toISOString()).toBe( validateDateDifference( updateStartTimestamp, diff --git a/tests/blackbox/schema/timezone/timezone.test.ts b/tests/blackbox/schema/timezone/timezone.test.ts index d5c5f47875..9e2546867f 100644 --- a/tests/blackbox/schema/timezone/timezone.test.ts +++ b/tests/blackbox/schema/timezone/timezone.test.ts @@ -26,6 +26,7 @@ describe('schema', () => { for (let i = 0; i < 24; i++) { const hour = i < 10 ? '0' + i : String(i); + sampleDates.push( { date: `2022-01-05`, @@ -63,6 +64,7 @@ describe('schema', () => { schema: {}, meta: {}, }; + await CreateCollection(vendor, tableOptions); const fieldOptions = { @@ -72,6 +74,7 @@ describe('schema', () => { schema: {}, type: 'date', }; + await CreateField(vendor, fieldOptions); fieldOptions.field = 'time'; @@ -137,6 +140,7 @@ describe('schema', () => { .set('Authorization', `Bearer ${common.USER.ADMIN.TOKEN}`) .expect('Content-Type', /application\/json/) .expect(200); + await request(getUrl(vendor)) .patch(`/fields/${collectionName}/date_created`) .send({ @@ -158,6 +162,7 @@ describe('schema', () => { .set('Authorization', `Bearer ${common.USER.ADMIN.TOKEN}`) .expect('Content-Type', /application\/json/) .expect(200); + break; case 'oracle': await request(getUrl(vendor)) @@ -170,6 +175,7 @@ describe('schema', () => { .set('Authorization', `Bearer ${common.USER.ADMIN.TOKEN}`) .expect('Content-Type', /application\/json/) .expect(200); + break; default: break; @@ -218,10 +224,13 @@ describe('schema', () => { if (vendor === 'oracle') { expect(responseObj.date).toBe(sampleDates[index]!.date); expect(responseObj.datetime).toBe(sampleDates[index]!.datetime); + expect(responseObj.timestamp.substring(0, 19)).toBe( new Date(sampleDates[index]!.timestamp).toISOString().substring(0, 19) ); + const dateCreated = new Date(responseObj.date_created); + expect(dateCreated.toISOString()).toBe( validateDateDifference( insertionStartTimestamp, @@ -229,6 +238,7 @@ describe('schema', () => { insertionEndTimestamp.getTime() - insertionStartTimestamp.getTime() ).toISOString() ); + expect(responseObj.date_updated).toBeNull(); continue; } @@ -236,10 +246,13 @@ describe('schema', () => { expect(responseObj.date).toBe(sampleDates[index]!.date); expect(responseObj.time).toBe(sampleDates[index]!.time); expect(responseObj.datetime).toBe(sampleDates[index]!.datetime); + expect(responseObj.timestamp.substring(0, 19)).toBe( new Date(sampleDates[index]!.timestamp).toISOString().substring(0, 19) ); + const dateCreated = new Date(responseObj.date_created); + expect(dateCreated.toISOString()).toBe( validateDateDifference( insertionStartTimestamp, @@ -247,6 +260,7 @@ describe('schema', () => { insertionEndTimestamp.getTime() - insertionStartTimestamp.getTime() + 1000 ).toISOString() ); + expect(responseObj.date_updated).toBeNull(); } }, @@ -293,6 +307,7 @@ describe('schema', () => { const dateCreated = new Date(responseObj.date_created); const dateUpdated = new Date(responseObj.date_updated); expect(dateUpdated.toISOString()).not.toBe(dateCreated.toISOString()); + expect(dateUpdated.toISOString()).toBe( validateDateDifference( updateStartTimestamp, diff --git a/tests/blackbox/setup/customEnvironment.ts b/tests/blackbox/setup/customEnvironment.ts index fff8a1b6ef..f4a3178ccf 100644 --- a/tests/blackbox/setup/customEnvironment.ts +++ b/tests/blackbox/setup/customEnvironment.ts @@ -40,6 +40,7 @@ class CustomEnvironment extends NodeEnvironment { Authorization: `Bearer ${common.USER.TESTS_FLOW.TOKEN}`, }, }); + const completedCount = Number(response.data.data[0].count.id); if (testIndex >= 0) { diff --git a/tests/blackbox/setup/migrations/20220112001234_create_tests_flow.js b/tests/blackbox/setup/migrations/20220112001234_create_tests_flow.js index b2cc4f4ebe..dc4a74942c 100644 --- a/tests/blackbox/setup/migrations/20220112001234_create_tests_flow.js +++ b/tests/blackbox/setup/migrations/20220112001234_create_tests_flow.js @@ -3,6 +3,7 @@ exports.up = async function (knex) { table.increments('id').primary(); table.string('total_tests_count'); }); + await knex.schema.createTable('tests_flow_completed', (table) => { table.increments('id').primary(); table.string('test_file_path'); diff --git a/tests/blackbox/setup/seeds/02_tests_flow.js b/tests/blackbox/setup/seeds/02_tests_flow.js index 61e841ff65..85fb13d975 100644 --- a/tests/blackbox/setup/seeds/02_tests_flow.js +++ b/tests/blackbox/setup/seeds/02_tests_flow.js @@ -5,6 +5,7 @@ exports.seed = async function (knex) { await knex('tests_flow_completed').del(); await knex('directus_roles').where('id', 'd70c0943-5b55-4c5d-a613-f539a27a57f5').del(); + await knex('directus_roles').insert([ { id: 'd70c0943-5b55-4c5d-a613-f539a27a57f5', @@ -15,6 +16,7 @@ exports.seed = async function (knex) { ]); await knex('directus_users').where('id', '3d075128-c073-4f5d-891c-ed2eb2790a1c').del(); + await knex('directus_users').insert([ { id: '3d075128-c073-4f5d-891c-ed2eb2790a1c', diff --git a/tests/blackbox/setup/setup.ts b/tests/blackbox/setup/setup.ts index b2b85515ed..11f38ca108 100644 --- a/tests/blackbox/setup/setup.ts +++ b/tests/blackbox/setup/setup.ts @@ -34,6 +34,7 @@ export default async (): Promise => { task: async () => { const database = knex(config.knexConfig[vendor]!); await awaitDatabaseConnection(database, config.knexConfig[vendor]!.waitTestSQL); + if (vendor === 'sqlite3') { writeFileSync(path.join(paths.cwd, 'test.db'), ''); } @@ -56,12 +57,15 @@ export default async (): Promise => { cwd: paths.cwd, env: config.envs[vendor], }); + global.directus[vendor] = server; let serverOutput = ''; server.stdout.setEncoding('utf8'); + server.stdout.on('data', (data) => { serverOutput += data.toString(); }); + server.on('exit', (code) => { if (process.env.TEST_SAVE_LOGS) { writeFileSync(path.join(paths.cwd, `server-log-${vendor}.txt`), serverOutput); @@ -69,6 +73,7 @@ export default async (): Promise => { if (code !== null) throw new Error(`Directus-${vendor} server failed: \n ${serverOutput}`); }); + // Give the server some time to start await awaitDirectusConnection(Number(config.envs[vendor]!.PORT!)); server.on('exit', () => undefined); @@ -81,9 +86,11 @@ export default async (): Promise => { global.directusNoCache[vendor] = serverNoCache; let serverNoCacheOutput = ''; serverNoCache.stdout.setEncoding('utf8'); + serverNoCache.stdout.on('data', (data) => { serverNoCacheOutput += data.toString(); }); + serverNoCache.on('exit', (code) => { if (process.env.TEST_SAVE_LOGS) { writeFileSync(__dirname + `/../server-log-${vendor}-no-cache.txt`, serverNoCacheOutput); @@ -92,6 +99,7 @@ export default async (): Promise => { if (code !== null) throw new Error(`Directus-${vendor}-no-cache server failed: \n ${serverNoCacheOutput}`); }); + // Give the server some time to start await awaitDirectusConnection(Number(noCacheEnv.PORT!)); serverNoCache.on('exit', () => undefined); @@ -119,6 +127,7 @@ export default async (): Promise => { for (const vendor of vendors) { try { const serverUrl = getUrl(vendor); + let response = await axios.get( `${serverUrl}/items/tests_flow_data?access_token=${common.USER.TESTS_FLOW.TOKEN}` ); @@ -130,6 +139,7 @@ export default async (): Promise => { const body = { total_tests_count: totalTestsCount, }; + response = await axios.post(`${serverUrl}/items/tests_flow_data`, body, { headers: { Authorization: 'Bearer ' + common.USER.TESTS_FLOW.TOKEN,