diff --git a/.env b/.env index ac6af0105..77a885fb7 100644 --- a/.env +++ b/.env @@ -12,8 +12,7 @@ ROCKET_CHAT_API_AUTH_TOKEN=an-auth-token ROCKET_CHAT_URL=https://community.serlo.org/ SERLO_ORG_DATABASE_LAYER_HOST=127.0.0.1:8080 SERLO_ORG_SECRET=serlo.org-secret -# Set the following value to `empty` to disable caching -CACHE_TYPE=redis +CACHE_TYPE=empty SERVER_HYDRA_HOST=http://localhost:4445 SERVER_KRATOS_PUBLIC_HOST=http://localhost:4433 diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index bdf53f260..9704d16df 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -41,12 +41,7 @@ jobs: - uses: serlo/configure-repositories/actions/setup-mysql@main - uses: serlo/configure-repositories/actions/setup-node@main - run: yarn start:containers - # It seems that running tests for changing taxonomies in parallel - # to metadata query messes up with internal optimizatons so that - # testing metadata endpoint runs into a timout - # => solution: Run tests for metadata in an extra step - - run: yarn test metadata.ts - - run: yarn test --testPathIgnorePatterns metadata.ts __utils__ + - run: yarn test codegen: runs-on: ubuntu-latest diff --git a/.yarn/cache/semver-npm-7.6.0-f4630729f6-7427f05b70.zip b/.yarn/cache/semver-npm-7.6.0-f4630729f6-7427f05b70.zip new file mode 100644 index 000000000..a5494e10a Binary files /dev/null and b/.yarn/cache/semver-npm-7.6.0-f4630729f6-7427f05b70.zip differ diff --git a/.yarn/cache/semver-npm-7.6.1-8d5ad7cc68-2c9c89b985.zip b/.yarn/cache/semver-npm-7.6.1-8d5ad7cc68-2c9c89b985.zip deleted file mode 100644 index f9b6887ab..000000000 Binary files a/.yarn/cache/semver-npm-7.6.1-8d5ad7cc68-2c9c89b985.zip and /dev/null differ diff --git a/README.md b/README.md index 6cc0bf129..91626f58a 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,7 @@ Happy coding! ### Stop -Interrupt the `yarn start` command to stop the dev server and run `yarn down` to remove all containers. +Interrupt the `yarn start` command to stop the dev server and run `yarn stop:redis` to stop Redis. ### Automatically check your codebase before pushing @@ -89,6 +89,17 @@ chmod +x .git/hooks/pre-push With `git push --no-verify` you can bypass the automatic checks. +### Repository structure + +- `__fixtures__` contains test data (used by both unit and contract tests). +- `__tests__` contains the unit tests. +- `__tests-pacts__` contains the contract test. +- `src/internals` contains a couple of internal data structures. In most cases, you won't need to touch this. Here we hide complexity that isn't needed for typical development tasks. +- `src/model` defines the model. +- `src/schema` defines the GraphQL schema. + +We have `~` as an absolute path alias for `./src` in place, e.g. `~/internals` refers to `./src/internals`. + ### Other commands - `yarn build:server` builds the server (only needed for deployment) @@ -118,17 +129,14 @@ For more info about it see its [documentation](https://www.ory.sh/docs/kratos). ### Integrating Keycloak -First of all add `nbp` and `vidis` as host +First of all add `nbp` as host `sudo bash -c "echo '127.0.0.1 nbp'" >> /etc/hosts` -`sudo bash -c "echo '127.0.0.1 vidis'" >> /etc/hosts` -_why do I need it? Kratos makes a request to the url of the oauth2 provider, but since it is running inside a container, it cannot easily use the host port. These DNSs are discoverable for the kratos container, so the host can also use it._ +_why do I need it? Kratos makes a request to the url of the oauth2 provider, but since it is running inside a container, it cannot easily use the host port. nbp is a dns that is discoverable for the kratos container, so the host can also use it._ -Run `yarn start:sso`. -_Make sure you already run `yarn start:kratos` before._ +Run `yarn start:nbp`. -Keycloak UI is available on `nbp:11111` and `vidis:11112`. -Username: admin, pw: admin. +Keycloak UI is available on `nbp:11111` (username: admin, pw: admin). There you have to configure Serlo as a client. > Client -> Create Client @@ -137,7 +145,6 @@ There you have to configure Serlo as a client. > id: serlo > home and root url: http://localhost:3000 > redirect uri: http://localhost:4433/self-service/methods/oidc/callback/nbp -> // OR redirect uri: http://localhost:4433/self-service/methods/oidc/callback/vidis > ``` Get the credentials and go to `kratos/config.yml`: @@ -149,7 +156,7 @@ selfservice: enabled: true config: providers: - - id: nbp # or vidis + - id: nbp provider: generic client_id: serlo client_secret: @@ -157,8 +164,6 @@ selfservice: Run the local frontend (not forgetting to change environment in its `.env` to local) to test. -Hint: you may want to create some users in Keycloak in order to test. - ### Email templates Kratos has to be rebuilt every time you change an email template. Use the following workflow: diff --git a/__fixtures__/index.ts b/__fixtures__/index.ts index 44999ebc4..4e00ec732 100644 --- a/__fixtures__/index.ts +++ b/__fixtures__/index.ts @@ -1,5 +1,3 @@ export * from './license-id' export * from './notification' export * from './uuid' -export * from './subjects' -export * from './metadata' diff --git a/__fixtures__/subjects.ts b/__fixtures__/subjects.ts deleted file mode 100644 index 6e70cb312..000000000 --- a/__fixtures__/subjects.ts +++ /dev/null @@ -1,10 +0,0 @@ -export const emptySubjects = [ - { unrevisedEntities: { nodes: [] } }, - { unrevisedEntities: { nodes: [] } }, - { unrevisedEntities: { nodes: [] } }, - { unrevisedEntities: { nodes: [] } }, - { unrevisedEntities: { nodes: [] } }, - { unrevisedEntities: { nodes: [] } }, - { unrevisedEntities: { nodes: [] } }, - { unrevisedEntities: { nodes: [] } }, -] diff --git a/__tests__/__utils__/assertions.ts b/__tests__/__utils__/assertions.ts index b5eb46e52..834e1d785 100644 --- a/__tests__/__utils__/assertions.ts +++ b/__tests__/__utils__/assertions.ts @@ -3,7 +3,6 @@ import { type Storage } from '@google-cloud/storage' import type { OAuth2Api } from '@ory/client' import * as Sentry from '@sentry/node' import { DocumentNode } from 'graphql' -import gql from 'graphql-tag' import * as R from 'ramda' import { given, nextUuid } from '.' @@ -122,8 +121,6 @@ export class Query< const result = await this.execute() if (result.body.kind === 'single') { - expect(result.body.singleResult['errors']).toBeUndefined() - return result.body.singleResult['data'] } @@ -240,32 +237,6 @@ export async function assertErrorEvent(args?: { expect(global.sentryEvents.some(eventPredicate)).toBe(true) } -export async function expectEvent( - event: { - __typename: string - objectId: number - }, - first = 1, -) { - const data = (await new Client() - .prepareQuery({ - query: gql` - query ($first: Int!) { - events(first: $first) { - nodes { - __typename - objectId - } - } - } - `, - variables: { first }, - }) - .getData()) as { events: { nodes: unknown[] } } - - expect(data.events.nodes).toContainEqual(event) -} - /** * Assertation that no error events have been triggert to sentry */ diff --git a/__tests__/__utils__/index.ts b/__tests__/__utils__/index.ts index d33517128..850e19cc0 100644 --- a/__tests__/__utils__/index.ts +++ b/__tests__/__utils__/index.ts @@ -3,7 +3,6 @@ import * as R from 'ramda' export * from './assertions' export * from './handlers' export * from './services' -export * from './query' export function getTypenameAndId(value: { __typename: string; id: number }) { return R.pick(['__typename', 'id'], value) diff --git a/__tests__/__utils__/query.ts b/__tests__/__utils__/query.ts deleted file mode 100644 index fc676c087..000000000 --- a/__tests__/__utils__/query.ts +++ /dev/null @@ -1,52 +0,0 @@ -import gql from 'graphql-tag' - -import { Client } from './assertions' - -export const taxonomyTermQuery = new Client().prepareQuery({ - query: gql` - query ($id: Int!) { - uuid(id: $id) { - __typename - ... on TaxonomyTerm { - id - trashed - type - instance - alias - title - name - description - weight - taxonomyId - path { - id - } - parent { - id - } - children { - nodes { - id - } - } - } - } - } - `, -}) - -export const subjectQuery = new Client().prepareQuery({ - query: gql` - query ($instance: Instance!) { - subject { - subjects(instance: $instance) { - id - taxonomyTerm { - name - } - } - } - } - `, - variables: { instance: 'de' }, -}) diff --git a/__tests__/schema/entity/checkout-revision.ts b/__tests__/schema/entity/checkout-revision.ts index d7a1808f4..ff230e60e 100644 --- a/__tests__/schema/entity/checkout-revision.ts +++ b/__tests__/schema/entity/checkout-revision.ts @@ -6,9 +6,9 @@ import { articleRevision, user as baseUser, taxonomyTermSubject, - emptySubjects, } from '../../../__fixtures__' import { getTypenameAndId, nextUuid, given, Client } from '../../__utils__' +import { emptySubjects } from '../subject' import { Instance } from '~/types' const user = { ...baseUser, roles: ['de_reviewer'] } diff --git a/__tests__/schema/entity/reject-revision.ts b/__tests__/schema/entity/reject-revision.ts index 0971cde68..39b6c478b 100644 --- a/__tests__/schema/entity/reject-revision.ts +++ b/__tests__/schema/entity/reject-revision.ts @@ -6,9 +6,9 @@ import { articleRevision, taxonomyTermSubject, user as baseUser, - emptySubjects, } from '../../../__fixtures__' import { given, getTypenameAndId, nextUuid, Client } from '../../__utils__' +import { emptySubjects } from '../subject' import { Instance } from '~/types' const user = { ...baseUser, roles: ['de_reviewer'] } diff --git a/__tests__/schema/entity/set.ts b/__tests__/schema/entity/set.ts index 87440cd81..6d6a67691 100644 --- a/__tests__/schema/entity/set.ts +++ b/__tests__/schema/entity/set.ts @@ -322,8 +322,11 @@ testCases.forEach((testCase) => { await mutationWithEntityId.shouldFailWithError('INTERNAL_SERVER_ERROR') }) - // TODO: Make it a proper test when doing the migration - test.skip('fails when parent does not exists', async () => { + test('fails when parent does not exists', async () => { + given('UuidQuery') + .withPayload({ id: testCase.parent.id }) + .returnsNotFound() + await mutationWithParentId.shouldFailWithError('BAD_USER_INPUT') }) diff --git a/__tests__/schema/subject.ts b/__tests__/schema/subject.ts index c3e368cdb..04cac2cfc 100644 --- a/__tests__/schema/subject.ts +++ b/__tests__/schema/subject.ts @@ -1,18 +1,66 @@ import gql from 'graphql-tag' -import { article, emptySubjects, taxonomyTermSubject } from '../../__fixtures__' -import { Client, given, subjectQuery } from '../__utils__' +import { article, taxonomyTermSubject } from '../../__fixtures__' +import { Client, given } from '../__utils__' +import { Instance } from '~/types' + +export const emptySubjects = [ + { unrevisedEntities: { nodes: [] } }, + { unrevisedEntities: { nodes: [] } }, + { unrevisedEntities: { nodes: [] } }, + { unrevisedEntities: { nodes: [] } }, + { unrevisedEntities: { nodes: [] } }, + { unrevisedEntities: { nodes: [] } }, + { unrevisedEntities: { nodes: [] } }, + { unrevisedEntities: { nodes: [] } }, +] test('endpoint "subjects" returns list of all subjects for an instance', async () => { - await subjectQuery.withVariables({ instance: 'en' }).shouldReturnData({ - subject: { subjects: [{ taxonomyTerm: { name: 'Math' } }] }, - }) + given('UuidQuery').for(taxonomyTermSubject) + + await new Client() + .prepareQuery({ + query: gql` + query ($instance: Instance!) { + subject { + subjects(instance: $instance) { + taxonomyTerm { + name + } + } + } + } + `, + }) + .withVariables({ instance: Instance.En }) + .shouldReturnData({ + subject: { + subjects: [{ taxonomyTerm: { name: 'Math' } }], + }, + }) }) test('`Subject.id` returns encoded id of subject', async () => { - await subjectQuery.withVariables({ instance: 'en' }).shouldReturnData({ - subject: { subjects: [{ id: 'czIzNTkz' }] }, - }) + given('UuidQuery').for(taxonomyTermSubject) + + await new Client() + .prepareQuery({ + query: gql` + query ($instance: Instance!) { + subject { + subjects(instance: $instance) { + id + } + } + } + `, + }) + .withVariables({ instance: Instance.En }) + .shouldReturnData({ + subject: { + subjects: [{ id: 'czIzNTkz' }], + }, + }) }) test('`Subject.unrevisedEntities` returns list of unrevisedEntities', async () => { diff --git a/__tests__/schema/taxonomy-term/create-entity-links.ts b/__tests__/schema/taxonomy-term/create-entity-links.ts index 2e6881782..ecc2fa159 100644 --- a/__tests__/schema/taxonomy-term/create-entity-links.ts +++ b/__tests__/schema/taxonomy-term/create-entity-links.ts @@ -1,37 +1,66 @@ import gql from 'graphql-tag' +import { HttpResponse } from 'msw' -import { exercise } from '../../../__fixtures__' -import { Client, taxonomyTermQuery } from '../../__utils__' +import { + article, + exercise, + user as baseUser, + taxonomyTermCurriculumTopic, + taxonomyTermSubject, + video, +} from '../../../__fixtures__' +import { Client, given } from '../../__utils__' + +const user = { ...baseUser, roles: ['de_architect'] } const input = { - entityIds: [32321, 1855], - taxonomyTermId: 1314, + entityIds: [video.id, exercise.id], + taxonomyTermId: taxonomyTermCurriculumTopic.id, } -const mutation = new Client({ userId: 1 }).prepareQuery({ - query: gql` - mutation ($input: TaxonomyEntityLinksInput!) { - taxonomyTerm { - createEntityLinks(input: $input) { - success +const mutation = new Client({ userId: user.id }) + .prepareQuery({ + query: gql` + mutation ($input: TaxonomyEntityLinksInput!) { + taxonomyTerm { + createEntityLinks(input: $input) { + success + } } } - } - `, - variables: { input }, -}) + `, + }) + .withInput(input) -test('adds links to taxonomies', async () => { - await taxonomyTermQuery - .withVariables({ id: input.taxonomyTermId }) - .shouldReturnData({ - uuid: { - children: { - nodes: [{ id: 25614 }, { id: 1501 }, { id: 1589 }, { id: 29910 }], - }, - }, +beforeEach(() => { + given('UuidQuery').for( + article, + exercise, + video, + taxonomyTermSubject, + taxonomyTermCurriculumTopic, + user, + ) + + given('TaxonomyCreateEntityLinksMutation') + .withPayload({ ...input, userId: user.id }) + .isDefinedBy(() => { + given('UuidQuery').for({ + ...exercise, + taxonomyTermIds: [ + ...exercise.taxonomyTermIds, + taxonomyTermCurriculumTopic.id, + ], + }) + given('UuidQuery').for({ + ...taxonomyTermCurriculumTopic, + childrenIds: [...taxonomyTermCurriculumTopic.childrenIds, exercise.id], + }) + return HttpResponse.json({ success: true }) }) +}) +test('returns { success, record } when mutation could be successfully executed', async () => { await mutation.shouldReturnData({ taxonomyTerm: { createEntityLinks: { @@ -39,55 +68,84 @@ test('adds links to taxonomies', async () => { }, }, }) +}) - await taxonomyTermQuery - .withVariables({ id: input.taxonomyTermId }) - .shouldReturnData({ - uuid: { - children: { - nodes: [ - { id: 25614 }, - { id: 1501 }, - { id: 1589 }, - { id: 29910 }, - { id: 32321 }, - { id: 1855 }, - ], - }, - }, +test('updates the cache', async () => { + const childQuery = new Client({ userId: user.id }) + .prepareQuery({ + query: gql` + query ($id: Int!) { + uuid(id: $id) { + ... on Exercise { + taxonomyTerms { + nodes { + id + } + } + } + } + } + `, }) -}) + .withVariables({ id: exercise.id }) -test('fails when instance does not match', async () => { - const englishEntityId = 35598 + await childQuery.shouldReturnData({ + uuid: { + taxonomyTerms: { + nodes: [{ id: exercise.taxonomyTermIds[0] }], + }, + }, + }) - await mutation - .changeInput({ entityIds: [englishEntityId] }) - .shouldFailWithError('BAD_USER_INPUT') -}) + const parentQuery = new Client({ userId: user.id }) + .prepareQuery({ + query: gql` + query ($id: Int!) { + uuid(id: $id) { + ... on TaxonomyTerm { + children { + nodes { + id + } + } + } + } + } + `, + }) + .withVariables({ id: taxonomyTermCurriculumTopic.id }) -test('fails when exercise shall be added to non exercise folders', async () => { - await mutation - .changeInput({ entityIds: [exercise.id] }) - .shouldFailWithError('BAD_USER_INPUT') -}) + await parentQuery.shouldReturnData({ + uuid: { + children: { + nodes: [{ id: taxonomyTermCurriculumTopic.childrenIds[0] }], + }, + }, + }) -test('fails when non exercise shall be added to exercise folders', async () => { - await mutation - .changeInput({ taxonomyIds: [35562] }) - .shouldFailWithError('BAD_USER_INPUT') -}) + await mutation.execute() -test('fails when taxonomyTermId does not belong to taxonomy', async () => { - await mutation - .changeInput({ taxonomyId: input.entityIds[1] }) - .shouldFailWithError('BAD_USER_INPUT') -}) + await childQuery.shouldReturnData({ + uuid: { + taxonomyTerms: { + nodes: [ + { id: exercise.taxonomyTermIds[0] }, + { id: taxonomyTermCurriculumTopic.id }, + ], + }, + }, + }) -test('fails when one child is no entity', async () => { - await mutation - .changeInput({ entityIds: [1] }) - .shouldFailWithError('BAD_USER_INPUT') + await parentQuery.shouldReturnData({ + uuid: { + children: { + nodes: [ + { id: taxonomyTermCurriculumTopic.childrenIds[0] }, + { id: exercise.id }, + ], + }, + }, + }) }) test('fails when user is not authenticated', async () => { @@ -97,3 +155,15 @@ test('fails when user is not authenticated', async () => { test('fails when user does not have role "architect"', async () => { await mutation.forLoginUser().shouldFailWithError('FORBIDDEN') }) + +test('fails when database layer returns a 400er response', async () => { + given('TaxonomyCreateEntityLinksMutation').returnsBadRequest() + + await mutation.shouldFailWithError('BAD_USER_INPUT') +}) + +test('fails when database layer has an internal error', async () => { + given('TaxonomyCreateEntityLinksMutation').hasInternalServerError() + + await mutation.shouldFailWithError('INTERNAL_SERVER_ERROR') +}) diff --git a/__tests__/schema/taxonomy-term/create.ts b/__tests__/schema/taxonomy-term/create.ts index f4a609daf..75bd195df 100644 --- a/__tests__/schema/taxonomy-term/create.ts +++ b/__tests__/schema/taxonomy-term/create.ts @@ -1,145 +1,214 @@ import gql from 'graphql-tag' +import { HttpResponse } from 'msw' -import { Client } from '../../__utils__' +import { + taxonomyTermCurriculumTopic, + taxonomyTermRoot, + taxonomyTermSubject, + taxonomyTermTopic, + user as baseUser, +} from '../../../__fixtures__' +import { Client, given } from '../../__utils__' import { TaxonomyTypeCreateOptions } from '~/types' -const input = { - parentId: 18230, - name: 'a name', - description: 'a description', - taxonomyType: TaxonomyTypeCreateOptions.Topic, -} -const taxonomyTypes = Object.values(TaxonomyTypeCreateOptions) - -const mutation = new Client({ userId: 1 }) - .prepareQuery({ - query: gql` - mutation set($input: TaxonomyTermCreateInput!) { - taxonomyTerm { - create(input: $input) { - success - record { - id - trashed - type - instance - name - description - weight - parent { - id +describe('TaxonomyTermCreateMutation', () => { + const user = { ...baseUser, roles: ['de_architect'] } + + describe('create Topic', () => { + const input = { + parentId: taxonomyTermSubject.id, + name: 'a name ', + description: 'a description', + taxonomyType: TaxonomyTypeCreateOptions.Topic, + } + + const mutation = new Client({ userId: user.id }) + .prepareQuery({ + query: gql` + mutation set($input: TaxonomyTermCreateInput!) { + taxonomyTerm { + create(input: $input) { + success } - children { - nodes { - id + } + } + `, + }) + .withVariables({ input }) + + const payload = { + ...input, + taxonomyType: 'topic' as const, + userId: user.id, + } + + beforeEach(() => { + given('UuidQuery').for(user, taxonomyTermSubject) + + given('TaxonomyTermCreateMutation') + .withPayload(payload) + .returns(taxonomyTermTopic) + }) + + test('returns { success, record } when mutation could be successfully executed', async () => { + await mutation.shouldReturnData({ + taxonomyTerm: { create: { success: true } }, + }) + }) + + test('updates the cache', async () => { + given('UuidQuery').for(taxonomyTermCurriculumTopic) + + given('TaxonomyTermCreateMutation') + .withPayload(payload) + .isDefinedBy(() => { + given('UuidQuery').for(taxonomyTermTopic) + + const updatedParent = { + ...taxonomyTermSubject, + childrenIds: [ + ...taxonomyTermSubject.childrenIds, + taxonomyTermTopic.id, + ], + } + given('UuidQuery').for(updatedParent) + return HttpResponse.json(taxonomyTermTopic) + }) + + const query = new Client({ userId: user.id }) + .prepareQuery({ + query: gql` + query ($id: Int!) { + uuid(id: $id) { + ... on TaxonomyTerm { + name + children { + nodes { + ... on TaxonomyTerm { + id + name + } + } + } } } } - } - } - } - `, - }) - .withVariables({ input }) - -describe('creates a new taxonomy term', () => { - test.each(taxonomyTypes)('%s', async (taxonomyType) => { - await mutation.changeInput({ taxonomyType }).shouldReturnData({ - taxonomyTerm: { - create: { - success: true, - record: { - trashed: false, - type: taxonomyType, - instance: 'de', - name: input.name, - description: input.description, - weight: 10, - parent: { id: input.parentId }, - children: { nodes: [] }, + `, + }) + .withVariables({ id: taxonomyTermSubject.id }) + + await query.shouldReturnData({ + uuid: { + name: taxonomyTermSubject.name, + children: { + nodes: [ + { + id: taxonomyTermSubject.childrenIds[0], + name: taxonomyTermCurriculumTopic.name, + }, + ], }, }, - }, + }) + + await mutation.execute() + + await query.shouldReturnData({ + uuid: { + name: taxonomyTermSubject.name, + children: { + nodes: [ + { + id: taxonomyTermSubject.childrenIds[0], + name: taxonomyTermCurriculumTopic.name, + }, + { + id: taxonomyTermTopic.id, + name: taxonomyTermTopic.name, + }, + ], + }, + }, + }) + }) + + test('fails when parent does not accept topic', async () => { + given('UuidQuery').for(taxonomyTermRoot) + + await mutation + .withVariables({ ...input, parentId: taxonomyTermRoot.id }) + .shouldFailWithError('BAD_USER_INPUT') + }) + + test('fails when user is not authenticated', async () => { + await mutation + .forUnauthenticatedUser() + .shouldFailWithError('UNAUTHENTICATED') + }) + + test('fails when user does not have role "architect"', async () => { + await mutation.forLoginUser().shouldFailWithError('FORBIDDEN') + }) + + test('fails when database layer returns a 400er response', async () => { + given('TaxonomyTermCreateMutation').returnsBadRequest() + + await mutation.shouldFailWithError('BAD_USER_INPUT') + }) + + test('fails when database layer has an internal error', async () => { + given('TaxonomyTermCreateMutation').hasInternalServerError() + + await mutation.shouldFailWithError('INTERNAL_SERVER_ERROR') }) }) -}) -test('cache of parent is updated', async () => { - const query = new Client().prepareQuery({ - query: gql` - query ($id: Int!) { - uuid(id: $id) { - ... on TaxonomyTerm { - children { - nodes { - ... on TaxonomyTerm { - id - } + describe('create ExerciseFolder', () => { + const input = { + parentId: taxonomyTermSubject.id, + name: 'a name ', + description: 'a description', + taxonomyType: TaxonomyTypeCreateOptions.Topic, + } + + const mutation = new Client({ userId: user.id }) + .prepareQuery({ + query: gql` + mutation set($input: TaxonomyTermCreateInput!) { + taxonomyTerm { + create(input: $input) { + success } } } - } - } - `, - variables: { id: input.parentId }, - }) + `, + }) + .withVariables({ input }) - await query.shouldReturnData({ - uuid: { - children: { - nodes: [ - { id: 21069 }, - { id: 18232 }, - { id: 18884 }, - { id: 18885 }, - { id: 18886 }, - { id: 23256 }, - { id: 18887 }, - { id: 18888 }, - ], - }, - }, - }) + const payload = { + ...input, + taxonomyType: 'topic' as const, + userId: user.id, + } - const data = (await mutation.getData()) as { - taxonomyTerm: { create: { record: { id: number } } } - } - - await query.shouldReturnData({ - uuid: { - children: { - nodes: [ - { id: 21069 }, - { id: 18232 }, - { id: 18884 }, - { id: 18885 }, - { id: 18886 }, - { id: 23256 }, - { id: 18887 }, - { id: 18888 }, - { id: data.taxonomyTerm.create.record.id }, - ], - }, - }, - }) -}) - -test('fails when parent is not a taxonomy term', async () => { - await mutation - .changeInput({ parentId: 1 }) - .shouldFailWithError('BAD_USER_INPUT') -}) + beforeEach(() => { + given('UuidQuery').for(user, taxonomyTermSubject) -test('fails when parent is a exercise folder', async () => { - await mutation - .changeInput({ parentId: 35562 }) - .shouldFailWithError('BAD_USER_INPUT') -}) + given('TaxonomyTermCreateMutation') + .withPayload(payload) + .returns(taxonomyTermTopic) + }) -test('fails when user is not authenticated', async () => { - await mutation.forUnauthenticatedUser().shouldFailWithError('UNAUTHENTICATED') -}) + test('returns { success, record } when mutation could be successfully executed', async () => { + await mutation.shouldReturnData({ + taxonomyTerm: { create: { success: true } }, + }) + }) -test('fails when user does not have role "architect"', async () => { - await mutation.forLoginUser().shouldFailWithError('FORBIDDEN') + test('fails when parent does not accept exerciseFolder', async () => { + await mutation + .withVariables({ ...input, parentId: taxonomyTermSubject.id }) + .shouldFailWithError('BAD_USER_INPUT') + }) + }) }) diff --git a/__tests__/schema/taxonomy-term/delete-entity-links.ts b/__tests__/schema/taxonomy-term/delete-entity-links.ts index 1a76c3e2d..31694462c 100644 --- a/__tests__/schema/taxonomy-term/delete-entity-links.ts +++ b/__tests__/schema/taxonomy-term/delete-entity-links.ts @@ -1,13 +1,21 @@ import gql from 'graphql-tag' +import { HttpResponse } from 'msw' -import { Client, taxonomyTermQuery } from '../../__utils__' +import { + article, + user as baseUser, + taxonomyTermCurriculumTopic, +} from '../../../__fixtures__' +import { Client, given } from '../../__utils__' + +const user = { ...baseUser, roles: ['de_architect'] } const input = { - entityIds: [29910, 1501], - taxonomyTermId: 1314, + entityIds: [article.id], + taxonomyTermId: taxonomyTermCurriculumTopic.id, } -const mutation = new Client({ userId: 1 }) +const mutation = new Client({ userId: user.id }) .prepareQuery({ query: gql` mutation ($input: TaxonomyEntityLinksInput!) { @@ -21,45 +29,85 @@ const mutation = new Client({ userId: 1 }) }) .withInput(input) -test('deletes entity links from taxonomy', async () => { - await taxonomyTermQuery - .withVariables({ id: input.taxonomyTermId }) - .shouldReturnData({ - uuid: { - children: { - nodes: [{ id: 25614 }, { id: 1501 }, { id: 1589 }, { id: 29910 }], - }, - }, - }) +beforeEach(() => { + given('UuidQuery').for( + { ...article, taxonomyTermIds: [taxonomyTermCurriculumTopic.id] }, + taxonomyTermCurriculumTopic, + user, + ) - // TDODO: After we have migrated the entities we should test that - // their taxonomyTermIds. have also changed + given('TaxonomyDeleteEntityLinksMutation') + .withPayload({ ...input, userId: user.id }) + .isDefinedBy(() => { + given('UuidQuery').for({ + ...article, + taxonomyTermIds: [], + }) + given('UuidQuery').for({ + ...taxonomyTermCurriculumTopic, + childrenIds: [], + }) + return HttpResponse.json({ success: true }) + }) +}) +test('returns { success, record } when mutation could be successfully executed', async () => { await mutation.shouldReturnData({ taxonomyTerm: { deleteEntityLinks: { success: true } }, }) +}) - await taxonomyTermQuery - .withVariables({ id: input.taxonomyTermId }) - .shouldReturnData({ - uuid: { - children: { - nodes: [{ id: 25614 }, { id: 1589 }], - }, - }, +test('updates the cache', async () => { + const childQuery = new Client({ userId: user.id }) + .prepareQuery({ + query: gql` + query ($id: Int!) { + uuid(id: $id) { + ... on Article { + taxonomyTerms { + nodes { + id + } + } + } + } + } + `, }) -}) + .withVariables({ id: article.id }) -test('fails when taxonomyTermId does not belong to taxonomy', async () => { - await mutation - .changeInput({ termTaxonomyId: 1 }) - .shouldFailWithError('BAD_USER_INPUT') -}) + await childQuery.shouldReturnData({ + uuid: { + taxonomyTerms: { nodes: [{ id: taxonomyTermCurriculumTopic.id }] }, + }, + }) -test('fails when a child is only linked to one taxonomy', async () => { - await mutation - .changeInput({ termTaxonomyId: 35562, entityIds: [25614] }) - .shouldFailWithError('BAD_USER_INPUT') + const parentQuery = new Client({ userId: user.id }) + .prepareQuery({ + query: gql` + query ($id: Int!) { + uuid(id: $id) { + ... on TaxonomyTerm { + children { + nodes { + id + } + } + } + } + } + `, + }) + .withVariables({ id: taxonomyTermCurriculumTopic.id }) + + await parentQuery.shouldReturnData({ + uuid: { children: { nodes: [{ id: article.id }] } }, + }) + + await mutation.execute() + + await parentQuery.shouldReturnData({ uuid: { children: { nodes: [] } } }) + await childQuery.shouldReturnData({ uuid: { taxonomyTerms: { nodes: [] } } }) }) test('fails when user is not authenticated', async () => { @@ -69,3 +117,15 @@ test('fails when user is not authenticated', async () => { test('fails when user does not have role "architect"', async () => { await mutation.forLoginUser().shouldFailWithError('FORBIDDEN') }) + +test('fails when database layer returns a 400er response', async () => { + given('TaxonomyDeleteEntityLinksMutation').returnsBadRequest() + + await mutation.shouldFailWithError('BAD_USER_INPUT') +}) + +test('fails when database layer has an internal error', async () => { + given('TaxonomyDeleteEntityLinksMutation').hasInternalServerError() + + await mutation.shouldFailWithError('INTERNAL_SERVER_ERROR') +}) diff --git a/__tests__/schema/taxonomy-term/set-name-and-description.ts b/__tests__/schema/taxonomy-term/set-name-and-description.ts index 092d16edf..f5cec2622 100644 --- a/__tests__/schema/taxonomy-term/set-name-and-description.ts +++ b/__tests__/schema/taxonomy-term/set-name-and-description.ts @@ -1,70 +1,123 @@ import gql from 'graphql-tag' +import { HttpResponse } from 'msw' -import { Client, expectEvent } from '../../__utils__' -import { NotificationEventType } from '~/model/decoder' - -const input = { - description: 'a description', - name: 'a name', - id: 5, -} - -const mutation = new Client({ userId: 1 }) - .prepareQuery({ - query: gql` - mutation set($input: TaxonomyTermSetNameAndDescriptionInput!) { - taxonomyTerm { - setNameAndDescription(input: $input) { - success +import { + taxonomyTermCurriculumTopic, + user as baseUser, +} from '../../../__fixtures__' +import { Client, given } from '../../__utils__' + +describe('TaxonomyTermSetNameAndDescriptionMutation', () => { + const user = { ...baseUser, roles: ['de_architect'] } + + const input = { + description: 'a description', + name: 'a name', + id: taxonomyTermCurriculumTopic.id, + } + + const mutation = new Client({ userId: user.id }) + .prepareQuery({ + query: gql` + mutation set($input: TaxonomyTermSetNameAndDescriptionInput!) { + taxonomyTerm { + setNameAndDescription(input: $input) { + success + } } } - } - `, + `, + }) + .withVariables({ input }) + + beforeEach(() => { + given('UuidQuery').for(user, taxonomyTermCurriculumTopic) }) - .withVariables({ input }) - -const query = new Client().prepareQuery({ - query: gql` - query ($id: Int!) { - uuid(id: $id) { - ... on TaxonomyTerm { - name - description - } - } - } - `, - variables: { id: input.id }, -}) -test('updates name and description', async () => { - await query.shouldReturnData({ uuid: { name: 'Mathe', description: null } }) + test('returns "{ success: true }" when mutation could be successfully executed', async () => { + given('TaxonomyTermSetNameAndDescriptionMutation') + .withPayload({ ...input, userId: user.id }) + .returns({ success: true }) - await mutation.shouldReturnData({ - taxonomyTerm: { setNameAndDescription: { success: true } }, + await mutation.shouldReturnData({ + taxonomyTerm: { setNameAndDescription: { success: true } }, + }) }) - await query.shouldReturnData({ - uuid: { name: input.name, description: input.description }, + test('fails when user is not authenticated', async () => { + await mutation + .forUnauthenticatedUser() + .shouldFailWithError('UNAUTHENTICATED') }) - await expectEvent({ - __typename: NotificationEventType.SetTaxonomyTerm, - objectId: input.id, + + test('fails when user does not have role "architect"', async () => { + await mutation.forLoginUser().shouldFailWithError('FORBIDDEN') }) -}) -test('fails when user is not authenticated', async () => { - await mutation.forUnauthenticatedUser().shouldFailWithError('UNAUTHENTICATED') -}) + test('fails when `name` is empty', async () => { + await mutation + .withInput({ ...input, name: '' }) + .shouldFailWithError('BAD_USER_INPUT') + }) -test('fails when user does not have role "architect"', async () => { - await mutation.forLoginUser().shouldFailWithError('FORBIDDEN') -}) + test('fails when database layer returns a 400er response', async () => { + given('TaxonomyTermSetNameAndDescriptionMutation').returnsBadRequest() -test('fails when `name` is empty', async () => { - await mutation.changeInput({ name: '' }).shouldFailWithError('BAD_USER_INPUT') -}) + await mutation.shouldFailWithError('BAD_USER_INPUT') + }) + + test('fails when database layer has an internal error', async () => { + given('TaxonomyTermSetNameAndDescriptionMutation').hasInternalServerError() + + await mutation.shouldFailWithError('INTERNAL_SERVER_ERROR') + }) + + test('updates the cache', async () => { + const query = new Client({ userId: user.id }) + .prepareQuery({ + query: gql` + query ($id: Int!) { + uuid(id: $id) { + ... on TaxonomyTerm { + name + description + } + } + } + `, + }) + .withVariables({ id: taxonomyTermCurriculumTopic.id }) -test('fails when `id` does not belong to a taxonomy term', async () => { - await mutation.changeInput({ id: 1 }).shouldFailWithError('BAD_USER_INPUT') + await query.shouldReturnData({ + uuid: { + name: taxonomyTermCurriculumTopic.name, + description: taxonomyTermCurriculumTopic.description, + }, + }) + + given('TaxonomyTermSetNameAndDescriptionMutation') + .withPayload({ + ...input, + userId: user.id, + }) + .isDefinedBy(async ({ request }) => { + const body = await request.json() + const { name, description } = body.payload + + given('UuidQuery').for({ + ...taxonomyTermCurriculumTopic, + name, + description, + }) + + return HttpResponse.json({ success: true }) + }) + await mutation.shouldReturnData({ + taxonomyTerm: { setNameAndDescription: { success: true } }, + }) + + await query.shouldReturnData({ + uuid: { name: 'a name', description: 'a description' }, + }) + }) }) diff --git a/__tests__/schema/taxonomy-term/sort.ts b/__tests__/schema/taxonomy-term/sort.ts index 3c1e99d54..6498aa0b4 100644 --- a/__tests__/schema/taxonomy-term/sort.ts +++ b/__tests__/schema/taxonomy-term/sort.ts @@ -1,13 +1,26 @@ import gql from 'graphql-tag' +import { HttpResponse } from 'msw' -import { Client, taxonomyTermQuery } from '../../__utils__' +import { + article, + taxonomyTermSubject, + user as baseUser, +} from '../../../__fixtures__' +import { Client, given } from '../../__utils__' +import { UserInputError } from '~/errors' +const user = { ...baseUser, roles: ['de_architect'] } + +const taxonomyTerm = { + ...taxonomyTermSubject, + childrenIds: [23453, 1454, 1394], +} const input = { - childrenIds: [18888, 18887, 21069, 18884, 23256, 18232, 18885, 18886], - taxonomyTermId: 18230, + childrenIds: [1394, 23453, 1454], + taxonomyTermId: taxonomyTerm.id, } -const mutation = new Client({ userId: 1 }) +const mutation = new Client({ userId: user.id }) .prepareQuery({ query: gql` mutation ($input: TaxonomyTermSortInput!) { @@ -21,50 +34,39 @@ const mutation = new Client({ userId: 1 }) }) .withInput(input) -test('changes order of children', async () => { - await taxonomyTermQuery.withVariables({ id: 18230 }).shouldReturnData({ - uuid: { - children: { - nodes: [ - { id: 21069 }, - { id: 18232 }, - { id: 18884 }, - { id: 18885 }, - { id: 18886 }, - { id: 23256 }, - { id: 18887 }, - { id: 18888 }, - ], - }, - }, +beforeEach(() => { + given('UuidQuery').for(user, taxonomyTerm) + + given('TaxonomySortMutation').isDefinedBy(async ({ request }) => { + const body = await request.json() + const { childrenIds } = body.payload + if ( + [...childrenIds].sort().join(',') !== + [...taxonomyTerm.childrenIds].sort().join(',') + ) { + throw new UserInputError( + 'children_ids have to match the current entities ids linked to the taxonomy_term_id', + ) + } + + given('UuidQuery').for({ ...taxonomyTerm, childrenIds }) + + return HttpResponse.json({ success: true }) }) +}) +test('returns "{ success: true }" when mutation could be successfully executed', async () => { await mutation.shouldReturnData({ taxonomyTerm: { sort: { success: true } }, }) - - await taxonomyTermQuery.withVariables({ id: 18230 }).shouldReturnData({ - uuid: { - children: { - nodes: [ - { id: 18888 }, - { id: 18887 }, - { id: 21069 }, - { id: 18884 }, - { id: 23256 }, - { id: 18232 }, - { id: 18885 }, - { id: 18886 }, - ], - }, - }, - }) }) -test('fails when some childIds are not in the taxonomy', async () => { +test('is successful even though user have not sent all children ids', async () => { await mutation - .changeInput({ childrenIds: [5] }) - .shouldFailWithError('BAD_USER_INPUT') + .withInput({ ...input, childrenIds: [1394, 23453] }) + .shouldReturnData({ + taxonomyTerm: { sort: { success: true } }, + }) }) test('fails when user is not authenticated', async () => { @@ -74,3 +76,59 @@ test('fails when user is not authenticated', async () => { test('fails when user does not have role "architect"', async () => { await mutation.forLoginUser().shouldFailWithError('FORBIDDEN') }) + +test('fails when database layer returns a 400er response', async () => { + given('TaxonomySortMutation').returnsBadRequest() + + await mutation.shouldFailWithError('BAD_USER_INPUT') +}) + +test('fails when database layer has an internal error', async () => { + given('TaxonomySortMutation').hasInternalServerError() + + await mutation.shouldFailWithError('INTERNAL_SERVER_ERROR') +}) + +test('updates the cache', async () => { + given('UuidQuery').for( + { ...article, id: 1394 }, + { ...taxonomyTermSubject, id: 23453 }, + { ...article, id: 1454 }, + ) + + const query = new Client({ userId: user.id }) + .prepareQuery({ + query: gql` + query ($id: Int!) { + uuid(id: $id) { + ... on TaxonomyTerm { + children { + nodes { + id + } + } + } + } + } + `, + }) + .withVariables({ id: taxonomyTerm.id }) + + await query.shouldReturnData({ + uuid: { + children: { + nodes: [{ id: 23453 }, { id: 1454 }, { id: 1394 }], + }, + }, + }) + + await mutation.execute() + + await query.shouldReturnData({ + uuid: { + children: { + nodes: [{ id: 1394 }, { id: 23453 }, { id: 1454 }], + }, + }, + }) +}) diff --git a/__tests__/schema/thread/set-comment-state.ts b/__tests__/schema/thread/set-comment-state.ts index 82861940a..0bed54c09 100644 --- a/__tests__/schema/thread/set-comment-state.ts +++ b/__tests__/schema/thread/set-comment-state.ts @@ -1,7 +1,16 @@ import gql from 'graphql-tag' -import { comment, comment3, user, user2 } from '../../../__fixtures__' -import { Client } from '../../__utils__' +import { + article, + article2, + comment, + comment1, + comment2, + comment3, + user, + user2, +} from '../../../__fixtures__' +import { Client, given } from '../../__utils__' const mutation = new Client({ userId: user.id }).prepareQuery({ query: gql` @@ -13,23 +22,52 @@ const mutation = new Client({ userId: user.id }).prepareQuery({ } } `, - variables: { input: { id: 35182, trashed: true } }, +}) + +beforeEach(() => { + given('UuidQuery').for( + article, + article2, + comment, + comment1, + comment2, + comment3, + user, + user2, + ) }) // TODO: this is actually wrong since the provided comment is a thread test('trashing any comment as a moderator returns success', async () => { - await mutation.shouldReturnData({ - thread: { setCommentState: { success: true } }, - }) + given('UuidSetStateMutation') + .withPayload({ ids: [comment2.id], userId: user.id, trashed: true }) + .returns(undefined) + + await mutation + .withInput({ id: comment2.id, trashed: true }) + .shouldReturnData({ + thread: { setCommentState: { success: true } }, + }) }) test('trashing own comment returns success', async () => { - await mutation.withContext({ userId: 266 }).shouldReturnData({ - thread: { setCommentState: { success: true } }, - }) + given('UuidSetStateMutation') + .withPayload({ ids: [comment2.id], userId: user2.id, trashed: true }) + .returns(undefined) + + await mutation + .withContext({ userId: user2.id }) + .withInput({ id: comment2.id, trashed: true }) + .shouldReturnData({ + thread: { setCommentState: { success: true } }, + }) }) test('trashing the comment from another user returns an error', async () => { + given('UuidSetStateMutation') + .withPayload({ ids: [comment3.id], userId: user2.id, trashed: true }) + .returns(undefined) + await mutation .withContext({ userId: user2.id }) .withInput({ id: comment3.id, trashed: true }) @@ -38,7 +76,7 @@ test('trashing the comment from another user returns an error', async () => { test('unauthenticated user gets error', async () => { await mutation - .forUnauthenticatedUser() .withInput({ id: comment.id, trashed: true }) + .forUnauthenticatedUser() .shouldFailWithError('UNAUTHENTICATED') }) diff --git a/__tests__/schema/thread/set-thread-state.ts b/__tests__/schema/thread/set-thread-state.ts index 7a7d6b203..023f6d6b3 100644 --- a/__tests__/schema/thread/set-thread-state.ts +++ b/__tests__/schema/thread/set-thread-state.ts @@ -1,7 +1,7 @@ import gql from 'graphql-tag' -import { comment, user } from '../../../__fixtures__' -import { Client } from '../../__utils__' +import { article, comment, user } from '../../../__fixtures__' +import { given, Client } from '../../__utils__' import { encodeThreadId } from '~/schema/thread/utils' const mutation = new Client({ userId: user.id }) @@ -18,11 +18,19 @@ const mutation = new Client({ userId: user.id }) }) .withInput({ id: encodeThreadId(comment.id), trashed: true }) +beforeEach(() => { + given('UuidQuery').for(article, comment, user) +}) + test('unauthenticated user gets error', async () => { await mutation.forUnauthenticatedUser().shouldFailWithError('UNAUTHENTICATED') }) test('trashing thread returns success', async () => { + given('UuidSetStateMutation') + .withPayload({ ids: [comment.id], userId: user.id, trashed: true }) + .returns(undefined) + await mutation.shouldReturnData({ thread: { setThreadState: { success: true } }, }) diff --git a/__tests__/schema/user/delete-regular-users.ts b/__tests__/schema/user/delete-regular-users.ts index f32391920..92d1ffa1a 100644 --- a/__tests__/schema/user/delete-regular-users.ts +++ b/__tests__/schema/user/delete-regular-users.ts @@ -1,4 +1,5 @@ import gql from 'graphql-tag' +import { HttpResponse } from 'msw' import * as R from 'ramda' import { user as baseUser } from '../../../__fixtures__' @@ -27,10 +28,19 @@ beforeEach(() => { }) .withInput(R.pick(['id', 'username'], user)) + given('UserDeleteRegularUsersMutation').isDefinedBy(async ({ request }) => { + const body = await request.json() + const { userId } = body.payload + + given('UuidQuery').withPayload({ id: userId }).returnsNotFound() + + return HttpResponse.json({ success: true }) + }) + given('UuidQuery').for(user) }) -test('runs successfully if mutation could be successfully executed', async () => { +test('runs successfully when mutation could be successfully executed', async () => { expect(global.kratos.identities).toHaveLength(1) await mutation.shouldReturnData({ @@ -39,7 +49,20 @@ test('runs successfully if mutation could be successfully executed', async () => expect(global.kratos.identities).toHaveLength(0) }) -test('fails if username does not match user', async () => { +test('fails when mutation failes', async () => { + given('UserDeleteRegularUsersMutation').returns({ + success: false, + reason: 'failure', + }) + expect(global.kratos.identities).toHaveLength(1) + + await mutation.shouldReturnData({ + user: { deleteRegularUser: { success: false } }, + }) + expect(global.kratos.identities).toHaveLength(1) +}) + +test('fails when username does not match user', async () => { await mutation .withInput({ users: [{ id: user.id, username: 'something' }] }) .shouldFailWithError('BAD_USER_INPUT') @@ -62,29 +85,32 @@ test('updates the cache', async () => { await mutation.execute() - // TODO: uncomment once UUID query does not call the database-layer any more if the UUID SQL query here is null - //await uuidQuery.shouldReturnData({ uuid: null }) + await uuidQuery.shouldReturnData({ uuid: null }) }) -test('fails if one of the given bot ids is not a user', async () => { +test('fails when one of the given bot ids is not a user', async () => { await mutation .withInput({ userIds: [noUserId] }) .shouldFailWithError('BAD_USER_INPUT') }) -test('fails if you try to delete user Deleted', async () => { - await mutation.withInput({ userIds: 4 }).shouldFailWithError('BAD_USER_INPUT') -}) - -test('fails if user is not authenticated', async () => { +test('fails when user is not authenticated', async () => { await mutation.forUnauthenticatedUser().shouldFailWithError('UNAUTHENTICATED') }) -test('fails if user does not have role "sysadmin"', async () => { +test('fails when user does not have role "sysadmin"', async () => { await mutation.forLoginUser('de_admin').shouldFailWithError('FORBIDDEN') }) -test('fails if kratos has an error', async () => { +test('fails when database layer has an internal error', async () => { + given('UserDeleteRegularUsersMutation').hasInternalServerError() + + await mutation.shouldFailWithError('INTERNAL_SERVER_ERROR') + + expect(global.kratos.identities).toHaveLength(1) +}) + +test('fails when kratos has an error', async () => { global.kratos.admin.deleteIdentity = () => { throw new Error('Error in kratos') } diff --git a/__tests__/schema/user/set-email.ts b/__tests__/schema/user/set-email.ts index d8f348b9d..79053df33 100644 --- a/__tests__/schema/user/set-email.ts +++ b/__tests__/schema/user/set-email.ts @@ -1,9 +1,9 @@ import gql from 'graphql-tag' -import { user } from '../../../__fixtures__' -import { Client } from '../../__utils__' +import { user as baseUser } from '../../../__fixtures__' +import { Client, given } from '../../__utils__' -const input = { userId: user.id, email: 'user@example.org' } +const user = { ...baseUser, roles: ['sysadmin'] } const query = new Client({ userId: user.id }) .prepareQuery({ query: gql` @@ -16,17 +16,18 @@ const query = new Client({ userId: user.id }) } `, }) - .withInput(input) + .withInput({ userId: user.id, email: 'user@example.org' }) -test('returns "{ success: true }" when mutation could be successfully executed', async () => { - await query.shouldReturnData({ user: { setEmail: { success: true } } }) +beforeEach(() => { + given('UuidQuery').for(user) +}) - const { email } = await database.fetchOne<{ email: string }>( - 'select email from user where id = ?', - [input.userId], - ) +test('returns "{ success: true }" when mutation could be successfully executed', async () => { + given('UserSetEmailMutation') + .withPayload({ userId: user.id, email: 'user@example.org' }) + .returns({ success: true, username: user.username }) - expect(email).toBe(input.email) + await query.shouldReturnData({ user: { setEmail: { success: true } } }) }) test('fails when user is not authenticated', async () => { @@ -36,3 +37,15 @@ test('fails when user is not authenticated', async () => { test('fails when user does not have role "sysadmin"', async () => { await query.forLoginUser('de_admin').shouldFailWithError('FORBIDDEN') }) + +test('fails when database layer returns a 400er response', async () => { + given('UserSetEmailMutation').returnsBadRequest() + + await query.shouldFailWithError('BAD_USER_INPUT') +}) + +test('fails when database layer has an internal error', async () => { + given('UserSetEmailMutation').hasInternalServerError() + + await query.shouldFailWithError('INTERNAL_SERVER_ERROR') +}) diff --git a/__tests__/schema/uuid/abstract-uuid.ts b/__tests__/schema/uuid/abstract-uuid.ts index 216904c0a..1931dcc4b 100644 --- a/__tests__/schema/uuid/abstract-uuid.ts +++ b/__tests__/schema/uuid/abstract-uuid.ts @@ -315,10 +315,14 @@ describe('property "title"', () => { ], '123', ], - ['exercise', [exercise, taxonomyTermSubject], 'Mathe'], - ['exercise group', [exerciseGroup, taxonomyTermSubject], 'Mathe'], + ['exercise', [exercise, taxonomyTermSubject], taxonomyTermSubject.name], + [ + 'exercise group', + [exerciseGroup, taxonomyTermSubject], + taxonomyTermSubject.name, + ], ['user', [user], user.username], - ['taxonomy term', [taxonomyTermRoot], 'Root'], + ['taxonomy term', [taxonomyTermRoot], taxonomyTermRoot.name], ] as [string, Model<'AbstractUuid'>[], string][] test.each(testCases)('%s', async (_, uuids, title) => { diff --git a/__tests__/schema/uuid/set-state.ts b/__tests__/schema/uuid/set-state.ts index f35bc261e..3c4caf896 100644 --- a/__tests__/schema/uuid/set-state.ts +++ b/__tests__/schema/uuid/set-state.ts @@ -1,16 +1,21 @@ import gql from 'graphql-tag' +import { HttpResponse } from 'msw' import { article, page, - taxonomyTermSubject, - user, - articleRevision, + pageRevision, + taxonomyTermRoot, + user as baseUser, } from '../../../__fixtures__' -import { Client } from '../../__utils__' +import { Client, given } from '../../__utils__' +import { generateRole } from '~/internals/graphql' +import { Instance, Role } from '~/types' -const uuids = [article.id, page.id, taxonomyTermSubject.id] -const mutation = new Client({ userId: 1 }).prepareQuery({ +const user = { ...baseUser, roles: ['de_architect'] } +const uuids = [article, page, pageRevision, taxonomyTermRoot] +const client = new Client({ userId: user.id }) +const mutation = client.prepareQuery({ query: gql` mutation uuid($input: UuidSetStateInput!) { uuid { @@ -20,49 +25,165 @@ const mutation = new Client({ userId: 1 }).prepareQuery({ } } `, - variables: { input: { id: uuids, trashed: true } }, }) -const query = new Client().prepareQuery({ - query: gql` - query ($id: Int!) { - uuid(id: $id) { - trashed + +beforeEach(() => { + given('UuidQuery').for(page, pageRevision, taxonomyTermRoot, article) + given('UuidSetStateMutation') + .withPayload({ userId: user.id, trashed: true }) + .isDefinedBy(async ({ request }) => { + const body = await request.json() + const { ids, trashed } = body.payload + + for (const id of ids) { + const uuid = uuids.find((x) => x.id === id) + + if (uuid != null) { + given('UuidQuery').for({ ...article, trashed }) + } else { + return new HttpResponse(null, { + status: 500, + }) + } } - } - `, - variables: { id: taxonomyTermSubject.id }, + + return new HttpResponse() + }) }) -test('set state of an uuid', async () => { - await query.shouldReturnData({ uuid: { trashed: false } }) +describe('infrastructural testing', () => { + beforeEach(() => { + given('UuidQuery').for( + { ...baseUser, roles: ['de_architect'] }, + { ...article, trashed: false }, + ) + }) - await mutation.shouldReturnData({ uuid: { setState: { success: true } } }) + test('returns "{ success: true }" when it succeeds', async () => { + await mutation + .withInput({ id: [article.id], trashed: true }) + .shouldReturnData({ uuid: { setState: { success: true } } }) + }) - await query.shouldReturnData({ uuid: { trashed: true } }) + test('updates the cache when it succeeds', async () => { + const uuidQuery = client + .prepareQuery({ + query: gql` + query ($id: Int!) { + uuid(id: $id) { + trashed + } + } + `, + }) + .withVariables({ id: article.id }) - await mutation - .changeInput({ trashed: false }) - .shouldReturnData({ uuid: { setState: { success: true } } }) + await uuidQuery.shouldReturnData({ uuid: { trashed: false } }) - await query.shouldReturnData({ uuid: { trashed: false } }) -}) + await mutation.withInput({ id: [article.id], trashed: true }).execute() -test('fails when user shall be deletd', async () => { - await mutation - .changeInput({ id: user.id, trashed: true }) - .shouldFailWithError('BAD_USER_INPUT') -}) + await uuidQuery.shouldReturnData({ uuid: { trashed: true } }) + }) -test('fails when article revision shall be deleted', async () => { - await mutation - .changeInput({ id: articleRevision.id }) - .shouldFailWithError('BAD_USER_INPUT') -}) + test('fails when database layer returns a BadRequest response', async () => { + given('UuidSetStateMutation').returnsBadRequest() + + await mutation + .withInput({ id: [article.id], trashed: true }) + .shouldFailWithError('BAD_USER_INPUT') + }) + + test('fails when database layer has an internal server error', async () => { + given('UuidSetStateMutation').hasInternalServerError() -test('fails when user is not authenticated', async () => { - await mutation.forUnauthenticatedUser().shouldFailWithError('UNAUTHENTICATED') + await mutation + .withInput({ id: [article.id], trashed: true }) + .shouldFailWithError('INTERNAL_SERVER_ERROR') + }) }) -test('fails for login user', async () => { - await mutation.forLoginUser().shouldFailWithError('FORBIDDEN') +describe('permission-based testing', () => { + beforeEach(() => { + given('UuidQuery').for(page, pageRevision, taxonomyTermRoot, article) + }) + + test('fails when user is not authenticated', async () => { + await mutation + .forUnauthenticatedUser() + .withInput({ id: [article.id], trashed: true }) + .shouldFailWithError('UNAUTHENTICATED') + }) + + test('fails when login user tries to set state of page', async () => { + await testPermissionWithMockUser(Role.Login, page.id, false) + }) + + test('fails when login user tries to set state of page revision', async () => { + await testPermissionWithMockUser(Role.Login, pageRevision.id, false) + }) + + test('fails when architect tries to set state of page', async () => { + await testPermissionWithMockUser(Role.Architect, page.id, false) + }) + + test('fails when static_pages_builder tries to set state of article', async () => { + await testPermissionWithMockUser(Role.StaticPagesBuilder, article.id, false) + }) + + test('fails when static_pages_builder tries to set state of taxonomy term', async () => { + await testPermissionWithMockUser( + Role.StaticPagesBuilder, + taxonomyTermRoot.id, + false, + ) + }) + + test('returns "{ success: true }" when architect tries to set state of article', async () => { + await testPermissionWithMockUser(Role.Architect, article.id, true) + }) + + test('returns "{ success: true }" when architect tries to set state of taxonomy term', async () => { + await testPermissionWithMockUser(Role.Architect, taxonomyTermRoot.id, true) + }) + + test('returns "{ success: true }" when static_pages_builder tries to set state of page', async () => { + await testPermissionWithMockUser(Role.StaticPagesBuilder, page.id, true) + }) + + test('returns "{ success: true }" when static_pages_builder tries to set state of page revision', async () => { + await testPermissionWithMockUser( + Role.StaticPagesBuilder, + pageRevision.id, + true, + ) + }) + + test('returns "{ success: true }" when static_pages_builder tries to set state of page revision', async () => { + await testPermissionWithMockUser( + Role.StaticPagesBuilder, + pageRevision.id, + true, + ) + }) }) + +async function testPermissionWithMockUser( + userRole: Role, + uuidId: number, + successSwitch: boolean, +) { + given('UuidQuery').for({ + ...baseUser, + roles: [generateRole(userRole, Instance.De)], + }) + + if (successSwitch) { + await mutation + .withInput({ id: [uuidId], trashed: true }) + .shouldReturnData({ uuid: { setState: { success: true } } }) + } else if (!successSwitch) { + await mutation + .withInput({ id: [uuidId], trashed: true }) + .shouldFailWithError('FORBIDDEN') + } +} diff --git a/__tests__/schema/uuid/taxonomy-term.ts b/__tests__/schema/uuid/taxonomy-term.ts index 5c9e680fc..db67aadec 100644 --- a/__tests__/schema/uuid/taxonomy-term.ts +++ b/__tests__/schema/uuid/taxonomy-term.ts @@ -1,100 +1,346 @@ -import { taxonomyTermQuery } from '../../__utils__' - -test('TaxonomyTerm root', async () => { - await taxonomyTermQuery.withVariables({ id: 3 }).shouldReturnData({ - uuid: { - __typename: 'TaxonomyTerm', - id: 3, - trashed: false, - type: 'root', - instance: 'de', - alias: '/root/3/root', - title: 'Root', - name: 'Root', - description: null, - weight: 0, - taxonomyId: 1, - path: [], - parent: null, - children: { - nodes: [ - { id: 26876 }, - { id: 26882 }, - { id: 33894 }, - { id: 35608 }, - { id: 25712 }, - { id: 25979 }, - { id: 26523 }, - { id: 8 }, - { id: 24798 }, - { id: 15465 }, - { id: 23382 }, - { id: 23362 }, - { id: 17746 }, - { id: 18234 }, - { id: 17744 }, - { id: 20605 }, - { id: 5 }, - { id: 18230 }, - ], - }, - }, +import gql from 'graphql-tag' +import * as R from 'ramda' + +import { + article, + taxonomyTermCurriculumTopic, + taxonomyTermRoot, + taxonomyTermSubject, + taxonomyTermTopic, + taxonomyTermTopicFolder, +} from '../../../__fixtures__' +import { Client, getTypenameAndId, given } from '../../__utils__' + +const client = new Client() + +describe('TaxonomyTerm root', () => { + beforeEach(() => { + given('UuidQuery').for(taxonomyTermRoot) + }) + + test('by id', async () => { + await client + .prepareQuery({ + query: gql` + query taxonomyTerm($id: Int!) { + uuid(id: $id) { + __typename + ... on TaxonomyTerm { + id + type + trashed + instance + name + description + weight + } + } + } + `, + }) + .withVariables({ id: taxonomyTermRoot.id }) + .shouldReturnData({ + uuid: R.pick( + [ + '__typename', + 'id', + 'type', + 'trashed', + 'instance', + 'name', + 'description', + 'weight', + ], + taxonomyTermRoot, + ), + }) + }) + + test('by id (w/ parent)', async () => { + await client + .prepareQuery({ + query: gql` + query taxonomyTerm($id: Int!) { + uuid(id: $id) { + ... on TaxonomyTerm { + parent { + __typename + id + type + trashed + instance + name + description + weight + } + } + } + } + `, + }) + .withVariables({ id: taxonomyTermRoot.id }) + .shouldReturnData({ uuid: { parent: null } }) + }) + + test('by id (w/ path)', async () => { + await client + .prepareQuery({ + query: gql` + query taxonomyTerm($id: Int!) { + uuid(id: $id) { + ... on TaxonomyTerm { + path { + id + name + } + } + } + } + `, + }) + .withVariables({ id: taxonomyTermRoot.id }) + .shouldReturnData({ uuid: { path: [] } }) + }) + + test('by id (w/ children)', async () => { + given('UuidQuery').for(taxonomyTermSubject) + + await client + .prepareQuery({ + query: gql` + query taxonomyTerm($id: Int!) { + uuid(id: $id) { + ... on TaxonomyTerm { + children { + nodes { + __typename + ... on TaxonomyTerm { + id + } + } + totalCount + } + } + } + } + `, + }) + .withVariables({ id: taxonomyTermRoot.id }) + .shouldReturnData({ + uuid: { + children: { + nodes: [getTypenameAndId(taxonomyTermSubject)], + totalCount: 1, + }, + }, + }) + }) +}) + +describe('TaxonomyTerm subject', () => { + beforeEach(() => { + given('UuidQuery').for(taxonomyTermSubject) + }) + + test('by id', async () => { + await client + .prepareQuery({ + query: gql` + query taxonomyTerm($id: Int!) { + uuid(id: $id) { + __typename + ... on TaxonomyTerm { + id + } + } + } + `, + }) + .withVariables({ id: taxonomyTermSubject.id }) + .shouldReturnData({ uuid: getTypenameAndId(taxonomyTermSubject) }) + }) + + test('by id (w/ parent)', async () => { + given('UuidQuery').for(taxonomyTermRoot) + + await client + .prepareQuery({ + query: gql` + query taxonomyTerm($id: Int!) { + uuid(id: $id) { + ... on TaxonomyTerm { + parent { + __typename + id + } + } + } + } + `, + }) + .withVariables({ id: taxonomyTermSubject.id }) + .shouldReturnData({ + uuid: { parent: getTypenameAndId(taxonomyTermRoot) }, + }) + }) + + test('by id (w/ children)', async () => { + given('UuidQuery').for(taxonomyTermCurriculumTopic) + + await client + .prepareQuery({ + query: gql` + query taxonomyTerm($id: Int!) { + uuid(id: $id) { + ... on TaxonomyTerm { + children { + nodes { + __typename + ... on TaxonomyTerm { + id + } + } + totalCount + } + } + } + } + `, + }) + .withVariables({ id: taxonomyTermSubject.id }) + .shouldReturnData({ + uuid: { + children: { + nodes: [getTypenameAndId(taxonomyTermCurriculumTopic)], + totalCount: 1, + }, + }, + }) }) }) -test('TaxonomyTerm subject', async () => { - await taxonomyTermQuery.withVariables({ id: 18230 }).shouldReturnData({ - uuid: { - __typename: 'TaxonomyTerm', - id: 18230, - trashed: false, - type: 'subject', - instance: 'de', - alias: '/chemie/18230/chemie', - title: 'Chemie', - name: 'Chemie', - description: '', - weight: 17, - taxonomyId: 3, - path: [], - parent: { id: 3 }, - children: { - nodes: [ - { id: 21069 }, - { id: 18232 }, - { id: 18884 }, - { id: 18885 }, - { id: 18886 }, - { id: 23256 }, - { id: 18887 }, - { id: 18888 }, - ], - }, - }, +describe('TaxonomyTerm curriculumTopic', () => { + beforeEach(() => { + given('UuidQuery').for(taxonomyTermCurriculumTopic) + }) + + test('by id', async () => { + await client + .prepareQuery({ + query: gql` + query taxonomyTerm($id: Int!) { + uuid(id: $id) { + __typename + ... on TaxonomyTerm { + id + } + } + } + `, + }) + .withVariables({ id: taxonomyTermCurriculumTopic.id }) + .shouldReturnData({ uuid: getTypenameAndId(taxonomyTermCurriculumTopic) }) + }) + + test('by id (w/ parent)', async () => { + given('UuidQuery').for(taxonomyTermSubject) + + await client + .prepareQuery({ + query: gql` + query taxonomyTerm($id: Int!) { + uuid(id: $id) { + ... on TaxonomyTerm { + parent { + __typename + id + } + } + } + } + `, + }) + .withVariables({ id: taxonomyTermCurriculumTopic.id }) + .shouldReturnData({ + uuid: { parent: getTypenameAndId(taxonomyTermSubject) }, + }) + }) + + test('by id (w/ path)', async () => { + given('UuidQuery').for(taxonomyTermSubject) + + await client + .prepareQuery({ + query: gql` + query taxonomyTerm($id: Int!) { + uuid(id: $id) { + ... on TaxonomyTerm { + path { + __typename + id + } + } + } + } + `, + }) + .withVariables({ id: taxonomyTermCurriculumTopic.id }) + .shouldReturnData({ + uuid: { + path: [getTypenameAndId(taxonomyTermSubject)], + }, + }) + }) + + test('by id (w/ children)', async () => { + given('UuidQuery').for(article) + + await client + .prepareQuery({ + query: gql` + query taxonomyTerm($id: Int!) { + uuid(id: $id) { + ... on TaxonomyTerm { + children { + nodes { + __typename + id + } + } + } + } + } + `, + }) + .withVariables({ id: taxonomyTermCurriculumTopic.id }) + .shouldReturnData({ + uuid: { children: { nodes: [getTypenameAndId(article)] } }, + }) }) }) -test('TaxonomyTerm exerciseFolder', async () => { - await taxonomyTermQuery.withVariables({ id: 35562 }).shouldReturnData({ - uuid: { - __typename: 'TaxonomyTerm', - id: 35562, - trashed: false, - type: 'exerciseFolder', - instance: 'en', - alias: '/math/35562/example-topic-folder', - title: 'Example topic folder', - name: 'Example topic folder', - description: '', - weight: 1, - taxonomyId: 19, - path: [{ id: 23590 }, { id: 23593 }, { id: 35559 }, { id: 35560 }], - parent: { - id: 35560, - }, - children: { - nodes: [{ id: 35573 }, { id: 35579 }, { id: 35580 }], - }, - }, +describe('TaxonomyTerm exerciseFolder', () => { + beforeEach(() => { + given('UuidQuery').for(taxonomyTermTopicFolder) + given('UuidQuery').for(taxonomyTermTopic) + }) + + test('by id (check changed type)', async () => { + await client + .prepareQuery({ + query: gql` + query taxonomyTerm($id: Int!) { + uuid(id: $id) { + ... on TaxonomyTerm { + type + } + } + } + `, + }) + .withVariables({ id: taxonomyTermTopicFolder.id }) + .shouldReturnData({ + uuid: { type: 'exerciseFolder' }, + }) }) }) diff --git a/__tests__/schema/uuid/user.ts b/__tests__/schema/uuid/user.ts index eb9bddc2c..dd711eb67 100644 --- a/__tests__/schema/uuid/user.ts +++ b/__tests__/schema/uuid/user.ts @@ -276,9 +276,9 @@ describe('User', () => { }) test('by id (w/ activeAuthor when user is not an active author', async () => { - await query - .withVariables({ id: user2.id }) - .shouldReturnData({ uuid: { isActiveAuthor: false } }) + query.changeInput({ id: user2.id }) + + await query.shouldReturnData({ uuid: { isActiveAuthor: false } }) }) }) diff --git a/docker-compose.kratos.yml b/docker-compose.kratos.yml index 490ce0a51..b50b5f6fe 100644 --- a/docker-compose.kratos.yml +++ b/docker-compose.kratos.yml @@ -2,7 +2,7 @@ services: kratos-migrate: depends_on: - postgres - image: oryd/kratos:v1.1.0 + image: oryd/kratos:v1.0.0 volumes: - ./kratos:/etc/config/kratos:z command: -c /etc/config/kratos/config.yml migrate sql -e --yes @@ -10,7 +10,7 @@ services: kratos: depends_on: - kratos-migrate - image: oryd/kratos:v1.1.0 + image: oryd/kratos:v1.0.0 ports: - '4433:4433' # public - '4434:4434' # admin @@ -38,3 +38,13 @@ services: ports: - '4436:4436' - '4437:4437' + nbp: + image: quay.io/keycloak/keycloak:21.0.0 + ports: + - 11111:11111 + environment: + - KEYCLOAK_ADMIN=admin + - KEYCLOAK_ADMIN_PASSWORD=admin + command: ['start-dev', '--http-port=11111'] + extra_hosts: + - 'host.docker.internal:host-gateway' diff --git a/docker-compose.sso.yml b/docker-compose.sso.yml deleted file mode 100644 index 4a19aaeab..000000000 --- a/docker-compose.sso.yml +++ /dev/null @@ -1,21 +0,0 @@ -services: - nbp: - image: quay.io/keycloak/keycloak:21.0.0 - ports: - - 11111:11111 - environment: - - KEYCLOAK_ADMIN=admin - - KEYCLOAK_ADMIN_PASSWORD=admin - command: ['start-dev', '--http-port=11111'] - extra_hosts: - - 'host.docker.internal:host-gateway' - vidis: - image: quay.io/keycloak/keycloak:21.0.0 - ports: - - 11112:11112 - environment: - - KEYCLOAK_ADMIN=admin - - KEYCLOAK_ADMIN_PASSWORD=admin - command: ['start-dev', '--http-port=11112'] - extra_hosts: - - 'host.docker.internal:host-gateway' diff --git a/kratos/config.yml b/kratos/config.yml index 5ecf559ff..8b1c08f0f 100644 --- a/kratos/config.yml +++ b/kratos/config.yml @@ -32,12 +32,6 @@ selfservice: client_secret: H8t6WKWtGwFjqNfuAjqxrwCfsdznMAfj issuer_url: http://nbp:11111/realms/master mapper_url: file:///etc/config/kratos/user_mapper.jsonnet - - id: vidis - provider: generic - client_id: serlo - client_secret: H8t6WKWtGwFjqNfuAjqxrwCfsdznMAfj - issuer_url: http://vidis:11112/realms/master - mapper_url: file:///etc/config/kratos/user_mapper.jsonnet flows: error: diff --git a/package.json b/package.json index 4befd21f2..837e2e386 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,7 @@ "docker:stop": "run-p --continue-on-error --print-label \"docker:stop:*\"", "docker:stop:server": "docker stop api-server-from-local-build && docker container rm api-server-from-local-build", "docker:stop:swr-queue-worker": "docker stop api-swr-queue-worker-from-local-build && docker container rm api-swr-queue-worker-from-local-build", - "down": "docker compose -f docker-compose.sso.yml -f docker-compose.kratos.yml -f docker-compose.yml down", + "down": "docker compose down", "format": "npm-run-all -c \"format:*\"", "format:eslint": "yarn _eslint --fix", "format:prettier": "yarn _prettier --write", @@ -62,11 +62,11 @@ "start:enmeshed": "docker-compose -f enmeshed/docker-compose.yml up -d", "start:containers": "docker compose up --detach", "start:kratos": "docker compose -f docker-compose.kratos.yml up --detach", - "start:sso": "docker compose -f docker-compose.sso.yml up --detach", "start:server": "yarn _start packages/server/src/server.ts server.cjs", "start:swr-queue-worker": "yarn _start packages/server/src/swr-queue-worker.ts swr-queue-worker.cjs", - "stop": "docker compose -f docker-compose.sso.yml -f docker-compose.kratos.yml -f docker-compose.yml stop", + "stop": "docker compose stop", "stop:enmeshed": "docker-compose -f enmeshed/docker-compose.yml down", + "stop:containers": "docker compose stop", "test": "yarn _jest --config jest.config.js --forceExit", "test:docker:server": "run-s \"test:docker:server:*\"", "test:docker:server:playground": "curl --verbose http://localhost:3001/___graphql | grep 'GraphQL Playground'", diff --git a/packages/server/package.json b/packages/server/package.json index 86af54e5c..d1be3d57b 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -18,8 +18,7 @@ "deploy:image:production": "node --loader ts-node/esm --experimental-specifier-resolution=node scripts/deploy-env.js production" }, "dependencies": { - "bee-queue": "^1.7.1", - "bull-arena": "^4.4.0" + "bee-queue": "^1.7.1" }, "devDependencies": { "@apollo/server": "^4.10.4", @@ -42,6 +41,7 @@ "@types/uuid": "^9.0.8", "apollo-datasource-rest": "^3.7.0", "basic-auth": "^2.0.1", + "bull-arena": "^4.4.0", "default-import": "^1.1.5", "dotenv": "^16.4.5", "express": "^4.19.2", diff --git a/packages/server/src/cached-resolver.ts b/packages/server/src/cached-resolver.ts index a7a7e3258..a9362d4bb 100644 --- a/packages/server/src/cached-resolver.ts +++ b/packages/server/src/cached-resolver.ts @@ -78,7 +78,7 @@ interface ResolverSpec { getPayload: (key: string) => O.Option getCurrentValue: ( args: Payload, - context: Pick, + context: Pick, ) => Promise enableSwr: boolean swrFrequency?: number diff --git a/packages/server/src/database.ts b/packages/server/src/database.ts index 9b6607858..29468f90b 100644 --- a/packages/server/src/database.ts +++ b/packages/server/src/database.ts @@ -112,10 +112,10 @@ export class Database { public async fetchOptional( sql: string, params?: unknown[], - ): Promise { + ): Promise { const [result] = await this.execute<(T & RowDataPacket)[]>(sql, params) - return result ?? null + return result } public async fetchOne( diff --git a/packages/server/src/internals/server/kratos-middleware.ts b/packages/server/src/internals/server/kratos-middleware.ts index 06e45feb0..551658400 100644 --- a/packages/server/src/internals/server/kratos-middleware.ts +++ b/packages/server/src/internals/server/kratos-middleware.ts @@ -37,11 +37,7 @@ export function applyKratosMiddleware({ ) { app.post( `${basePath}/single-logout`, - createKratosRevokeSessionsHandler(kratos, 'nbp'), - ) - app.post( - `${basePath}/single-logout-vidis`, - createKratosRevokeSessionsHandler(kratos, 'vidis'), + createKratosRevokeSessionsHandler(kratos), ) } return basePath @@ -141,10 +137,7 @@ function createKratosRegisterHandler(kratos: Kratos): RequestHandler { } } -function createKratosRevokeSessionsHandler( - kratos: Kratos, - provider: 'nbp' | 'vidis', -): RequestHandler { +function createKratosRevokeSessionsHandler(kratos: Kratos): RequestHandler { function sendErrorResponse(response: Response, message: string) { // see https://openid.net/specs/openid-connect-backchannel-1_0.html#BCResponse response.set('Cache-Control', 'no-store').status(400).send(message) @@ -165,9 +158,7 @@ function createKratosRevokeSessionsHandler( return } - const id = await kratos.db.getIdByCredentialIdentifier( - `${provider}:${sub}`, - ) + const id = await kratos.db.getIdByCredentialIdentifier(`nbp:${sub}`) if (!id) { sendErrorResponse(response, 'user not found or not valid') diff --git a/packages/server/src/internals/swr-queue.ts b/packages/server/src/internals/swr-queue.ts index 2238288da..a1c3ce7a0 100644 --- a/packages/server/src/internals/swr-queue.ts +++ b/packages/server/src/internals/swr-queue.ts @@ -4,7 +4,7 @@ import * as t from 'io-ts' import * as R from 'ramda' import { isLegacyQuery, LegacyQuery } from './data-source-helper' -import { CachedResolver } from '~/cached-resolver' +import { type Context } from '~/context' import { createAuthServices } from '~/context/auth-services' import { CacheEntry, Cache, Priority } from '~/context/cache' import { SwrQueue } from '~/context/swr-queue' @@ -12,7 +12,7 @@ import { Database } from '~/database' import { captureErrorEvent } from '~/error-event' import { modelFactories } from '~/model' import { cachedResolvers } from '~/schema' -import { Timer, timeToSeconds, timeToMilliseconds } from '~/timer' +import { Timer, Time, timeToSeconds, timeToMilliseconds } from '~/timer' const INVALID_VALUE_RECEIVED = 'SWR-Queue: Invalid value received from data source.' @@ -161,8 +161,6 @@ export function createSwrQueueWorker({ removeOnSuccess: true, }) - const swrQueue = createSwrQueue({ cache, timer, database }) - queue.process(concurrency, async (job): Promise => { async function processJob() { const { key } = job.data @@ -186,12 +184,7 @@ export function createSwrQueueWorker({ source: 'SWR worker', priority: Priority.Low, getValue: async () => { - const value = await spec.getCurrentValue(payload, { - database, - cache, - timer, - swrQueue, - }) + const value = await spec.getCurrentValue(payload, { database }) if (spec.decoder.is(value)) { return value @@ -260,7 +253,6 @@ async function shouldProcessJob({ for (const legacyQuery of legacyQueries) { if (O.isSome(legacyQuery._querySpec.getPayload(key))) { return { - name: legacyQuery._querySpec.type, ...legacyQuery._querySpec, decoder: legacyQuery._querySpec.decoder ?? t.unknown, } @@ -269,7 +261,7 @@ async function shouldProcessJob({ for (const cachedResolver of cachedResolvers) { if (O.isSome(cachedResolver.spec.getPayload(key))) { // TODO: Change types so that `as` is not needed here - return cachedResolver.spec + return cachedResolver.spec as unknown as JobSpec } } return null @@ -305,7 +297,18 @@ async function shouldProcessJob({ }) } -type JobSpec = CachedResolver['spec'] +// TODO: Merge with CachedResolverSpec in `cached-resolver.ts` +interface JobSpec

{ + decoder: { is: (a: unknown) => a is P; name: string } + getPayload: (key: string) => O.Option

+ getCurrentValue: ( + payload: P, + context: Pick, + ) => Promise + maxAge?: Time + staleAfter?: Time + enableSwr: boolean +} function reportError({ error, diff --git a/packages/server/src/model/database-layer.ts b/packages/server/src/model/database-layer.ts index 013b337ac..95eff55e8 100644 --- a/packages/server/src/model/database-layer.ts +++ b/packages/server/src/model/database-layer.ts @@ -10,6 +10,7 @@ import { NotificationEventDecoder, PageDecoder, SubscriptionsDecoder, + TaxonomyTermDecoder, UuidDecoder, } from './decoder' import { UserInputError } from '~/errors' @@ -197,6 +198,54 @@ export const spec = { response: t.void, canBeNull: false, }, + TaxonomyCreateEntityLinksMutation: { + payload: t.type({ + entityIds: t.array(t.number), + taxonomyTermId: t.number, + userId: t.number, + }), + response: t.strict({ success: t.literal(true) }), + canBeNull: false, + }, + TaxonomyDeleteEntityLinksMutation: { + payload: t.type({ + entityIds: t.array(t.number), + taxonomyTermId: t.number, + userId: t.number, + }), + response: t.strict({ success: t.literal(true) }), + canBeNull: false, + }, + TaxonomyTermCreateMutation: { + payload: t.type({ + taxonomyType: t.union([t.literal('topic'), t.literal('topic-folder')]), + name: t.string, + userId: t.number, + description: t.union([t.string, t.null, t.undefined]), + parentId: t.number, + }), + response: TaxonomyTermDecoder, + canBeNull: false, + }, + TaxonomySortMutation: { + payload: t.type({ + childrenIds: t.array(t.number), + taxonomyTermId: t.number, + userId: t.number, + }), + response: t.type({ success: t.boolean }), + canBeNull: false, + }, + TaxonomyTermSetNameAndDescriptionMutation: { + payload: t.type({ + name: t.string, + id: t.number, + userId: t.number, + description: t.union([t.string, t.null, t.undefined]), + }), + response: t.type({ success: t.boolean }), + canBeNull: false, + }, ThreadCreateCommentMutation: { payload: t.type({ content: t.string, @@ -278,6 +327,14 @@ export const spec = { }), canBeNull: false, }, + UserDeleteRegularUsersMutation: { + payload: t.type({ userId: t.number }), + response: t.union([ + t.type({ success: t.literal(true) }), + t.type({ success: t.literal(false), reason: t.string }), + ]), + canBeNull: false, + }, UserPotentialSpamUsersQuery: { payload: t.type({ first: t.number, after: t.union([t.number, t.null]) }), response: t.type({ userIds: t.array(t.number) }), @@ -290,6 +347,20 @@ export const spec = { }), canBeNull: false, }, + UserSetEmailMutation: { + payload: t.type({ userId: t.number, email: t.string }), + response: t.type({ success: t.boolean, username: t.string }), + canBeNull: false, + }, + UuidSetStateMutation: { + payload: t.type({ + ids: t.array(t.number), + userId: t.number, + trashed: t.boolean, + }), + response: t.void, + canBeNull: false, + }, UuidQuery: { payload: t.type({ id: t.number }), response: UuidDecoder, diff --git a/packages/server/src/model/serlo.ts b/packages/server/src/model/serlo.ts index 83da66332..908e25fe2 100644 --- a/packages/server/src/model/serlo.ts +++ b/packages/server/src/model/serlo.ts @@ -25,6 +25,20 @@ export function createSerloModel({ }: { context: Pick }) { + const setUuidState = createMutation({ + type: 'UuidSetStateMutation', + decoder: DatabaseLayer.getDecoderFor('UuidSetStateMutation'), + mutate(payload: DatabaseLayer.Payload<'UuidSetStateMutation'>) { + return DatabaseLayer.makeRequest('UuidSetStateMutation', payload) + }, + async updateCache({ ids }) { + await UuidResolver.removeCacheEntries( + ids.map((id) => ({ id })), + context, + ) + }, + }) + const getActiveReviewerIds = createLegacyQuery( { type: 'ActiveReviewersQuery', @@ -98,6 +112,32 @@ export function createSerloModel({ }, }) + const deleteRegularUsers = createMutation({ + type: 'UserDeleteRegularUsersMutation', + decoder: DatabaseLayer.getDecoderFor('UserDeleteRegularUsersMutation'), + mutate: ( + payload: DatabaseLayer.Payload<'UserDeleteRegularUsersMutation'>, + ) => { + return DatabaseLayer.makeRequest( + 'UserDeleteRegularUsersMutation', + payload, + ) + }, + async updateCache({ userId }, { success }) { + if (success) { + await UuidResolver.removeCacheEntry({ id: userId }, context) + } + }, + }) + + const setEmail = createMutation({ + type: 'UserSetEmailMutation', + decoder: DatabaseLayer.getDecoderFor('UserSetEmailMutation'), + mutate(payload: DatabaseLayer.Payload<'UserSetEmailMutation'>) { + return DatabaseLayer.makeRequest('UserSetEmailMutation', payload) + }, + }) + const getAlias = createLegacyQuery( { type: 'AliasQuery', @@ -544,6 +584,57 @@ export function createSerloModel({ }, }) + const linkEntitiesToTaxonomy = createMutation({ + type: 'TaxonomyCreateEntityLinksMutation', + decoder: DatabaseLayer.getDecoderFor('TaxonomyCreateEntityLinksMutation'), + mutate: ( + payload: DatabaseLayer.Payload<'TaxonomyCreateEntityLinksMutation'>, + ) => { + return DatabaseLayer.makeRequest( + 'TaxonomyCreateEntityLinksMutation', + payload, + ) + }, + async updateCache({ taxonomyTermId, entityIds }, { success }) { + if (success) { + const payloads = [...entityIds, taxonomyTermId].map((id) => ({ id })) + await UuidResolver.removeCacheEntries(payloads, context) + } + }, + }) + + const unlinkEntitiesFromTaxonomy = createMutation({ + type: 'TaxonomyCreateEntityLinksMutation', + decoder: DatabaseLayer.getDecoderFor('TaxonomyDeleteEntityLinksMutation'), + mutate: ( + payload: DatabaseLayer.Payload<'TaxonomyDeleteEntityLinksMutation'>, + ) => { + return DatabaseLayer.makeRequest( + 'TaxonomyDeleteEntityLinksMutation', + payload, + ) + }, + async updateCache({ taxonomyTermId, entityIds }, { success }) { + if (success) { + const payloads = [...entityIds, taxonomyTermId].map((id) => ({ id })) + await UuidResolver.removeCacheEntries(payloads, context) + } + }, + }) + + const createTaxonomyTerm = createMutation({ + type: 'TaxonomyTermCreateMutation', + decoder: DatabaseLayer.getDecoderFor('TaxonomyTermCreateMutation'), + mutate: (payload: DatabaseLayer.Payload<'TaxonomyTermCreateMutation'>) => { + return DatabaseLayer.makeRequest('TaxonomyTermCreateMutation', payload) + }, + async updateCache({ parentId }) { + if (parentId) { + await UuidResolver.removeCacheEntry({ id: parentId }, context) + } + }, + }) + const sortEntity = createMutation({ type: 'EntitySortMutation', decoder: DatabaseLayer.getDecoderFor('EntitySortMutation'), @@ -558,6 +649,20 @@ export function createSerloModel({ }, }) + const sortTaxonomyTerm = createMutation({ + type: 'TaxonomySortMutation', + decoder: DatabaseLayer.getDecoderFor('TaxonomySortMutation'), + mutate: (payload: DatabaseLayer.Payload<'TaxonomySortMutation'>) => { + return DatabaseLayer.makeRequest('TaxonomySortMutation', payload) + }, + + async updateCache({ taxonomyTermId }, { success }) { + if (success) { + await UuidResolver.removeCacheEntry({ id: taxonomyTermId }, context) + } + }, + }) + const setEntityLicense = createMutation({ type: 'EntitySetLicenseMutation', decoder: DatabaseLayer.getDecoderFor('EntitySetLicenseMutation'), @@ -579,6 +684,26 @@ export function createSerloModel({ }, }) + const setTaxonomyTermNameAndDescription = createMutation({ + type: 'TaxonomyTermSetNameAndDescriptionMutation', + decoder: DatabaseLayer.getDecoderFor( + 'TaxonomyTermSetNameAndDescriptionMutation', + ), + mutate: ( + payload: DatabaseLayer.Payload<'TaxonomyTermSetNameAndDescriptionMutation'>, + ) => { + return DatabaseLayer.makeRequest( + 'TaxonomyTermSetNameAndDescriptionMutation', + payload, + ) + }, + async updateCache({ id }, { success }) { + if (success) { + await UuidResolver.removeCacheEntry({ id }, context) + } + }, + }) + const addRole = createMutation({ type: 'UsersByRoleQuery', decoder: DatabaseLayer.getDecoderFor('UserAddRoleMutation'), @@ -615,8 +740,10 @@ export function createSerloModel({ createComment, createEntity, createPage, + createTaxonomyTerm, createThread, deleteBots, + deleteRegularUsers, executePrompt, getActiveReviewerIds, getActivityByType, @@ -629,11 +756,17 @@ export function createSerloModel({ getUnrevisedEntities, getUnrevisedEntitiesPerSubject, getUsersByRole, + linkEntitiesToTaxonomy, getPages, rejectEntityRevision, + setEmail, setEntityLicense, setSubscription, + setTaxonomyTermNameAndDescription, sortEntity, + sortTaxonomyTerm, + setUuidState, + unlinkEntitiesFromTaxonomy, } } diff --git a/packages/server/src/model/types.ts b/packages/server/src/model/types.ts index edce6f317..51baa4c24 100644 --- a/packages/server/src/model/types.ts +++ b/packages/server/src/model/types.ts @@ -149,11 +149,6 @@ export interface Models { record: t.TypeOf | null query: Record } - TaxonomyTermCreateResponse: { - success: boolean - record: t.TypeOf | null - query: Record - } } enum Role { diff --git a/packages/server/src/schema/authorization/utils.ts b/packages/server/src/schema/authorization/utils.ts index 719080c4a..b0b3c6e12 100644 --- a/packages/server/src/schema/authorization/utils.ts +++ b/packages/server/src/schema/authorization/utils.ts @@ -3,7 +3,6 @@ import { instanceToScope, Scope, } from '@serlo/authorization' -import * as t from 'io-ts' import { UuidResolver } from '../uuid/abstract-uuid/resolvers' import { Context } from '~/context' @@ -14,7 +13,6 @@ import { EntityRevisionDecoder, PageRevisionDecoder, UserDecoder, - UuidDecoder, } from '~/model/decoder' import { resolveRolesPayload, RolesPayload } from '~/schema/authorization/roles' import { isInstance, isInstanceAware } from '~/schema/instance/utils' @@ -63,41 +61,25 @@ export async function fetchScopeOfUuid( if (object === null) throw new UserInputError('UUID does not exist.') - const instance = await fetchInstance(object, context) - - return instance != null ? instanceToScope(instance) : Scope.Serlo -} - -export function resolveScopedRoles(user: Model<'User'>): Model<'ScopedRole'>[] { - return user.roles.map(legacyRoleToRole).filter(isDefined) -} - -export async function fetchInstance( - object: t.TypeOf | null, - context: Context, -) { - if (object == null) return null - // If the object has an instance, return the corresponding scope if (isInstanceAware(object)) { - return object.instance + return instanceToScope(object.instance) } // Comments and Threads don't have an instance itself, but their object descendant has if (object.__typename === DiscriminatorType.Comment) { - const parent = await UuidResolver.resolve({ id: object.parentId }, context) - return await fetchInstance(parent, context) + return await fetchScopeOfUuid({ id: object.parentId }, context) } if (EntityRevisionDecoder.is(object) || PageRevisionDecoder.is(object)) { - const repository = await UuidResolver.resolve( - { id: object.repositoryId }, - context, - ) - return await fetchInstance(repository, context) + return await fetchScopeOfUuid({ id: object.repositoryId }, context) } - return null + return Scope.Serlo +} + +export function resolveScopedRoles(user: Model<'User'>): Model<'ScopedRole'>[] { + return user.roles.map(legacyRoleToRole).filter(isDefined) } function legacyRoleToRole(role: string): Model<'ScopedRole'> | null { diff --git a/packages/server/src/schema/cache/resolvers.ts b/packages/server/src/schema/cache/resolvers.ts index b6f76e454..8349424ae 100644 --- a/packages/server/src/schema/cache/resolvers.ts +++ b/packages/server/src/schema/cache/resolvers.ts @@ -8,7 +8,6 @@ const allowedUserIds = [ 32543, // botho 178807, // HugoBT 245844, // MoeHome - 269930, // MikeySerlo ] export const resolvers: Resolvers = { diff --git a/packages/server/src/schema/index.ts b/packages/server/src/schema/index.ts index 378f5dc08..6716ca0d0 100644 --- a/packages/server/src/schema/index.ts +++ b/packages/server/src/schema/index.ts @@ -12,7 +12,6 @@ import { notificationsSchema } from './notifications' import { oauthSchema } from './oauth' import { rolesSchema } from './roles' import { subjectsSchema } from './subject' -import { SubjectResolver } from './subject/resolvers' import { subscriptionSchema } from './subscription' import { threadSchema } from './thread' import { uuidCachedResolvers, uuidSchema } from './uuid' @@ -42,9 +41,7 @@ export const schema = mergeSchemas( ) // TODO: Fix the following type error +// @ts-expect-error Unfortunately typecasting does not work here export const cachedResolvers: Array> = [ - // @ts-expect-error Unfortunately typecasting does not work here ...uuidCachedResolvers, - // @ts-expect-error Unfortunately typecasting does not work here - SubjectResolver, ] diff --git a/packages/server/src/schema/subject/resolvers.ts b/packages/server/src/schema/subject/resolvers.ts index 971fc6a77..713876ea9 100644 --- a/packages/server/src/schema/subject/resolvers.ts +++ b/packages/server/src/schema/subject/resolvers.ts @@ -24,10 +24,15 @@ export const SubjectsResolver = createCachedResolver({ }, examplePayload: undefined, async getCurrentValue(_, { database }) { - const rows = await database.fetchAll( + interface Row { + id: number + instance: string + } + + const rows = await database.fetchAll( ` SELECT - subject.id as taxonomyTermId, + subject.id, subject_instance.subdomain as instance FROM term_taxonomy AS subject JOIN term_taxonomy AS root ON root.id = subject.parent_id @@ -46,7 +51,15 @@ export const SubjectsResolver = createCachedResolver({ `, ) - return rows.filter(SubjectDecoder.is) + return rows + .map((row) => { + const { id, instance } = row + return { + taxonomyTermId: id, + instance, + } + }) + .filter(SubjectDecoder.is) }, }) @@ -93,66 +106,3 @@ export const resolvers: Resolvers = { }, }, } - -export const SubjectResolver = createCachedResolver({ - name: 'SubjectQuery', - decoder: t.union([t.null, t.type({ name: t.string, id: t.number })]), - enableSwr: true, - staleAfter: { days: 14 }, - maxAge: { days: 90 }, - getKey: ({ taxonomyId }) => { - return `subject/${taxonomyId}` - }, - getPayload: (key) => { - if (!key.startsWith('subject/')) return O.none - const taxonomyId = parseInt(key.replace('subject/', ''), 10) - return O.some({ taxonomyId }) - }, - examplePayload: { taxonomyId: 1 }, - async getCurrentValue({ taxonomyId }, { database }) { - interface Row { - name: string - id: number - } - - return await database.fetchOptional( - ` - SELECT t.name as name, t1.id as id - FROM term_taxonomy t0 - JOIN term_taxonomy t1 ON t1.parent_id = t0.id - LEFT JOIN term_taxonomy t2 ON t2.parent_id = t1.id - LEFT JOIN term_taxonomy t3 ON t3.parent_id = t2.id - LEFT JOIN term_taxonomy t4 ON t4.parent_id = t3.id - LEFT JOIN term_taxonomy t5 ON t5.parent_id = t4.id - LEFT JOIN term_taxonomy t6 ON t6.parent_id = t5.id - LEFT JOIN term_taxonomy t7 ON t7.parent_id = t6.id - LEFT JOIN term_taxonomy t8 ON t8.parent_id = t7.id - LEFT JOIN term_taxonomy t9 ON t9.parent_id = t8.id - LEFT JOIN term_taxonomy t10 ON t10.parent_id = t9.id - LEFT JOIN term_taxonomy t11 ON t11.parent_id = t10.id - LEFT JOIN term_taxonomy t12 ON t12.parent_id = t11.id - LEFT JOIN term_taxonomy t13 ON t13.parent_id = t12.id - LEFT JOIN term_taxonomy t14 ON t14.parent_id = t13.id - LEFT JOIN term_taxonomy t15 ON t15.parent_id = t14.id - LEFT JOIN term_taxonomy t16 ON t16.parent_id = t15.id - LEFT JOIN term_taxonomy t17 ON t17.parent_id = t16.id - LEFT JOIN term_taxonomy t18 ON t18.parent_id = t17.id - LEFT JOIN term_taxonomy t19 ON t19.parent_id = t18.id - LEFT JOIN term_taxonomy t20 ON t20.parent_id = t19.id - JOIN term t on t1.term_id = t.id - WHERE - ( - t0.id = 146728 OR - t0.id = 106081 OR - (t0.parent_id IS NULL AND t2.id != 146728 AND t1.id != 106081) - ) AND ( - t1.id = ? OR t2.id = ? OR t3.id = ? OR t4.id = ? OR t5.id = ? OR - t6.id = ? OR t7.id = ? OR t8.id = ? OR t9.id = ? OR t10.id = ? OR - t11.id = ? OR t12.id = ? OR t13.id = ? OR t14.id = ? OR t15.id = ? - OR t16.id = ? OR t17.id = ? OR t18.id = ? OR t19.id = ? OR t20.id = ? - ) - `, - new Array(20).fill(taxonomyId), - ) - }, -}) diff --git a/packages/server/src/schema/thread/resolvers.ts b/packages/server/src/schema/thread/resolvers.ts index 95695dc0c..66a181b5c 100644 --- a/packages/server/src/schema/thread/resolvers.ts +++ b/packages/server/src/schema/thread/resolvers.ts @@ -7,7 +7,6 @@ import { encodeThreadId, resolveThreads, } from './utils' -import { createEvent } from '../events/event' import { Context } from '~/context' import { ForbiddenError, UserInputError } from '~/errors' import { @@ -19,17 +18,13 @@ import { import { CommentDecoder, DiscriminatorType, - NotificationEventType, UserDecoder, UuidDecoder, } from '~/model/decoder' -import { fetchInstance, fetchScopeOfUuid } from '~/schema/authorization/utils' +import { fetchScopeOfUuid } from '~/schema/authorization/utils' import { resolveConnection } from '~/schema/connection/utils' import { decodeSubjectId } from '~/schema/subject/utils' -import { - UuidResolver, - setUuidState, -} from '~/schema/uuid/abstract-uuid/resolvers' +import { UuidResolver } from '~/schema/uuid/abstract-uuid/resolvers' import { createUuidResolvers } from '~/schema/uuid/abstract-uuid/utils' import { CommentStatus, Resolvers } from '~/types' @@ -330,7 +325,7 @@ export const resolvers: Resolvers = { return { success: true, query: {} } }, async setThreadState(_parent, payload, context) { - const { database, userId } = context + const { dataSources, userId } = context const { trashed } = payload.input const ids = decodeThreadIds(payload.input.id) @@ -347,45 +342,12 @@ export const resolvers: Resolvers = { context, }) - const transaction = await database.beginTransaction() - - try { - for (const id of ids) { - const comment = await UuidResolver.resolve({ id }, context) - - if (comment?.__typename !== DiscriminatorType.Comment) { - throw new UserInputError(`${id} is no comment`) - } - - const instance = await fetchInstance(comment, context) + await dataSources.model.serlo.setUuidState({ ids, userId, trashed }) - if (instance == null) { - throw new UserInputError('comment must have an instance') - } - - await setUuidState({ id, trashed }, context) - - await createEvent( - { - __typename: NotificationEventType.SetThreadState, - actorId: userId, - archived: trashed, - threadId: comment.id, - instance, - }, - context, - ) - } - - await transaction.commit() - - return { success: true, query: {} } - } finally { - await transaction.rollback() - } + return { success: true, query: {} } }, async setCommentState(_parent, payload, context) { - const { userId } = context + const { dataSources, userId } = context const { id: ids, trashed } = payload.input const scopes = await Promise.all( @@ -413,42 +375,9 @@ export const resolvers: Resolvers = { }) } - const transaction = await database.beginTransaction() - - try { - for (const id of ids) { - const comment = await UuidResolver.resolve({ id }, context) - - if (comment?.__typename !== DiscriminatorType.Comment) { - throw new UserInputError(`${id} is no comment`) - } - - const instance = await fetchInstance(comment, context) - - if (instance == null) { - throw new UserInputError('comment must have an instance') - } + await dataSources.model.serlo.setUuidState({ ids, trashed, userId }) - await setUuidState({ id, trashed }, context) - - await createEvent( - { - __typename: NotificationEventType.SetThreadState, - actorId: userId, - archived: trashed, - threadId: comment.id, - instance, - }, - context, - ) - } - - await transaction.commit() - - return { success: true, query: {} } - } finally { - await transaction.rollback() - } + return { success: true, query: {} } }, }, } diff --git a/packages/server/src/schema/uuid/abstract-uuid/resolvers.ts b/packages/server/src/schema/uuid/abstract-uuid/resolvers.ts index 5dc5b0e5c..c37e89d0d 100644 --- a/packages/server/src/schema/uuid/abstract-uuid/resolvers.ts +++ b/packages/server/src/schema/uuid/abstract-uuid/resolvers.ts @@ -2,7 +2,6 @@ import * as auth from '@serlo/authorization' import { option as O } from 'fp-ts' import * as t from 'io-ts' import { date } from 'io-ts-types/lib/date' -import * as R from 'ramda' import { createCachedResolver } from '~/cached-resolver' import { resolveCustomId } from '~/config' @@ -19,16 +18,13 @@ import { UuidDecoder, DiscriminatorType, EntityTypeDecoder, + EntityRevisionTypeDecoder, CommentStatusDecoder, - InstanceDecoder, - EntityRevisionDecoder, - PageRevisionDecoder, - NotificationEventType, } from '~/model/decoder' -import { createEvent } from '~/schema/events/event' -import { SubjectResolver } from '~/schema/subject/resolvers' +import { fetchScopeOfUuid } from '~/schema/authorization/utils' import { decodePath, encodePath } from '~/schema/uuid/alias/utils' -import { Resolvers, QueryUuidArgs, TaxonomyTermType } from '~/types' +import { Resolvers, QueryUuidArgs } from '~/types' +import { isDefined } from '~/utils' export const UuidResolver = createCachedResolver< { id: number }, @@ -81,71 +77,56 @@ export const resolvers: Resolvers = { }, UuidMutation: { async setState(_parent, payload, context) { - const { userId } = context + const { dataSources, userId } = context const { id, trashed } = payload.input const ids = id - assertUserIsAuthenticated(userId) - - const objects = await Promise.all( - ids.map((id) => UuidResolver.resolve({ id }, context)), - ) - - const transaction = await database.beginTransaction() - - try { - for (const object of objects) { - if ( - object === null || - object.__typename === DiscriminatorType.Comment || - object.__typename === DiscriminatorType.User || - EntityRevisionDecoder.is(object) || - PageRevisionDecoder.is(object) - ) { - throw new UserInputError( - 'One of the provided ids cannot be deleted', - ) + const guards = await Promise.all( + ids.map(async (id): Promise => { + // TODO: this is not optimized since it fetches the object twice and sequentially. + // change up fetchScopeOfUuid to return { scope, object } instead + const scope = await fetchScopeOfUuid({ id }, context) + const object = await UuidResolver.resolve({ id }, context) + if (object === null) { + return null + } else { + return auth.Uuid.setState(getType(object))(scope) } - const scope = auth.instanceToScope(object.instance) - const type = EntityTypeDecoder.is(object) - ? 'Entity' - : object.__typename === DiscriminatorType.Page - ? 'Page' - : 'TaxonomyTerm' - - await assertUserIsAuthorized({ - guard: auth.Uuid.setState(type)(scope), - message: - 'You are not allowed to set the state of the provided UUID(s).', - context, - }) - - await setUuidState({ id: object.id, trashed }, context) - - await createEvent( - { - __typename: NotificationEventType.SetUuidState, - actorId: userId, - instance: object.instance, - objectId: object.id, - trashed, - }, - context, - ) - } + function getType(object: Model<'AbstractUuid'>): auth.UuidType { + switch (object.__typename) { + case DiscriminatorType.Page: + return 'Page' + case DiscriminatorType.PageRevision: + return 'PageRevision' + case DiscriminatorType.TaxonomyTerm: + return 'TaxonomyTerm' + case DiscriminatorType.User: + return 'User' + default: + if (EntityTypeDecoder.is(object.__typename)) { + return 'Entity' + } + if (EntityRevisionTypeDecoder.is(object.__typename)) { + return 'EntityRevision' + } + return 'unknown' + } + } + }), + ) - await transaction.commit() + assertUserIsAuthenticated(userId) + await assertUserIsAuthorized({ + guards: guards.filter(isDefined), + message: + 'You are not allowed to set the state of the provided UUID(s).', + context, + }) - await UuidResolver.removeCacheEntries( - ids.map((id) => ({ id }), context), - context, - ) + await dataSources.model.serlo.setUuidState({ ids, userId, trashed }) - return { success: true, query: {} } - } finally { - await transaction.rollback() - } + return { success: true, query: {} } }, }, } @@ -153,63 +134,24 @@ export const resolvers: Resolvers = { // TODO: Move to util file databse.ts const Tinyint = t.union([t.literal(0), t.literal(1)]) -const BaseUuid = t.type({ +const BaseComment = t.type({ id: t.number, + discriminator: t.literal('comment'), trashed: Tinyint, + authorId: t.number, + title: t.string, + date: date, + archived: Tinyint, + content: t.string, + parentUuid: t.union([t.number, t.null]), + parentCommentId: t.union([t.number, t.null]), + status: t.union([CommentStatusDecoder, t.null]), + childrenIds: t.array(t.union([t.number, t.null])), }) -const WeightedNumberList = t.record( - t.union([t.literal('__no_key'), t.number]), - t.union([t.null, t.number]), -) - -const BaseComment = t.intersection([ - BaseUuid, - t.type({ - discriminator: t.literal('comment'), - commentAuthorId: t.number, - commentTitle: t.string, - commentDate: date, - commentArchived: Tinyint, - commentContent: t.string, - commentParentUuid: t.union([t.number, t.null]), - commentParentCommentId: t.union([t.number, t.null]), - commentStatus: t.union([CommentStatusDecoder, t.null]), - commentChildrenIds: WeightedNumberList, - }), -]) - -const BaseTaxonomy = t.intersection([ - BaseUuid, - t.type({ - discriminator: t.literal('taxonomyTerm'), - taxonomyInstance: InstanceDecoder, - taxonomyType: t.string, - taxonomyName: t.string, - taxonomyDescription: t.union([t.null, t.string]), - taxonomyWeight: t.union([t.null, t.number]), - taxonomyId: t.number, - taxonomyParentId: t.union([t.null, t.number]), - taxonomyChildrenIds: WeightedNumberList, - taxonomyEntityChildrenIds: WeightedNumberList, - }), -]) - -const BaseUser = t.intersection([ - BaseUuid, - t.type({ - discriminator: t.literal('user'), - userUsername: t.string, - userDate: date, - userLastLogin: date, - userDescription: t.string, - userRoles: t.array(t.string), - }), -]) - async function resolveUuidFromDatabase( { id }: { id: number }, - context: Pick, + context: Pick, ): Promise | null> { const baseUuid = await context.database.fetchOptional( ` @@ -217,128 +159,43 @@ async function resolveUuidFromDatabase( uuid.id as id, uuid.trashed, uuid.discriminator, - - comment.author_id AS commentAuthorId, - comment.title AS commentTitle, - comment.date AS commentDate, - comment.archived AS commentArchived, - comment.content AS commentContent, - comment.parent_id AS commentParentCommentId, - comment.uuid_id AS commentParentUuid, - JSON_OBJECTAGG( - COALESCE(comment_children.id, "__no_key"), - comment_children.id - ) AS commentChildrenIds, + comment.author_id as authorId, + comment.title as title, + comment.date as date, + comment.archived as archived, + comment.content as content, + comment.parent_id as parentCommentId, + comment.uuid_id as parentUuid, + JSON_ARRAYAGG(comment_children.id) as childrenIds, CASE WHEN comment_status.name = 'no_status' THEN 'noStatus' ELSE comment_status.name - END AS commentStatus, - - taxonomy_type.name AS taxonomyType, - taxonomy_instance.subdomain AS taxonomyInstance, - term.name AS taxonomyName, - term_taxonomy.description AS taxonomyDescription, - term_taxonomy.weight AS taxonomyWeight, - taxonomy.id AS taxonomyId, - term_taxonomy.parent_id AS taxonomyParentId, - JSON_OBJECTAGG( - COALESCE(taxonomy_child.id, "__no_key"), - taxonomy_child.weight - ) AS taxonomyChildrenIds, - JSON_OBJECTAGG( - COALESCE(term_taxonomy_entity.entity_id, "__no_key"), - term_taxonomy_entity.position - ) AS taxonomyEntityChildrenIds, - - user.username AS userUsername, - user.date AS userDate, - user.last_login AS userLastLogin, - user.description AS userDescription, - JSON_ARRAYAGG(role.name) AS userRoles - + END AS status FROM uuid - LEFT JOIN comment ON comment.id = uuid.id LEFT JOIN comment comment_children ON comment_children.parent_id = comment.id - LEFT JOIN comment_status ON comment_status.id = comment.comment_status_id - - LEFT JOIN term_taxonomy ON term_taxonomy.id = uuid.id - LEFT JOIN taxonomy ON taxonomy.id = term_taxonomy.taxonomy_id - LEFT JOIN type taxonomy_type ON taxonomy_type.id = taxonomy.type_id - LEFT JOIN instance taxonomy_instance ON taxonomy_instance.id = taxonomy.instance_id - LEFT JOIN term ON term.id = term_taxonomy.term_id - LEFT JOIN term_taxonomy taxonomy_child ON taxonomy_child.parent_id = term_taxonomy.id - LEFT JOIN term_taxonomy_entity ON term_taxonomy_entity.term_taxonomy_id = term_taxonomy.id - - LEFT JOIN user ON user.id = uuid.id - LEFT JOIN role_user ON user.id = role_user.user_id - LEFT JOIN role ON role.id = role_user.role_id - + LEFT JOIN comment_status on comment_status.id = comment.comment_status_id WHERE uuid.id = ? GROUP BY uuid.id `, [id], ) - if (BaseUuid.is(baseUuid)) { - const base = { id: baseUuid.id, trashed: Boolean(baseUuid.trashed) } - - if (BaseComment.is(baseUuid)) { - const parentId = - baseUuid.commentParentUuid ?? baseUuid.commentParentCommentId ?? null - - if (parentId == null) return null - - return { - ...base, - __typename: DiscriminatorType.Comment, - archived: Boolean(baseUuid.commentArchived), - parentId, - alias: `/${parentId}#comment-${baseUuid.id}`, - status: baseUuid.commentStatus ?? 'noStatus', - childrenIds: getSortedList(baseUuid.commentChildrenIds), - date: baseUuid.commentDate.toISOString(), - title: baseUuid.commentTitle, - authorId: baseUuid.commentAuthorId, - content: baseUuid.commentContent, - } - } else if (BaseTaxonomy.is(baseUuid)) { - const subject = await SubjectResolver.resolve( - { taxonomyId: baseUuid.id }, - context, - ) - const subjectName = - subject != null && subject.name.length > 0 ? subject.name : 'root' - const alias = `/${toSlug(subjectName)}/${baseUuid.id}/${toSlug(baseUuid.taxonomyName)}` - const childrenIds = [ - ...getSortedList(baseUuid.taxonomyChildrenIds), - ...getSortedList(baseUuid.taxonomyEntityChildrenIds), - ] - - return { - ...base, - __typename: DiscriminatorType.TaxonomyTerm, - instance: baseUuid.taxonomyInstance, - type: getTaxonomyTermType(baseUuid.taxonomyType), - alias, - name: baseUuid.taxonomyName, - description: baseUuid.taxonomyDescription, - weight: baseUuid.taxonomyWeight ?? 0, - taxonomyId: baseUuid.taxonomyId, - parentId: baseUuid.taxonomyParentId, - childrenIds, - } - } else if (BaseUser.is(baseUuid)) { - return { - ...base, - __typename: DiscriminatorType.User, - alias: `/user/${base.id}/${baseUuid.userUsername}`, - date: baseUuid.userDate.toISOString(), - description: baseUuid.userDescription, - lastLogin: baseUuid.userLastLogin.toISOString(), - roles: baseUuid.userRoles, - username: baseUuid.userUsername, - } + if (BaseComment.is(baseUuid)) { + const parentId = baseUuid.parentUuid ?? baseUuid.parentCommentId ?? null + + if (parentId == null) return null + + return { + ...baseUuid, + __typename: DiscriminatorType.Comment, + trashed: Boolean(baseUuid.trashed), + archived: Boolean(baseUuid.archived), + parentId, + alias: `/${parentId}#comment-${baseUuid.id}`, + status: baseUuid.status ?? 'noStatus', + childrenIds: baseUuid.childrenIds.filter(isDefined), + date: baseUuid.date.toISOString(), } } @@ -347,37 +204,6 @@ async function resolveUuidFromDatabase( return UuidDecoder.is(uuidFromDBLayer) ? uuidFromDBLayer : null } -export async function setUuidState( - { id, trashed }: { id: number; trashed: boolean }, - { database }: Pick, -) { - await database.mutate('update uuid set trashed = ? where id = ?', [ - trashed ? 1 : 0, - id, - ]) -} - -function getSortedList(listAsDict: t.TypeOf) { - const ids = Object.keys(listAsDict) - .map((x) => parseInt(x)) - .filter((x) => !isNaN(x)) - - return R.sortBy((x) => listAsDict[x] ?? 0, ids) -} - -function getTaxonomyTermType(type: string) { - switch (type) { - case 'subject': - return TaxonomyTermType.Subject - case 'root': - return TaxonomyTermType.Root - case 'topic-folder': - return 'topicFolder' - default: - return TaxonomyTermType.Topic - } -} - async function resolveIdFromPayload( dataSources: Context['dataSources'], payload: QueryUuidArgs, @@ -423,13 +249,3 @@ async function resolveIdFromAlias( return (await dataSources.model.serlo.getAlias(alias))?.id ?? null } - -function toSlug(name: string) { - return name - .toLowerCase() - .replace(/ /g, '-') // replace spaces with hyphens - .replace(/[^\w-]+/g, '') // remove all non-word chars including _ - .replace(/--+/g, '-') // replace multiple hyphens - .replace(/^-+/, '') // trim starting hyphen - .replace(/-+$/, '') // trim end hyphen -} diff --git a/packages/server/src/schema/uuid/taxonomy-term/resolvers.ts b/packages/server/src/schema/uuid/taxonomy-term/resolvers.ts index 756a38d52..2b9bfe7e9 100644 --- a/packages/server/src/schema/uuid/taxonomy-term/resolvers.ts +++ b/packages/server/src/schema/uuid/taxonomy-term/resolvers.ts @@ -2,7 +2,6 @@ import * as serloAuth from '@serlo/authorization' import { UuidResolver } from '../abstract-uuid/resolvers' import { Context } from '~/context' -import { InternalServerError, UserInputError } from '~/errors' import { createNamespace, assertUserIsAuthenticated, @@ -10,15 +9,9 @@ import { assertStringIsNotEmpty, Model, } from '~/internals/graphql' -import { - DiscriminatorType, - EntityDecoder, - EntityType, - NotificationEventType, - TaxonomyTermDecoder, -} from '~/model/decoder' +import { TaxonomyTermDecoder } from '~/model/decoder' +import { fetchScopeOfUuid } from '~/schema/authorization/utils' import { resolveConnection } from '~/schema/connection/utils' -import { createEvent } from '~/schema/events/event' import { createThreadResolvers } from '~/schema/thread/utils' import { createUuidResolvers } from '~/schema/uuid/abstract-uuid/utils' import { TaxonomyTermType, TaxonomyTypeCreateOptions, Resolvers } from '~/types' @@ -86,296 +79,86 @@ export const resolvers: Resolvers = { }, TaxonomyTermMutation: { async create(_parent, { input }, context) { - const { database, userId } = context - - const { parentId, name, description = null } = input - const taxonomyType = - input.taxonomyType === TaxonomyTypeCreateOptions.ExerciseFolder - ? 'topic-folder' - : 'topic' - + const { dataSources, userId } = context assertUserIsAuthenticated(userId) - assertStringIsNotEmpty({ name }) - const parent = await UuidResolver.resolve({ id: parentId }, context) + const { parentId, name, taxonomyType, description = undefined } = input - if (parent?.__typename != DiscriminatorType.TaxonomyTerm) { - throw new UserInputError(`parent with ${parentId} is no taxonomy term`) - } + assertStringIsNotEmpty({ name }) - if (parent.type === 'topicFolder') { - throw new UserInputError(`parent ${parentId} is an exercise folder`) - } + const scope = await fetchScopeOfUuid({ id: parentId }, context) await assertUserIsAuthorized({ context, message: 'You are not allowed create taxonomy terms.', - guard: serloAuth.Uuid.create('TaxonomyTerm')( - serloAuth.instanceToScope(parent.instance), - ), + guard: serloAuth.Uuid.create('TaxonomyTerm')(scope), + }) + + const taxonomyTerm = await dataSources.model.serlo.createTaxonomyTerm({ + parentId, + taxonomyType: + taxonomyType === TaxonomyTypeCreateOptions.ExerciseFolder + ? 'topic-folder' + : 'topic', + name, + description, + userId, }) - const transaction = await database.beginTransaction() - - try { - const { insertId: taxonomyId } = await database.mutate( - 'insert into uuid (trashed, discriminator) values (0, "taxonomyTerm")', - ) - - if (taxonomyId <= 0) { - throw new InternalServerError('no uuid entry could be created') - } - - const { insertId: termId } = await database.mutate( - ` - insert into term (instance_id, name) - select term_parent.instance_id, ? - from term term_parent - join term_taxonomy taxonomy_parent on taxonomy_parent.term_id = term_parent.id - where taxonomy_parent.id = ? - limit 1 - `, - [name, parentId], - ) - - if (termId <= 0) { - throw new UserInputError( - `parent taxonomy ${parentId} does not exists`, - ) - } - - const { currentHeaviest } = await database.fetchOne<{ - currentHeaviest: number - }>( - ` - SELECT IFNULL(MAX(tt.weight), 0) AS currentHeaviest - FROM term_taxonomy tt - WHERE tt.parent_id = ? - `, - [parentId], - ) - - await database.mutate( - ` - insert into term_taxonomy (id, taxonomy_id, term_id, parent_id, description, weight) - select ?, taxonomy.id, ?, ?, ?, ? - from taxonomy - join type on taxonomy.type_id = type.id - join instance on taxonomy.instance_id = instance.id - where type.name = ? and instance.subdomain = ? - `, - [ - taxonomyId, - termId, - parentId, - description, - currentHeaviest + 1, - taxonomyType, - parent.instance, - ], - ) - - const record = await UuidResolver.resolve({ id: taxonomyId }, context) - - if (record?.__typename !== DiscriminatorType.TaxonomyTerm) { - throw new InternalServerError('taxonomy term could not be created') - } - - await createEvent( - { - __typename: NotificationEventType.CreateTaxonomyTerm, - actorId: userId, - taxonomyTermId: taxonomyId, - instance: record.instance, - }, - context, - ) - - await UuidResolver.removeCacheEntry({ id: record.parentId! }, context) - await UuidResolver.removeCacheEntry({ id: record.id }, context) - await transaction.commit() - - return { success: true, record, query: {} } - } finally { - await transaction.rollback() + return { + success: taxonomyTerm ? true : false, + record: taxonomyTerm, + query: {}, } }, async createEntityLinks(_parent, { input }, context) { - const { database, userId } = context + const { dataSources, userId } = context assertUserIsAuthenticated(userId) const { entityIds, taxonomyTermId } = input - const taxonomyTerm = await UuidResolver.resolve( - { id: taxonomyTermId }, - context, - ) - const entities = await Promise.all( - entityIds.map((id) => UuidResolver.resolve({ id }, context)), - ) - - if ( - taxonomyTerm == null || - taxonomyTerm.__typename !== DiscriminatorType.TaxonomyTerm - ) { - throw new UserInputError('termTaxonomyId must belong to taxonomy') - } - - const canBeLinked = (entity: (typeof entities)[number]) => { - if (!EntityDecoder.is(entity)) return false - if (entity.__typename === EntityType.CoursePage) return false - if (entity.instance !== taxonomyTerm.instance) return false - if ( - taxonomyTerm.type === 'topicFolder' && - entity.__typename !== EntityType.Exercise && - entity.__typename !== EntityType.ExerciseGroup - ) { - return false - } - if ( - taxonomyTerm.type !== 'topicFolder' && - (entity.__typename === EntityType.Exercise || - entity.__typename === EntityType.ExerciseGroup) - ) { - return false - } - return true - } - - if (entities.some((entity) => !canBeLinked(entity))) { - throw new UserInputError( - 'At least one child cannot be added to the taxonomy', - ) - } + const scope = await fetchScopeOfUuid({ id: taxonomyTermId }, context) await assertUserIsAuthorized({ message: 'You are not allowed to link entities to this taxonomy term.', - guard: serloAuth.TaxonomyTerm.change( - serloAuth.instanceToScope(taxonomyTerm.instance), - ), + guard: serloAuth.TaxonomyTerm.change(scope), context, }) - const transaction = await database.beginTransaction() - - try { - for (const entity of entities) { - if ( - !EntityDecoder.is(entity) || - entity.__typename === EntityType.CoursePage || - entity.taxonomyTermIds.includes(taxonomyTermId) - ) { - continue - } - - const { lastPosition } = await database.fetchOne<{ - lastPosition: number - }>( - ` - SELECT IFNULL(MAX(position), 0) as lastPosition - FROM term_taxonomy_entity - WHERE term_taxonomy_id = ?`, - [taxonomyTermId], - ) - - await database.mutate( - ` - insert into term_taxonomy_entity (entity_id, term_taxonomy_id, position) - values (?,?,?) - `, - [entity.id, taxonomyTermId, lastPosition + 1], - ) - } - - await transaction.commit() - - await UuidResolver.removeCacheEntries( - entityIds.map((id) => ({ id })), - context, - ) - await UuidResolver.removeCacheEntry({ id: taxonomyTermId }, context) - - return { success: true, query: {} } - } finally { - await transaction.rollback() - } + const { success } = await dataSources.model.serlo.linkEntitiesToTaxonomy({ + entityIds, + taxonomyTermId, + userId, + }) + + return { success, query: {} } }, async deleteEntityLinks(_parent, { input }, context) { - const { database, userId } = context + const { dataSources, userId } = context assertUserIsAuthenticated(userId) const { entityIds, taxonomyTermId } = input - const entities = await Promise.all( - entityIds.map((id) => UuidResolver.resolve({ id }, context)), - ) - const taxonomyTerm = await UuidResolver.resolveWithDecoder( - TaxonomyTermDecoder, - { id: taxonomyTermId }, - context, - ) - - if ( - entities.some( - (entity) => - !EntityDecoder.is(entity) || - entity.__typename === EntityType.CoursePage || - entity.taxonomyTermIds.length <= 1, - ) - ) { - throw new UserInputError( - 'All children must be entities (beside course pages) and must have more than one parent', - ) - } + const scope = await fetchScopeOfUuid({ id: taxonomyTermId }, context) await assertUserIsAuthorized({ message: 'You are not allowed to unlink entities from this taxonomy term.', - guard: serloAuth.TaxonomyTerm.change( - serloAuth.instanceToScope(taxonomyTerm.instance), - ), + guard: serloAuth.TaxonomyTerm.change(scope), context, }) - const transaction = await database.beginTransaction() - - try { - for (const entityId of entityIds) { - await database.mutate( - ` - delete from term_taxonomy_entity - where entity_id = ? and term_taxonomy_id = ? - `, - [entityId, taxonomyTermId], - ) - - await createEvent( - { - __typename: NotificationEventType.RemoveEntityLink, - actorId: userId, - instance: taxonomyTerm.instance, - parentId: taxonomyTermId, - childId: entityId, - }, - context, - ) - } - - await transaction.commit() - - await UuidResolver.removeCacheEntries( - entityIds.map((id) => ({ id })), - context, - ) - await UuidResolver.removeCacheEntry({ id: taxonomyTermId }, context) - - return { success: true, query: {} } - } finally { - await transaction.rollback() - } + const { success } = + await dataSources.model.serlo.unlinkEntitiesFromTaxonomy({ + entityIds, + taxonomyTermId, + userId, + }) + + return { success, query: {} } }, async sort(_parent, { input }, context) { - const { database, userId } = context + const { dataSources, userId } = context assertUserIsAuthenticated(userId) const { childrenIds, taxonomyTermId } = input @@ -395,103 +178,45 @@ export const resolvers: Resolvers = { context, }) - if ( - childrenIds.some( - (childId) => !taxonomyTerm.childrenIds.includes(childId), - ) - ) { - throw new UserInputError( - 'children_ids have to be a subset of children entities and taxonomy terms of the given taxonomy term', - ) - } + // Provisory solution, See https://github.com/serlo/serlo.org-database-layer/issues/303 + const allChildrenIds = [ + ...new Set(childrenIds.concat(taxonomyTerm.childrenIds)), + ] - const transaction = await database.beginTransaction() - - try { - await Promise.all( - childrenIds.map(async (childId, position) => { - // Since the id of entities and taxonomies is always different - // we do not need to distinguish between them - - await database.mutate( - 'update term_taxonomy set weight = ? where parent_id = ? and id = ?', - [position, taxonomyTermId, childId], - ) - - await database.mutate( - 'update term_taxonomy_entity set position = ? where term_taxonomy_id = ? and entity_id = ?', - [position, taxonomyTermId, childId], - ) - }), - ) - - await UuidResolver.removeCacheEntry({ id: taxonomyTermId }, context) - await createEvent( - { - __typename: NotificationEventType.SetTaxonomyTerm, - taxonomyTermId, - actorId: userId, - instance: taxonomyTerm.instance, - }, - context, - ) - - await transaction.commit() - - return { success: true, query: {} } - } finally { - await transaction.rollback() - } + const { success } = await dataSources.model.serlo.sortTaxonomyTerm({ + childrenIds: allChildrenIds, + taxonomyTermId, + userId, + }) + + return { success, query: {} } }, async setNameAndDescription(_parent, { input }, context) { - const { database, userId } = context + const { dataSources, userId } = context assertUserIsAuthenticated(userId) - const { name } = input + const { id, name, description = null } = input assertStringIsNotEmpty({ name }) - const taxonomyTerm = await UuidResolver.resolve(input, context) - - if ( - taxonomyTerm == null || - taxonomyTerm.__typename !== DiscriminatorType.TaxonomyTerm - ) { - throw new UserInputError(`Taxonomy term ${input.id} does not exists`) - } + const scope = await fetchScopeOfUuid({ id }, context) await assertUserIsAuthorized({ message: 'You are not allowed to set name or description of this taxonomy term.', - guard: serloAuth.TaxonomyTerm.set( - serloAuth.instanceToScope(taxonomyTerm.instance), - ), + guard: serloAuth.TaxonomyTerm.set(scope), context, }) - await database.mutate( - ` - UPDATE term - JOIN term_taxonomy ON term.id = term_taxonomy.term_id - SET term.name = ?, - term_taxonomy.description = ? - WHERE term_taxonomy.id = ?; - `, - [input.name, input.description, input.id], - ) - - await createEvent( - { - __typename: NotificationEventType.SetTaxonomyTerm, - taxonomyTermId: input.id, - actorId: userId, - instance: taxonomyTerm.instance, - }, - context, - ) - await UuidResolver.removeCacheEntry(input, context) + const { success } = + await dataSources.model.serlo.setTaxonomyTermNameAndDescription({ + id, + name, + description, + userId, + }) - return { success: true, query: {} } + return { success, query: {} } }, }, } diff --git a/packages/server/src/schema/uuid/taxonomy-term/types.graphql b/packages/server/src/schema/uuid/taxonomy-term/types.graphql index 426e4bca8..d6224bd6d 100644 --- a/packages/server/src/schema/uuid/taxonomy-term/types.graphql +++ b/packages/server/src/schema/uuid/taxonomy-term/types.graphql @@ -32,7 +32,7 @@ extend type Mutation { } type TaxonomyTermMutation { - create(input: TaxonomyTermCreateInput!): TaxonomyTermCreateResponse! + create(input: TaxonomyTermCreateInput!): DefaultResponse! createEntityLinks(input: TaxonomyEntityLinksInput!): DefaultResponse! deleteEntityLinks(input: TaxonomyEntityLinksInput!): DefaultResponse! sort(input: TaxonomyTermSortInput!): DefaultResponse! @@ -68,9 +68,3 @@ input TaxonomyTermSetNameAndDescriptionInput { name: String! description: String } - -type TaxonomyTermCreateResponse { - success: Boolean! - record: TaxonomyTerm - query: Query! -} diff --git a/packages/server/src/schema/uuid/user/resolvers.ts b/packages/server/src/schema/uuid/user/resolvers.ts index 903368307..e31d020eb 100644 --- a/packages/server/src/schema/uuid/user/resolvers.ts +++ b/packages/server/src/schema/uuid/user/resolvers.ts @@ -15,7 +15,7 @@ import { consumeErrorEvent, ErrorEvent, } from '~/error-event' -import { ForbiddenError, UserInputError } from '~/errors' +import { UserInputError } from '~/errors' import { assertUserIsAuthenticated, assertUserIsAuthorized, @@ -398,7 +398,7 @@ export const resolvers: Resolvers = { }, async deleteRegularUser(_parent, { input }, context) { - const { database, authServices, userId } = context + const { dataSources, authServices, userId } = context assertUserIsAuthenticated(userId) await assertUserIsAuthorized({ guard: serloAuth.User.deleteRegularUser(serloAuth.Scope.Serlo), @@ -408,56 +408,19 @@ export const resolvers: Resolvers = { const { id, username } = input const user = await UuidResolver.resolve({ id: input.id }, context) - const idUserDeleted = 4 if (!UserDecoder.is(user) || user.username !== username) { throw new UserInputError( '`id` does not belong to a user or `username` does not match the `user`', ) } - if (id === idUserDeleted) { - throw new ForbiddenError('You cannot delete the user Deleted.') - } - - const transaction = await database.beginTransaction() - try { - await Promise.all([ - database.mutate( - 'UPDATE comment SET author_id = ? WHERE author_id = ?', - [idUserDeleted, id], - ), - database.mutate( - 'UPDATE entity_revision SET author_id = ? WHERE author_id = ?', - [idUserDeleted, id], - ), - database.mutate( - 'UPDATE event_log SET actor_id = ? WHERE actor_id = ?', - [idUserDeleted, id], - ), - database.mutate( - 'UPDATE page_revision SET author_id = ? WHERE author_id = ?', - [idUserDeleted, id], - ), - database.mutate('DELETE FROM notification WHERE user_id = ?', [id]), - database.mutate('DELETE FROM role_user WHERE user_id = ?', [id]), - database.mutate('DELETE FROM subscription WHERE user_id = ?', [id]), - database.mutate('DELETE FROM subscription WHERE uuid_id = ?', [id]), - database.mutate( - "DELETE FROM uuid WHERE id = ? and discriminator = 'user'", - [id], - ), - ]) - - await UuidResolver.removeCacheEntry({ id }, context) - - await deleteKratosUser(id, authServices) - await transaction.commit() - } finally { - await transaction.rollback() - } + const result = await dataSources.model.serlo.deleteRegularUsers({ + userId: id, + }) - return { success: true, query: {} } + if (result.success) await deleteKratosUser(id, authServices) + return { success: result.success, query: {} } }, async removeRole(_parent, { input }, context) { @@ -507,7 +470,7 @@ export const resolvers: Resolvers = { if (input.description.length >= 64 * 1024) { throw new UserInputError('description too long') } - await database.mutate('UPDATE user SET description = ? WHERE id = ?', [ + await database.mutate('update user set description = ? where id = ?', [ input.description, userId, ]) @@ -516,18 +479,17 @@ export const resolvers: Resolvers = { }, async setEmail(_parent, { input }, context) { - const { database, userId } = context + const { dataSources, userId } = context assertUserIsAuthenticated(userId) await assertUserIsAuthorized({ guard: serloAuth.User.setEmail(serloAuth.Scope.Serlo), message: 'You are not allowed to change the E-mail address for a user', context, }) - await database.mutate('UPDATE user SET email = ? WHERE id = ?', [ - input.email, - userId, - ]) - return { success: true, query: {} } + + const result = await dataSources.model.serlo.setEmail(input) + + return { ...result, query: {} } }, }, } diff --git a/packages/server/src/types.ts b/packages/server/src/types.ts index a9af71b47..4313733b8 100644 --- a/packages/server/src/types.ts +++ b/packages/server/src/types.ts @@ -1446,16 +1446,9 @@ export type TaxonomyTermCreateInput = { taxonomyType: TaxonomyTypeCreateOptions; }; -export type TaxonomyTermCreateResponse = { - __typename?: 'TaxonomyTermCreateResponse'; - query: Query; - record?: Maybe; - success: Scalars['Boolean']['output']; -}; - export type TaxonomyTermMutation = { __typename?: 'TaxonomyTermMutation'; - create: TaxonomyTermCreateResponse; + create: DefaultResponse; createEntityLinks: DefaultResponse; deleteEntityLinks: DefaultResponse; setNameAndDescription: DefaultResponse; @@ -2094,7 +2087,6 @@ export type ResolversTypes = { TaxonomyTerm: ResolverTypeWrapper>; TaxonomyTermConnection: ResolverTypeWrapper>; TaxonomyTermCreateInput: ResolverTypeWrapper>; - TaxonomyTermCreateResponse: ResolverTypeWrapper>; TaxonomyTermMutation: ResolverTypeWrapper>; TaxonomyTermSetNameAndDescriptionInput: ResolverTypeWrapper>; TaxonomyTermSortInput: ResolverTypeWrapper>; @@ -2242,7 +2234,6 @@ export type ResolversParentTypes = { TaxonomyTerm: ModelOf; TaxonomyTermConnection: ModelOf; TaxonomyTermCreateInput: ModelOf; - TaxonomyTermCreateResponse: ModelOf; TaxonomyTermMutation: ModelOf; TaxonomyTermSetNameAndDescriptionInput: ModelOf; TaxonomyTermSortInput: ModelOf; @@ -3144,15 +3135,8 @@ export type TaxonomyTermConnectionResolvers; }; -export type TaxonomyTermCreateResponseResolvers = { - query?: Resolver; - record?: Resolver, ParentType, ContextType>; - success?: Resolver; - __isTypeOf?: IsTypeOfResolverFn; -}; - export type TaxonomyTermMutationResolvers = { - create?: Resolver>; + create?: Resolver>; createEntityLinks?: Resolver>; deleteEntityLinks?: Resolver>; setNameAndDescription?: Resolver>; @@ -3403,7 +3387,6 @@ export type Resolvers = { SubscriptionQuery?: SubscriptionQueryResolvers; TaxonomyTerm?: TaxonomyTermResolvers; TaxonomyTermConnection?: TaxonomyTermConnectionResolvers; - TaxonomyTermCreateResponse?: TaxonomyTermCreateResponseResolvers; TaxonomyTermMutation?: TaxonomyTermMutationResolvers; Thread?: ThreadResolvers; ThreadAware?: ThreadAwareResolvers; diff --git a/scripts/build.ts b/scripts/build.ts index c1a3010da..f30ca3a14 100644 --- a/scripts/build.ts +++ b/scripts/build.ts @@ -48,7 +48,7 @@ export function getEsbuildOptions(source: string, outfile: string) { // type of "undefined". // // We rather install it seperately. - external: ['bee-queue', 'bull-arena'], + external: ['bee-queue'], outfile, plugins: [graphqlLoaderPlugin()], } as esbuild.BuildOptions diff --git a/yarn.lock b/yarn.lock index 91946e46f..6fddb9be5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -16957,11 +16957,13 @@ __metadata: linkType: hard "semver@npm:^7.0.0, semver@npm:^7.1.1, semver@npm:^7.3.2, semver@npm:^7.3.4, semver@npm:^7.3.5, semver@npm:^7.3.7, semver@npm:^7.3.8, semver@npm:^7.5.3, semver@npm:^7.5.4, semver@npm:^7.6.0": - version: 7.6.1 - resolution: "semver@npm:7.6.1" + version: 7.6.0 + resolution: "semver@npm:7.6.0" + dependencies: + lru-cache: ^6.0.0 bin: semver: bin/semver.js - checksum: 2c9c89b985230c0fcf02c96ae6a3ca40c474f2f4e838634394691e6e10c347a0c6def0f14fc355d82f90f1744a073b8b9c45457b108aa728280b5d68ed7961cd + checksum: 7427f05b70786c696640edc29fdd4bc33b2acf3bbe1740b955029044f80575fc664e1a512e4113c3af21e767154a94b4aa214bf6cd6e42a1f6dba5914e0b208c languageName: node linkType: hard