diff --git a/.github/workflows/pull-request-target.yml b/.github/workflows/pull-request-target.yml index 14d84bc7b0f..51e1f0bc6fd 100644 --- a/.github/workflows/pull-request-target.yml +++ b/.github/workflows/pull-request-target.yml @@ -21,7 +21,7 @@ jobs: - name: Add the PR Review Policy uses: thollander/actions-comment-pull-request@v3 with: - comment_tag: pr_review_policy + comment-tag: pr_review_policy message: | ## Our Pull Request Approval Process diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index 894cb390ef2..05898623439 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -48,8 +48,8 @@ jobs: echo "Error: Close this PR and try again." exit 1 - check_biome_ignore: - name: Check for biome_ignore + python_checks: + name: Run Python Checks runs-on: ubuntu-latest steps: - name: Checkout code @@ -64,30 +64,19 @@ jobs: with: python-version: 3.9 - - name: Run Python script + - name: Run Biome Ignore Check run: | python .github/workflows/scripts/biome_disable_check.py --files ${{ steps.changed-files.outputs.all_changed_files }} - check_code_coverage_disable: - name: Check for code coverage disable - runs-on: ubuntu-latest - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Get changed files - id: changed-files - uses: tj-actions/changed-files@v45 - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: 3.9 - - - name: Run Python script + - name: Run Code Coverage Disable Check run: | python .github/workflows/scripts/code_coverage_disable_check.py --files ${{ steps.changed-files.outputs.all_changed_files }} + - name: Run TS Ignore Check + run: | + python .github/workflows/scripts/detect_ts_ignore.py --files ${{ steps.changed-files.outputs.all_changed_files }} + check_gql_tada: name: Check gql tada files and configuration runs-on: ubuntu-latest @@ -167,6 +156,8 @@ jobs: docs/docusaurus.config.ts docs/sidebar* setup.ts + src/graphql/types/Mutation/** + src/graphql/types/Query/** tsconfig.build.json vite.config.mts CNAME @@ -193,7 +184,7 @@ jobs: Run-Tests: name: Run tests for talawa api runs-on: ubuntu-latest - needs: [Code-Quality-Checks, check_code_coverage_disable] + needs: [Code-Quality-Checks, python_checks] env: CODECOV_UNIQUE_NAME: ${{github.workflow}}-${{github.ref_name}} steps: @@ -221,7 +212,7 @@ jobs: uses: VeryGoodOpenSource/very_good_coverage@v2 with: path: "./coverage/lcov.info" - min_coverage: 45.0 + min_coverage: 48.6 Test-Docusaurus-Deployment: name: Test Deployment to https://docs-api.talawa.io diff --git a/.github/workflows/scripts/detect_ts_ignore.py b/.github/workflows/scripts/detect_ts_ignore.py new file mode 100755 index 00000000000..9988dadc67a --- /dev/null +++ b/.github/workflows/scripts/detect_ts_ignore.py @@ -0,0 +1,134 @@ +#!/usr/bin/env python3 +"""Script to detect and report usage of @ts-ignore comments in TypeScript.""" + +import argparse +import re +import sys +import logging +import os + +# Configure logging +logging.basicConfig( + level=logging.INFO, + format="%(levelname)s: %(message)s", +) + +TS_IGNORE_PATTERN = r"(?://|/\*)\s*@ts-ignore(?:\s+|$)" + +IGNORED_EXTENSIONS = { + # Image formats + ".avif", + ".jpeg", + ".jpg", + ".png", + ".webp", + ".gif", + ".bmp", + ".ico", + ".svg", + # Video formats + ".mp4", + ".webm", + ".mov", + ".avi", + ".mkv", + # Audio formats + ".mp3", + ".wav", + ".ogg", + # Document formats + ".pdf", + ".doc", + ".docx", +} + + +def is_binary_file(filepath: str) -> bool: + """Check if a file is binary based on its extension. + + Args: + filepath (str): The file path. + + Returns: + bool: True if the file should be ignored, False otherwise. + """ + return os.path.splitext(filepath)[1].lower() in IGNORED_EXTENSIONS + + +def check_ts_ignore(files: list[str]) -> int: + """Check for occurrences of '@ts-ignore' in the given files. + + Args: + files (list[str]): List of file paths to check. + + Returns: + int: 0 if no violations, 1 if violations are found. + """ + ts_ignore_found = False + + for file in files: + if not is_binary_file(file): + try: + logging.info("Checking file: %s", file) + with open(file, encoding="utf-8") as f: + for line_num, line in enumerate(f, start=1): + # Handle more variations of @ts-ignore + if re.search( + TS_IGNORE_PATTERN, + line.strip(), + ): + print( + f"❌ Error: '@ts-ignore' found in {file} ", + f"at line {line_num}", + ) + logging.debug( + "Found @ts-ignore in line: %s", + line.strip(), + ) + ts_ignore_found = True + except FileNotFoundError: + logging.warning("File not found: %s", file) + except OSError: + logging.exception("Could not read %s", file) + if not ts_ignore_found: + print("✅ No '@ts-ignore' comments found in the files.") + + return 1 if ts_ignore_found else 0 + + +def main() -> None: + """Main function to parse arguments and run the check. + + This function sets up argument parsing for file paths and runs the ts-ignore + check on the specified files. + + Args: + None + + Returns: + None: The function exits the program with status code 0 if no ts-ignore + comments are found, or 1 if any are detected. + """ + parser = argparse.ArgumentParser( + description="Check for @ts-ignore in changed files.", + ) + parser.add_argument( + "--files", + nargs="+", + help="List of changed files", + required=True, + ) + args = parser.parse_args() + + # Filter TypeScript files + ts_files = [f for f in args.files if f.endswith((".ts", ".tsx"))] + if not ts_files: + logging.info("No TypeScript files to check.") + sys.exit(0) + + exit_code = check_ts_ignore(args.files) + sys.exit(exit_code) + + +if __name__ == "__main__": + main() diff --git a/docs/docs/docs/developer-resources/introduction.md b/docs/docs/docs/developer-resources/introduction.md index f38f9e47260..76f14b031ce 100644 --- a/docs/docs/docs/developer-resources/introduction.md +++ b/docs/docs/docs/developer-resources/introduction.md @@ -7,6 +7,16 @@ sidebar_position: 1 Welcome to the Talawa-API developer resources. +## Design Philosophy + +Coming Soon + +### Authentication + +We have kept the authentication system very minimal so that a proper authentication system can be put in place later on. We feel that some kind of typescript based authentication library that can integrate with the current database schema or a self hosted service with its own database is needed. + +For this reason, the authentication system needs to be detached from the GraphQL schema and be handled using REST APIs using something like Better Auth: https://github.com/better-auth/better-auth + ## Important Directories Review these important locations before you start your coding journey. diff --git a/docs/docs/docs/developer-resources/testing.md b/docs/docs/docs/developer-resources/testing.md index 10bf4549f07..6e27be11188 100644 --- a/docs/docs/docs/developer-resources/testing.md +++ b/docs/docs/docs/developer-resources/testing.md @@ -7,31 +7,269 @@ sidebar_position: 4 This section covers important tests to validate the operation of the API. -### Sample Database Login Credentials +## The `tests/` Directory + +The `tests/` directory contains the code for performing api tests against Talawa API. The tests in this directory and its various subdirectories must follow the practices of black box testing and most of them should be written to be able to run concurrently. + +1. Tests for files in the `src/` directory must only be placed in the equivalent subdirectory under the `tests/` directory. +2. Test files must have a `.test.ts` extension. + +The rest of this page will assist you in being an active contributor to the code base. + +## Testing Philosophy + +Black box testing in this context means we test Talawa API from the perspective of a client making requests to it. This also means that we must only communicate with Talawa API during the tests with its publicly exposed interface. + +In the context of the rest api interfaces exposed by Talawa API it means making standard HTTP calls using methods like GET, POST, PATCH, PUT, DELETE etc., and asserting against the HTTP responses. + +In the context of the graphql api interfaces exposed by Talawa API it means triggering standard graphql query, mutation and subscription operations against the graphql api endpoint(over HTTP POST method for our use case) and asserting against the graphql responses. + +### Unit Tests vs Integration Testing + +The current codebase has the simplest implementation for graphql which is doing everything within resolvers. It is good for now because it lets the project move fast, break things and quickly iterate on changes. When a stable api is reached, then the project can be architected into something that is more suited to unit tests. + +We started implementing integration tests because for the current requirements the best kind of testing is to ensure that the graphql operations return what they are expected to return when talawa clients make those operations. + +The GraphQL schema cannot be tested without running the graphql server itself because it is an internal implementation detail of the graphql engine. The old approach, when the API used a MongoDB backend, only tested the resolvers which does not account for this. + +The end users will be interacting with the graphql schema and not the typescript graphql resolvers. So, the tests should be written in a way that asserts against the runtime behavior of that graphql schema. + +This does mean that code coverage is not possible because vitest cannot know what typescript module paths are being traversed inside the tests because at runtime those typescript modules are compiled into node.js(v8) internal implementation of byte code. + +#### Integration Testing + +Based on this design, we only do integration testing for GraphQL queries and mutations in these folders. + +1. `src/Graphql/mutation` +1. `src/Graphql/query` + +**NOTE:** No unit testing is done in these folders. + +#### Unit Testing + +We only do unit testing the return type of the Graphql types resolver in these folders. + +1. `src/graphql/types/*` + +**NOTE:** No integration testing is done in these folders. + +### Directory Structure + +The `tests/server.ts` file exports the Talawa API server instance that can be imported and used in different api tests. This Talawa API server instance is shared between api tests. + +There aren't any other strict structure requirements for the this directory. + +### Future Considerations + +In the future there might be a requirement to run some tests sequentially. When that moment arrives separating sequential and parallel tests into separate directories and using separate vitest configuration for them would be the best idea. + +### Writing Reliable Concurrent Tests + +Here are the guidelines for writing non-flaky tests that are able to run concurrently or in parallel: + +1. All tests must set up their own data to get the application to their desired state. Tests must not assume that the data they need to act on can be dervied from other tests or could pre-exist. + +2. All tests must perform write operations only on data associated to them. Tests must not in any way perform write operations on data that isn't associated to them because it could lead to disruption of other tests. The best way to ensure this is to introduce uniqueness to the data created within tests through the usage of cryptographic identifier generators like uuid, cuid, nanoid etc. + +3. All tests must either assert against data associated to them or they must change their assertion logic to something that suits asserting against random data. + +Example test suites 1 and 2 depicting the violations and followage of these guidelines: + +#### Guideline Violation Example + +This example show a violation of the guidelines. + +```typescript +// Test suite 1 +suite.concurrent("flaky concurrent tests", async () => { + test.concurrent("create user test", async () => { + const userData = { + id: "1", + name: "user1", + }; + const createdUsers = await fetch.post("/users", { + body: [userData], + }); + expect(createdUsers[0]).toEqual(userData); + }); + + test.concurrent("get user test", async () => { + const user = await fetch.get("/users/1"); + expect(user).toEqual({ + id: "1", + name: "user1", + }); + }); + + test.concurrent("update user test", async () => { + const updatedUser = await fetch.update("/user/1", { + body: { + name: "updatedUser1", + }, + }); + expect(updatedUser).toEqual({ + id: "1", + name: "updatedUser1", + }); + }); + + test.concurrent("delete user test", async () => { + const deletedUser = await fetch.delete("/user/1"); + expect(deletedUser).toEqual({ + id: "1", + name: "user1", + }); + }); + + test.concurrent("get users test", async () => { + await fetch.post("/users", { + body: [ + { + id: "2", + name: "user2", + }, + { + id: "3", + name: "user3", + }, + { + id: "4", + name: "user4", + }, + ], + }); + const users = await fetch.get("/users"); + expect(users).toHaveLength(3); + }); +}); +``` + +#### Guideline Compliance Example + +This example shows compliance with the guidelines. + +```typescript +// Test suite 2 +suite.concurrent("non-flaky concurrent tests", async () => { + test.concurrent("create user test", async () => { + const userData = { + id: randomIdGenerator(), + name: `name${randomIdGenerator()}`, + }; + const createdUsers = await fetch.post("/users", { + body: [userData], + }); + expect(createdUsers[0]).toEqual(userData); + }); + + test.concurrent("get user test", async () => { + const userData = { + id: randomIdGenerator(), + name: `name${randomIdGenerator()}`, + }; + await fetch.post("/users", { + body: [userData], + }); + const user = await fetch.get(`/users/${userData.id}`); + expect(user).toEqual(userData); + }); + + test.concurrent("update user test", async () => { + const userData = { + id: randomIdGenerator(), + name: `name${randomIdGenerator()}`, + }; + await fetch.post("/users", { + body: [userData], + }); + const newName = `newName${randomIdGenerator()}`; + const updatedUser = await fetch.update(`/users/${userData.id}`, { + body: { + name: newName, + }, + }); + expect(updatedUser).toEqual({ + id: userData.id, + name: newName, + }); + }); + + test.concurrent("delete user test", async () => { + const userData = { + id: randomIdGenerator(), + name: `name${randomIdGenerator()}`, + }; + await fetch.post("/users", { + body: [userData], + }); + const deletedUser = await fetch.delete(`/users/${userData.id}`); + expect(deletedUser).toEqual(userData); + }); + + test.concurrent("get users test", async () => { + const userDataList = [ + { + id: randomIdGenerator(), + name: `name${randomIdGenerator()}`, + }, + { + id: randomIdGenerator(), + name: `name${randomIdGenerator()}`, + }, + { + id: randomIdGenerator(), + name: `name${randomIdGenerator()}`, + }, + ]; + await fetch.post("/users", { + body: userDataList, + }); + const users = await fetch.get("/users"); + expect(users).length.greaterThanOrEqual(3); + }); +}); +``` + +## Test DB Usage + +Here is some important information about how the test DB is used both for your tests and the CI/CD pipeline. + +1. The `postgres-test` database is strictly meant for testing in all environments. The environment variable value of `API_POSTGRES_HOST` is overwritten from `postgres` to `postgres-test` in all environments where tests are run as shown in `/test/server.ts`. +1. The `postgres` service is not enabled in the CI environment as can be seen in the `COMPOSE_PROFILES` environment variable in `envFiles/.env.ci`. Only the `postgres_test` service is enabled in the CI environment. +1. We use two database containers for `postgres` and `postgres_test`, not two databases in a single container because the latter goes against the philosophy of containerization. +1. The `test/` folder has an environment variables schema defined in `test/envConfigSchema.ts`. To access `API_POSTGRES_TEST_HOST` you just pass the schema to `env-schema`. +1. We don't use `postgres_test` in the `.env.ci` file because the CI/CD pipeline isn't limited to testing. We did this to be future proof. + +## Environment Variables + +If you need to overwrite the environment variables, you have to allow passing those environment variables as arguments as in the createServer function in the `src/createServer.ts` file. This is how `API_POSTGRES_HOST` and `API_MINIO_END_POINT` are overwritten in the tests as explained previously. + +## Sample DB Login Credentials If the API: + 1. is running with an unmodified `.env` file copied from `envFiles/.env.devcontainer` and; 2. the API sample database is loaded; -then you can use these login credentials to access the API via various clients. - -| Email | Password | User Type | Joined Organization | -| -----------------------------------| -------- | ---------------| -------------------- | -| administrator@email.com | password | Administrator | N/A | -| testsuperadmin@example.com | Pass@123 | Administrator | N/A | -| testadmin1@example.com | Pass@123 | Administrator | N/A | -| testadmin2@example.com | Pass@123 | Administrator | N/A | -| testadmin3@example.com | Pass@123 | Administrator | N/A | -| testuser1@example.com | Pass@123 | Regular | N/A | -| testuser2@example.com | Pass@123 | Regular | N/A | -| testuser3@example.com | Pass@123 | Regular | N/A | -| testuser4@example.com | Pass@123 | Regular | N/A | -| testuser5@example.com | Pass@123 | Regular | N/A | -| testuser6@example.com | Pass@123 | Regular | N/A | -| testuser7@example.com | Pass@123 | Regular | N/A | -| testuser8@example.com | Pass@123 | Regular | N/A | -| testuser9@example.com | Pass@123 | Regular | N/A | -| testuser10@example.com | Pass@123 | Regular | N/A | -| testuser11@example.com | Pass@123 | Regular | N/A | + then you can use these login credentials to access the API via various clients. + +| Email | Password | User Type | Joined Organization | +| -------------------------- | -------- | ------------- | ------------------- | +| administrator@email.com | password | Administrator | N/A | +| testsuperadmin@example.com | Pass@123 | Administrator | N/A | +| testadmin1@example.com | Pass@123 | Administrator | N/A | +| testadmin2@example.com | Pass@123 | Administrator | N/A | +| testadmin3@example.com | Pass@123 | Administrator | N/A | +| testuser1@example.com | Pass@123 | Regular | N/A | +| testuser2@example.com | Pass@123 | Regular | N/A | +| testuser3@example.com | Pass@123 | Regular | N/A | +| testuser4@example.com | Pass@123 | Regular | N/A | +| testuser5@example.com | Pass@123 | Regular | N/A | +| testuser6@example.com | Pass@123 | Regular | N/A | +| testuser7@example.com | Pass@123 | Regular | N/A | +| testuser8@example.com | Pass@123 | Regular | N/A | +| testuser9@example.com | Pass@123 | Regular | N/A | +| testuser10@example.com | Pass@123 | Regular | N/A | +| testuser11@example.com | Pass@123 | Regular | N/A | ## Accessing the API @@ -61,13 +299,15 @@ This section covers how to access the GraphQL API interface. #### Interactive Web Queries With GraphiQL -The url for accessing the GraphQL Playground is +You can use GraphiQL to test your GraphQL queries interactively via a web page. + +The url for accessing the GraphQL Playground is: ```bash http://127.0.0.1:4000/graphiql ``` -#### Programmatic Queries With GraphiQL +#### Programmatic Queries With GraphQL The graphQL endpoint for handling `queries` and `mutations` is this: @@ -114,130 +354,7 @@ Replace `` with the actual IP address you copied in step 2. **Note**: In the Talawa app, type the endpoint URL in the field labeled `Enter Community URL`. -## Database Management - -This section covers easy ways for developers to validate their work - -### CloudBeaver - -CloudBeaver is a lightweight web application designed for comprehensive data management. It allows you to work with various data sources, including SQL, NoSQL, and cloud databases, all through a single secure cloud solution accessible via a browser. - -#### Accessing the PostgreSQL Database using CloudBeaver - -1. Open your preferred browser and navigate to: - ```bash - http://127.0.0.1:8978/ - ``` -2. Log in to the CloudBeaver UI using the following credentials (these credentials can be modified in the `.env.devcontainer` file by changing the `CLOUDBEAVER_ADMIN_NAME` and `CLOUDBEAVER_ADMIN_PASSWORD` variables): - ``` - Username: talawa - Password: password - ``` -3. You should now see the CloudBeaver UI. Click on the "New Connection" button and select `PostgreSQL` from the list of available connections. -4. Fill in the connection details as follows: - ``` - Name: talawa - Host: postgres - Port: 5432 - Database: talawa - Username: talawa - Password: password - ``` - - **Note:** The host name should match the one specified in the Docker Compose file and credentials should match those specified in the `.env.development` file. -5. Check the `Save credentials for all users with access` option to avoid entering the credentials each time. -6. Check the following boxes in the Database list: - ```sql - show all databases - show template databases - show unavailable databases - show database statistics - ``` -7. Click `Create` to save the connection. -8. You should now see the `PostgreSql@postgres` connection in the list of available connections. Click on the connection to open the database. -9. Navigate to `PostgreSql@postgres > Databases > talawa > Schemas > public > Tables` to view the available schemas. - -#### Accessing the PostgreSQL Test Database using CloudBeaver - -1. Click on the `New Connection` button and select `PostgreSQL` from the list of available connections. -2. Fill in the connection details as follows: - - ``` - Name: talawa - Host: postgrestest - Port: 5432 - Database: talawa - Username: talawa - Password: password - ``` - - - **Note:** The host name should match the one specified in the Docker Compose file and credentials should match those specified in the `.env.development` file. - -3. Check the `Save credentials for all users with access` option to avoid entering the credentials each time. -4. Check the following boxes in the Database list: - ```sql - show all databases - show template databases - show unavailable databases - show database statistics - ``` -5. Click `Create` to save the connection. -6. You should now see the `PostgreSql@postgres-test` connection in the list of available connections. Click on the connection to open the database. -7. Navigate to `PostgreSql@postgres-test > Databases > talawa > Schemas > public > Tables` to view the available tables. - -## Object Storage Management - -MinIO is a free, open-source object storage server that's compatible with Amazon S3. It's designed for large-scale data storage and can run on-premises or in the cloud. - -### Accessing MinIO - (Production Environments) - -1. Open your preferred browser and navigate to: - ```bash - http://127.0.0.1:9001/ - ``` -2. Log in to the MinIO UI using the following credentials(these credentials can be modified in the env files by changing the `MINIO_ROOT_USER` and `MINIO_ROOT_PASSWORD` variables): - - Username: `talawa` - - Password: `password` -3. You should now see the MinIO UI. Click on the `Login` button to access the MinIO dashboard. -4. You can now view the available buckets and objects in the MinIO dashboard. - -### Accessing MinIO - (Development Environments) - -1. Open your preferred browser and navigate to: - ```bash - http://127.0.0.1:9003/ - ``` -2. Log in to the MinIO UI using the following credentials(these credentials can be modified in the `.env.devcontainer` file by changing the `MINIO_ROOT_USER` and `MINIO_ROOT_PASSWORD` variables): - - Username: `talawa` - - Password: `password` -3. You should now see the MinIO UI. Click on the `Login` button to access the MinIO dashboard. -4. You can now view the available buckets and objects in the MinIO dashboard. - -## Resetting Docker - -**NOTE:** This applies only to Talawa API developers. - -Sometimes you may want to start all over again from scratch. These steps will reset your development docker environment. - -1. From the repository's root directory, use this command to shutdown the dev container. - ```bash - docker compose down - ``` -1. **WARNING:** These commands will stop **ALL** your Docker processes and delete their volumes. This applies not only to the Talawa API instances, but everything. Use with extreme caution. - ```bash - docker stop $(docker ps -q) - docker rm $(docker ps -a -q) - docker rmi $(docker images -q) - docker volume prune -f - ``` -1. Restart the Docker dev containers to resume your development work. - ```bash - devcontainer build --workspace-folder . - devcontainer up --workspace-folder . - ``` - -Now you can resume your development work. - -## Testing The API +## Interactive Testing Use the `API_BASE_URL` URL configured in the `.env` file. As the endpoint uses GraphQL, the complete URL will be `API_BASE_URL/graphql` @@ -424,9 +541,15 @@ mutation { } ``` -##### Create an Organization Administrator +##### Create an Organization Member -Use the following GraphQL **mutation** to assign **administrator** role to user: +This **mutation** is used to add a member to an organization and assign them a role. + +- Administrators can add other users and assign roles (administrator or regular). +- Non-administrators can only add themselves to an organization. +- Non-administrators cannot assign roles while adding themselves; they will be assigned the default role (regular). + +The example below shows how to add an administrator to an organization: ```graphql mutation { @@ -501,3 +624,201 @@ Use the following GraphQL **query** to query organization data: } } ``` + +##### Query User Data with Organizations + +Use the following GraphQL **query** to query user data including a list of organizations the user is a member of: + +```graphql +query { + user(input: { id: "user-id" }) { + name + emailAddress + organizationsWhereMember(first: 5, after: null, before: null, last: null) { + edges { + node { + id + name + } + } + } + } +} +``` + +**Request Headers:** + +- `Content-Type: application/json` +- `Authorization: Bearer ` + +**Example Response:** + +```json +{ + "data": { + "user": { + "name": "administrator", + "emailAddress": "administrator@email.com", + "organizationsWhereMember": { + "edges": [ + { + "node": { + "id": "019527e1-2f4a-7a89-94b6-193a3e9dfd76", + "name": "Test Org 7" + } + }, + { + "node": { + "id": "cd3e4f5b-6a7c-8d9e-0f1a-2b3c4d5e6f7a", + "name": "Unity Foundation 3" + } + }, + { + "node": { + "id": "bc2d3e4f-5a6b-7c8d-9e0f-1a2b3c4d5e6f", + "name": "Unity Foundation 4" + } + }, + { + "node": { + "id": "ab1c2d3e-4f5b-6a7c-8d9e-0f1a2b3c4d5f", + "name": "Unity Foundation 2" + } + }, + { + "node": { + "id": "ab1c2d3e-4f5b-6a7c-8d9e-0f1a2b3c4d5e", + "name": "Unity Foundation 1" + } + } + ] + } + } + } +} +``` + +## Database Management + +This section covers easy ways for developers to validate their work by examining the database. + +We use CloudBeaver which is a lightweight web application designed for comprehensive data management. It allows you to work with various data sources, including SQL, NoSQL, and cloud databases, all through a single secure cloud solution accessible via a browser. + +### Interactive Production DB Access + +1. Open your preferred browser and navigate to: + ```bash + http://127.0.0.1:8978/ + ``` +2. Log in to the CloudBeaver UI using the following credentials (these credentials can be modified in the `.env.devcontainer` file by changing the `CLOUDBEAVER_ADMIN_NAME` and `CLOUDBEAVER_ADMIN_PASSWORD` variables): + ``` + Username: talawa + Password: password + ``` +3. You should now see the CloudBeaver UI. Click on the "New Connection" button and select `PostgreSQL` from the list of available connections. +4. Fill in the connection details as follows: + ``` + Name: talawa + Host: postgres + Port: 5432 + Database: talawa + Username: talawa + Password: password + ``` + - **Note:** The host name should match the one specified in the Docker Compose file and credentials should match those specified in the `.env.development` file. +5. Check the `Save credentials for all users with access` option to avoid entering the credentials each time. +6. Check the following boxes in the Database list: + ```sql + show all databases + show template databases + show unavailable databases + show database statistics + ``` +7. Click `Create` to save the connection. +8. You should now see the `PostgreSql@postgres` connection in the list of available connections. Click on the connection to open the database. +9. Navigate to `PostgreSql@postgres > Databases > talawa > Schemas > public > Tables` to view the available schemas. + +### Interactive Test DB Access + +1. Click on the `New Connection` button and select `PostgreSQL` from the list of available connections. +2. Fill in the connection details as follows: + + ``` + Name: talawa + Host: postgrestest + Port: 5432 + Database: talawa + Username: talawa + Password: password + ``` + + - **Note:** The host name should match the one specified in the Docker Compose file and credentials should match those specified in the `.env.development` file. + +3. Check the `Save credentials for all users with access` option to avoid entering the credentials each time. +4. Check the following boxes in the Database list: + ```sql + show all databases + show template databases + show unavailable databases + show database statistics + ``` +5. Click `Create` to save the connection. +6. You should now see the `PostgreSql@postgres-test` connection in the list of available connections. Click on the connection to open the database. +7. Navigate to `PostgreSql@postgres-test > Databases > talawa > Schemas > public > Tables` to view the available tables. + +## Object Storage Management + +We use MinIO, a free, open-source object storage server that's compatible with Amazon S3. It's designed for large-scale data storage and can run on-premises or in the cloud. + +### MinIO Access in Production + +This is how you access MinIO in production environments. + +1. Open your preferred browser and navigate to: + ```bash + http://127.0.0.1:9001/ + ``` +2. Log in to the MinIO UI using the following credentials(these credentials can be modified in the env files by changing the `MINIO_ROOT_USER` and `MINIO_ROOT_PASSWORD` variables): + - Username: `talawa` + - Password: `password` +3. You should now see the MinIO UI. Click on the `Login` button to access the MinIO dashboard. +4. You can now view the available buckets and objects in the MinIO dashboard. + +### MinIO Access in Development + +This is how you access MinIO in development environments. + +1. Open your preferred browser and navigate to: + ```bash + http://127.0.0.1:9003/ + ``` +2. Log in to the MinIO UI using the following credentials(these credentials can be modified in the `.env.devcontainer` file by changing the `MINIO_ROOT_USER` and `MINIO_ROOT_PASSWORD` variables): + - Username: `talawa` + - Password: `password` +3. You should now see the MinIO UI. Click on the `Login` button to access the MinIO dashboard. +4. You can now view the available buckets and objects in the MinIO dashboard. + +## Resetting Docker + +**NOTE:** This applies only to Talawa API developers. + +Sometimes you may want to start all over again from scratch. These steps will reset your development docker environment. + +1. From the repository's root directory, use this command to shutdown the dev container. + ```bash + docker compose down + ``` +1. **WARNING:** These commands will stop **ALL** your Docker processes and delete their volumes. This applies not only to the Talawa API instances, but everything. Use with extreme caution. + ```bash + docker stop $(docker ps -q) + docker rm $(docker ps -a -q) + docker rmi $(docker images -q) + docker volume prune -f + ``` +1. Restart the Docker dev containers to resume your development work. + ```bash + devcontainer build --workspace-folder . + devcontainer up --workspace-folder . + ``` + +Now you can resume your development work. diff --git a/docs/docs/docs/developer-resources/troubleshooting.md b/docs/docs/docs/developer-resources/troubleshooting.md index d3939f41174..44de1fd5267 100644 --- a/docs/docs/docs/developer-resources/troubleshooting.md +++ b/docs/docs/docs/developer-resources/troubleshooting.md @@ -1,10 +1,100 @@ --- id: troubleshooting -title: Operation +title: Troubleshooting slug: /developer-resources/troubleshooting sidebar_position: 5 --- ## Introduction -Coming soon. +This page provides basic troubleshooting steps for the applications. + +## Docker + +When running the application using docker it may seem difficult at first to troubleshoot failures. This section covers some basic troubleshooting strategies. + +### Status Validation + +You can get a summary of all the running docker containers using the `docker ps` command. + +It will provide this information under these headings: + +1. **CONTAINER ID**: Container IDs +1. **IMAGE**: Image names on which the containers are based +1. **COMMAND**: The command that created the containers +1. **CREATED**: The time of containers +1. **STATUS**: Whether or not the containers are healthy +1. **PORTS**: The exposed ports they use +1. **NAMES**: The names of the running containers + +Here is an example: + +``` +CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES +3a6743b03029 docker-app "docker-entrypoint.s…" 42 minutes ago Up 41 minutes 0.0.0.0:4321->4321/tcp docker-app-1 +f86a9f480819 talawa-api-dev "/bin/bash /init-dat…" 42 minutes ago Up 42 minutes 0.0.0.0:4000->4000/tcp talawa-api-dev +83ae5ff56a3f redis:8.0 "docker-entrypoint.s…" 42 minutes ago Up 42 minutes 0.0.0.0:6379->6379/tcp talawa-api-redis +44c8a0f38b04 minio/minio "/usr/bin/docker-ent…" 42 minutes ago Up 42 minutes 0.0.0.0:9000->9001/tcp talawa-api-minio-1 +3a9deccdb68e caddy/caddy:2.9 "caddy run --config …" 42 minutes ago Up 42 minutes 0.0.0.0:9080->9080/tcp caddy-service +132dacf0aff4 mongo "/bin/bash /init-mon…" 42 minutes ago Up 42 minutes 0.0.0.0:27017->27017/tcp mongo +``` + +You can get information on each of the headings by using filters like this: + +1. CONTAINER ID: `docker ps --format '{{.ID}}'` +1. IMAGE: `docker ps --format '{{.Names}}'` +1. COMMAND: `docker ps --format '{{.Command}}'` +1. CREATED: `docker ps --format '{{.RunningFor}}'` +1. STATUS: `docker ps --format '{{.Status}}'` +1. PORTS: `docker ps --format '{{.Ports}}'` +1. NAMES: `docker ps --format '{{.Names}}'` + +### Accessing The Container CLI + +You can access the CLI of each container using the docker interactive TTY mode flags `-it`. + +Here is an example accessing the `/bin/bash` CLI of the `talawa-api-dev` container: + +```bash +$ docker exec -it talawa-api-dev /bin/bash +root@f86a9f480819:/usr/src/app# ls +CODEOWNERS Caddyfile Dockerfile.prod +CODE_OF_CONDUCT.md DOCUMENTATION.md INSTALLATION.md +CONTRIBUTING.md Dockerfile.dev ISSUE_GUIDELINES.md +root@f86a9f480819:/usr/src/app# exit +$ +``` + +Here is an example accessing the `/bin/mongosh` CLI of the `mongo` container: + +```bash +$ docker exec -it mongo /bin/mongosh +... +... +... +rs0 [direct: primary] test> show databases +admin 80.00 KiB +config 356.00 KiB +local 1.92 MiB +talawa-api 2.49 MiB +rs0 [direct: primary] test> exit +$ +``` + +### Viewing Container Logs + +You can view the container logs in real time by using the `docker logs -f` command. The output will update dynamically as you run the app. + +In this case we see the logs of the `mongo` container. The `-n 10` flag makes the output start with the most recent 10 rows of logs which makes the output less verbose. + +```bash +$ docker logs -f mongo -n 10 +``` + +``` +mongosh","version":"6.12.0|2.3.8"},"platform":"Node.js v20.18.1, LE","os":{"name":"linux","architecture":"x64","version":"3.10.0-327.22.2.el7.x86_64","type":"Linux"},"env":{"container":{"runtime":"docker"}}}}} +{"t":{"$date":"2025-02-22T01:14:08.038+00:00"},"s":"I", "c":"NETWORK", "id":51800, "ctx":"conn2194","msg":"client metadata","attr":{"remote":"127.0.0.1:36844","client":"conn2194","negotiatedCompressors":[],"doc":{"application":{"name":"mongosh 2.3.8"},"driver":{"name":"nodejs|mongosh","version":"6.12.0|2.3.8"},"platform":"Node.js v20.18.1, LE","os":{"name":"linux","architecture":"x64","version":"3.10.0-327.22.2.el7.x86_64","type":"Linux"},"env":{"container":{"runtime":"docker"}}}}} +{"t":{"$date":"2025-02-22T01:14:08.040+00:00"},"s":"I", "c":"NETWORK", "id":6788700, "ctx":"conn2193","msg":"Received first command on ingress connection since session start or auth handshake","attr":{"elapsedMillis":2}} +{"t":{"$date":"2025-02-22T01:14:08.040+00:00"},"s":"I", "c":"NETWORK", "id":22943, "ctx":"listener","msg":"Connection accepted","attr":{"remote":"127.0.0.1:36848","uuid":{"uuid":{"$uuid":"1ef5fcbd-4913-45fe-bc66-7bc3600a941a"}},"connectionId":2195,"connectionCount":24}} +{"t":{"$date":"2025-02-22T01:14:08.043+00:00"},"s":"I", "c":"NETWORK", "id":22943, "ctx":"listener","msg":"Connection accepted","attr":{"remote":"127.0.0.1:36854","uuid":{"uuid":{"$uuid":"48522796-7b00-46df-a5d1-3e2a9ec7edd8"}},"connectionId":2196,"connectionCount":25}} +``` diff --git a/docs/docs/docs/getting-started/environment-variables.md b/docs/docs/docs/getting-started/environment-variables.md index 21aded57e5a..4cf5a7fac91 100644 --- a/docs/docs/docs/getting-started/environment-variables.md +++ b/docs/docs/docs/getting-started/environment-variables.md @@ -11,6 +11,15 @@ Listed below are all the environment variables utilized by different workflows w > Environment variables should be explicitly provided to the container that they're being used for. This is because with changes in implicit environment variables docker cannot know exactly which compose container services should be rebuilt to reflect those changes. There are two ways of doing that, the first way is explicitly typing out each of those environment variables in the `environment` field of the compose container service and the second way is to create separate environment variable files for storing environment variables for each compose container service. +## Our `NODE_ENV` Variable Philosophy + +The `NODE_ENV` variable is extremely sparsely used. + +1. It should not be touched. It exists only to make other javascript tools **always** run with their production environment capabilities no matter the environment. +2. If a capability is to be controlled it must be done explicitly using an environment variable specific to that capability. For example the `API_IS_GRAPHIQL` and `API_IS_PINO_PRETTY` variables below are meant for enabling/disabling the `graphiql` web explorer and pretty logging by pino logger. We didn't use `NODE_ENV` for these because these functionalities should be individually configurable in all environments and not be controlled using a single `NODE_ENV` environment variable. + +In an environment where one capability is needed but the other is not, using a single environment variable to control all of them at once wouldn't work. + ## talawa api (standalone) At runtime, talawa api requires certain environment variables to be defined in its execution context. Some of these environment variables must be provided by you and some are optional to be provided because they might be using a default value or their requirement is dependent on the environment in which talawa api is running. diff --git a/docs/docs/docs/getting-started/installation.md b/docs/docs/docs/getting-started/installation.md index ba553c7c27a..13047661d8d 100644 --- a/docs/docs/docs/getting-started/installation.md +++ b/docs/docs/docs/getting-started/installation.md @@ -34,8 +34,8 @@ You will need to configure the API to work correctly. | `API_COMMUNITY_X_URL` | URL to the community's X (formerly Twitter) page, used for linking and integrating social media presence. | | `API_COMMUNITY_YOUTUBE_URL` | URL to the community's YouTube channel, used for linking and integrating video content. | | `API_JWT_SECRET` | Secret key for JWT(JSON Web Token) generation and validation, used for securing API authentication and authorization. | -| `API_MINIO_SECRET_KEY` | Secret key for MinIO, used for securing access to MinIO object storage. | -| `API_POSTGRES_PASSWORD` | Password for the PostgreSQL database, used for database authentication and security. | +| `API_MINIO_SECRET_KEY` | Secret key for MinIO, used for securing access to MinIO object storage. **NOTE:** Must match `MINIO_ROOT_PASSWORD` | +| `API_POSTGRES_PASSWORD` | Password for the PostgreSQL database, used for database authentication and security. **NOTE:** Must match `POSTGRES_PASSWORD` | | `CADDY_TALAWA_API_DOMAIN_NAME` | Domain name for the Talawa API, used for configuring and routing API traffic. | | `CADDY_TALAWA_API_EMAIL` | Email address for the Talawa API, used for SSL certificate registration and notifications. | | `MINIO_ROOT_PASSWORD` | Root password for MinIO, used for securing administrative access to MinIO object storage. | @@ -147,13 +147,13 @@ You will need to update the `.env` file with the following information. You will need to update the `.env` file with the following information. 1. `MINIO_ROOT_PASSWORD` is a plain text password of your choosing. -1. `API_MINIO_SECRET_KEY` is a plain text password of your choosing. +1. `API_MINIO_SECRET_KEY` - **NOTE:** must match `MINIO_ROOT_PASSWORD`. ##### Update the PostgreSQL Credentials You will need to update the `.env` file with the following information. The passwords are in plain text and must match. -1. `API_POSTGRES_PASSWORD` +1. `API_POSTGRES_PASSWORD` - **NOTE:** Must match `POSTGRES_PASSWORD` 2. `POSTGRES_PASSWORD` ##### Update the API_BASE_URL Value diff --git a/envFiles/.env.deploy b/envFiles/.env.deploy new file mode 100644 index 00000000000..c4a697cc462 --- /dev/null +++ b/envFiles/.env.deploy @@ -0,0 +1,83 @@ +################################################################################ +# +# DO NOT EDIT ! +# DO NOT EDIT ! +# DO NOT EDIT ! +# +# Used for the deployment of our Talawa demonstration sites +# +################################################################################ + +########## talawa api ########## + +API_ADMINISTRATOR_USER_EMAIL_ADDRESS=administrator@example.com +API_ADMINISTRATOR_USER_NAME=Administrator +API_ADMINISTRATOR_USER_PASSWORD=password +API_BASE_URL=http://127.0.0.1:4000 +API_COMMUNITY_FACEBOOK_URL=https://facebook.com +API_COMMUNITY_GITHUB_URL=https://github.com/PalisadoesFoundation +API_COMMUNITY_INACTIVITY_TIMEOUT_DURATION=900 +API_COMMUNITY_INSTAGRAM_URL=https://instagram.com +API_COMMUNITY_LINKEDIN_URL=https://www.linkedin.com/company/palisadoes/ +API_COMMUNITY_NAME=The Palisadoes Foundation +API_COMMUNITY_REDDIT_URL=https://reddit.com +API_COMMUNITY_SLACK_URL=https://www.palisadoes.org/slack +API_COMMUNITY_WEBSITE_URL=https://docs.talawa.io +API_COMMUNITY_X_URL=https://x.com/palisadoesorg +API_COMMUNITY_YOUTUBE_URL=https://www.youtube.com/c/palisadoesorganization +API_HOST=0.0.0.0 +API_IS_APPLY_DRIZZLE_MIGRATIONS=true +API_IS_GRAPHIQL=false +API_IS_PINO_PRETTY=false +API_JWT_EXPIRES_IN=2592000000 +API_JWT_SECRET=REPLACE_WITH_RANDOM_JWT_SECRET +API_LOG_LEVEL=info +API_MINIO_ACCESS_KEY=talawa +API_MINIO_END_POINT=minio +API_MINIO_PORT=9000 +API_MINIO_SECRET_KEY=REPLACE_WITH_RANDOM_PASSWORD +API_MINIO_USE_SSL=false +API_PORT=4000 +API_POSTGRES_DATABASE=talawa +API_POSTGRES_HOST=postgres +API_POSTGRES_PASSWORD=REPLACE_WITH_RANDOM_PASSWORD +API_POSTGRES_PORT=5432 +API_POSTGRES_SSL_MODE=false +API_POSTGRES_USER=talawa +# https://vitest.dev/config/#watch +CI=false +# https://blog.platformatic.dev/handling-environment-variables-in-nodejs#heading-set-nodeenvproduction-for-all-environments +NODE_ENV=production + +########## docker compose `api` container service ########## + +API_GID=1000 +API_UID=1000 + +########## docker compose `caddy` container service ########## + +CADDY_HTTP_MAPPED_PORT=8080 +CADDY_HTTPS_MAPPED_PORT=8443 +CADDY_HTTP3_MAPPED_PORT=8443 +CADDY_TALAWA_API_DOMAIN_NAME=localhost +CADDY_TALAWA_API_EMAIL=talawa@example.com +CADDY_TALAWA_API_HOST=api +CADDY_TALAWA_API_PORT=4000 + +########## docker compose `minio` container service ########## + +MINIO_BROWSER=off +MINIO_ROOT_PASSWORD=REPLACE_WITH_RANDOM_PASSWORD +MINIO_ROOT_USER=talawa + +########## docker compose `postgres` container service ########## + +POSTGRES_DB=talawa +POSTGRES_PASSWORD=REPLACE_WITH_RANDOM_PASSWORD +POSTGRES_USER=talawa + +########## docker compose ########## + +COMPOSE_FILE=./compose.yaml +COMPOSE_PROFILES=api,caddy,minio,postgres +COMPOSE_PROJECT_NAME=talawa diff --git a/lefthook.yaml b/lefthook.yaml index f1a49e2b7d5..f88f4f8ec8d 100644 --- a/lefthook.yaml +++ b/lefthook.yaml @@ -30,11 +30,15 @@ pre-commit: pnpm fix_code_quality git add {staged_files} 8_check_type_errors: - run: pnpm check_type_errors + run: pnpm check_type_errors + 9_check_tests: + run: | + set -e + pnpm check_tests 9_generate_docs: run: | set -e pnpm generate:docs git add ./docs/docs/docs/auto-schema - piped: true \ No newline at end of file + piped: true diff --git a/package.json b/package.json index 63c7d471689..e6b5f6d3453 100644 --- a/package.json +++ b/package.json @@ -24,13 +24,11 @@ "graphql": "^16.10.0", "graphql-scalars": "^1.24.0", "graphql-upload-minimal": "^1.6.1", - "inquirer": "^12.4.1", "mercurius": "^16.0.1", "mercurius-upload": "^8.0.0", "minio": "^8.0.4", "postgres": "^3.4.5", "ulidx": "^2.4.1", - "uuid": "^11.0.5", "uuidv7": "^1.0.2", "zod": "^3.24.1" }, @@ -40,7 +38,6 @@ "@faker-js/faker": "^9.4.0", "@swc/cli": "0.6.0", "@swc/core": "^1.10.9", - "@types/inquirer": "^9.0.7", "@types/node": "^22.10.7", "@types/uuid": "^10.0.0", "@vitest/coverage-v8": "^3.0.3", @@ -92,10 +89,12 @@ "generate_drizzle_migrations": "drizzle-kit generate", "generate_graphql_sdl_file": "tsx ./scripts/generateGraphQLSDLFile.ts", "generate_gql_tada": "gql.tada generate-output && gql.tada turbo --fail-on-warn", - "import:sample-data": "tsx ./src/utilities/loadSampleData.ts", + "reset:data": "tsx ./scripts/dbManagement/resetData.ts", + "add:sample_data": "tsx ./scripts/dbManagement/addSampleData.ts", "push_drizzle_schema": "drizzle-kit push", "push_drizzle_test_schema": "drizzle-kit push --config=./test/drizzle.config.ts", "run_tests": "vitest --coverage", + "check_tests": "vitest run", "start_development_server": "tsx watch ./src/index.ts", "start_development_server_with_debugger": "tsx watch --inspect=${API_DEBUGGER_HOST:-127.0.0.1}:${API_DEBUGGER_PORT:-9229} ./src/index.ts", "start_production_server": "pnpm build_production && node ./dist/index.js", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5292b316bad..bbedde22a89 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -41,9 +41,6 @@ importers: close-with-grace: specifier: ^2.2.0 version: 2.2.0 - dotenv: - specifier: ^16.0.3 - version: 16.4.7 drizzle-orm: specifier: ^0.39.1 version: 0.39.2(postgres@3.4.5) @@ -68,9 +65,6 @@ importers: graphql-upload-minimal: specifier: ^1.6.1 version: 1.6.1(graphql@16.10.0) - inquirer: - specifier: ^12.4.1 - version: 12.4.1(@types/node@22.13.1) mercurius: specifier: ^16.0.1 version: 16.0.1(graphql@16.10.0) @@ -86,9 +80,6 @@ importers: ulidx: specifier: ^2.4.1 version: 2.4.1 - uuid: - specifier: ^11.0.5 - version: 11.0.5 uuidv7: specifier: ^1.0.2 version: 1.0.2 @@ -108,9 +99,6 @@ importers: '@swc/core': specifier: ^1.10.9 version: 1.10.14 - '@types/inquirer': - specifier: ^9.0.7 - version: 9.0.7 '@types/node': specifier: ^22.10.7 version: 22.13.1 @@ -917,127 +905,6 @@ packages: peerDependencies: graphql: ^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 - '@inquirer/checkbox@4.1.1': - resolution: {integrity: sha512-os5kFd/52gZTl/W6xqMfhaKVJHQM8V/U1P8jcSaQJ/C4Qhdrf2jEXdA/HaxfQs9iiUA/0yzYhk5d3oRHTxGDDQ==} - engines: {node: '>=18'} - peerDependencies: - '@types/node': '>=18' - peerDependenciesMeta: - '@types/node': - optional: true - - '@inquirer/confirm@5.1.5': - resolution: {integrity: sha512-ZB2Cz8KeMINUvoeDi7IrvghaVkYT2RB0Zb31EaLWOE87u276w4wnApv0SH2qWaJ3r0VSUa3BIuz7qAV2ZvsZlg==} - engines: {node: '>=18'} - peerDependencies: - '@types/node': '>=18' - peerDependenciesMeta: - '@types/node': - optional: true - - '@inquirer/core@10.1.6': - resolution: {integrity: sha512-Bwh/Zk6URrHwZnSSzAZAKH7YgGYi0xICIBDFOqBQoXNNAzBHw/bgXgLmChfp+GyR3PnChcTbiCTZGC6YJNJkMA==} - engines: {node: '>=18'} - peerDependencies: - '@types/node': '>=18' - peerDependenciesMeta: - '@types/node': - optional: true - - '@inquirer/editor@4.2.6': - resolution: {integrity: sha512-l0smvr8g/KAVdXx4I92sFxZiaTG4kFc06cFZw+qqwTirwdUHMFLnouXBB9OafWhpO3cfEkEz2CdPoCmor3059A==} - engines: {node: '>=18'} - peerDependencies: - '@types/node': '>=18' - peerDependenciesMeta: - '@types/node': - optional: true - - '@inquirer/expand@4.0.8': - resolution: {integrity: sha512-k0ouAC6L+0Yoj/j0ys2bat0fYcyFVtItDB7h+pDFKaDDSFJey/C/YY1rmIOqkmFVZ5rZySeAQuS8zLcKkKRLmg==} - engines: {node: '>=18'} - peerDependencies: - '@types/node': '>=18' - peerDependenciesMeta: - '@types/node': - optional: true - - '@inquirer/figures@1.0.10': - resolution: {integrity: sha512-Ey6176gZmeqZuY/W/nZiUyvmb1/qInjcpiZjXWi6nON+nxJpD1bxtSoBxNliGISae32n6OwbY+TSXPZ1CfS4bw==} - engines: {node: '>=18'} - - '@inquirer/input@4.1.5': - resolution: {integrity: sha512-bB6wR5wBCz5zbIVBPnhp94BHv/G4eKbUEjlpCw676pI2chcvzTx1MuwZSCZ/fgNOdqDlAxkhQ4wagL8BI1D3Zg==} - engines: {node: '>=18'} - peerDependencies: - '@types/node': '>=18' - peerDependenciesMeta: - '@types/node': - optional: true - - '@inquirer/number@3.0.8': - resolution: {integrity: sha512-CTKs+dT1gw8dILVWATn8Ugik1OHLkkfY82J+Musb57KpmF6EKyskv8zmMiEJPzOnLTZLo05X/QdMd8VH9oulXw==} - engines: {node: '>=18'} - peerDependencies: - '@types/node': '>=18' - peerDependenciesMeta: - '@types/node': - optional: true - - '@inquirer/password@4.0.8': - resolution: {integrity: sha512-MgA+Z7o3K1df2lGY649fyOBowHGfrKRz64dx3+b6c1w+h2W7AwBoOkHhhF/vfhbs5S4vsKNCuDzS3s9r5DpK1g==} - engines: {node: '>=18'} - peerDependencies: - '@types/node': '>=18' - peerDependenciesMeta: - '@types/node': - optional: true - - '@inquirer/prompts@7.3.1': - resolution: {integrity: sha512-r1CiKuDV86BDpvj9DRFR+V+nIjsVBOsa2++dqdPqLYAef8kgHYvmQ8ySdP/ZeAIOWa27YGJZRkENdP3dK0H3gg==} - engines: {node: '>=18'} - peerDependencies: - '@types/node': '>=18' - peerDependenciesMeta: - '@types/node': - optional: true - - '@inquirer/rawlist@4.0.8': - resolution: {integrity: sha512-hl7rvYW7Xl4un8uohQRUgO6uc2hpn7PKqfcGkCOWC0AA4waBxAv6MpGOFCEDrUaBCP+pXPVqp4LmnpWmn1E1+g==} - engines: {node: '>=18'} - peerDependencies: - '@types/node': '>=18' - peerDependenciesMeta: - '@types/node': - optional: true - - '@inquirer/search@3.0.8': - resolution: {integrity: sha512-ihSE9D3xQAupNg/aGDZaukqoUSXG2KfstWosVmFCG7jbMQPaj2ivxWtsB+CnYY/T4D6LX1GHKixwJLunNCffww==} - engines: {node: '>=18'} - peerDependencies: - '@types/node': '>=18' - peerDependenciesMeta: - '@types/node': - optional: true - - '@inquirer/select@4.0.8': - resolution: {integrity: sha512-Io2prxFyN2jOCcu4qJbVoilo19caiD3kqkD3WR0q3yDA5HUCo83v4LrRtg55ZwniYACW64z36eV7gyVbOfORjA==} - engines: {node: '>=18'} - peerDependencies: - '@types/node': '>=18' - peerDependenciesMeta: - '@types/node': - optional: true - - '@inquirer/type@3.0.4': - resolution: {integrity: sha512-2MNFrDY8jkFYc9Il9DgLsHhMzuHnOYM1+CUYVWbzu9oT0hC7V7EcYvdCKeoll/Fcci04A+ERZ9wcc7cQ8lTkIA==} - engines: {node: '>=18'} - peerDependencies: - '@types/node': '>=18' - peerDependenciesMeta: - '@types/node': - optional: true - '@isaacs/cliui@8.0.2': resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} @@ -1504,15 +1371,9 @@ packages: '@types/http-cache-semantics@4.0.4': resolution: {integrity: sha512-1m0bIFVc7eJWyve9S0RnuRgcQqF/Xd5QsUZAZeQFr1Q3/p9JWoQQEqmVy+DPTNpGXwhgIetAoYF8JSc33q29QA==} - '@types/inquirer@9.0.7': - resolution: {integrity: sha512-Q0zyBupO6NxGRZut/JdmqYKOnN95Eg5V8Csg3PGKkP+FnvsUZx1jAyK7fztIszxxMuoBA6E3KXWvdZVXIpx60g==} - '@types/node@22.13.1': resolution: {integrity: sha512-jK8uzQlrvXqEU91UxiK5J7pKHyzgnI1Qnl0QDHIgVGuolJhRb9EEl28Cj9b3rGR8B2lhFCtvIm5os8lFnO/1Ew==} - '@types/through@0.0.33': - resolution: {integrity: sha512-HsJ+z3QuETzP3cswwtzt2vEIiHBk/dCcHGhbmG5X3ecnwFD/lPrMpliGXxSCg03L9AhrdwA4Oz/qfspkDW+xGQ==} - '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} @@ -1622,10 +1483,6 @@ packages: ajv@8.17.1: resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==} - ansi-escapes@4.3.2: - resolution: {integrity: sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==} - engines: {node: '>=8'} - ansi-regex@5.0.1: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} @@ -1753,9 +1610,6 @@ packages: resolution: {integrity: sha512-aGtmf24DW6MLHHG5gCx4zaI3uBq3KRtxeVs0DjFH6Z0rDNbsvTxFASFvdj79pxjxZ8/5u3PIiN3IwEIQkiiuPw==} engines: {node: '>=12'} - chardet@0.7.0: - resolution: {integrity: sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==} - check-error@2.1.1: resolution: {integrity: sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==} engines: {node: '>= 16'} @@ -1764,10 +1618,6 @@ packages: resolution: {integrity: sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==} engines: {node: '>=6'} - cli-width@4.1.0: - resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==} - engines: {node: '>= 12'} - close-with-grace@2.2.0: resolution: {integrity: sha512-OdcFxnxTm/AMLPHA4Aq3J1BLpkojXP7I4G5QBQLN5TT55ED/rk04rAoDbtfNnfZ988kGXPxh1bdRLeIU9bz/lA==} @@ -2065,10 +1915,6 @@ packages: resolution: {integrity: sha512-yblEwXAbGv1VQDmow7s38W77hzAgJAO50ztBLMcUyUBfxv1HC+LGwtiEN+Co6LtlqT/5uwVOxsD4TNIilWhwdQ==} engines: {node: '>=4'} - external-editor@3.1.0: - resolution: {integrity: sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew==} - engines: {node: '>=4'} - fast-copy@3.0.2: resolution: {integrity: sha512-dl0O9Vhju8IrcLndv2eU4ldt1ftXMqqfgN4H1cpmGV7P6jeB9FwpN9a2c8DPGE1Ys88rNUJVYDHq73CGAGOPfQ==} @@ -2302,10 +2148,6 @@ packages: resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} engines: {node: '>=10.17.0'} - iconv-lite@0.4.24: - resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} - engines: {node: '>=0.10.0'} - ieee754@1.2.1: resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} @@ -2316,15 +2158,6 @@ packages: inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} - inquirer@12.4.1: - resolution: {integrity: sha512-/V7OyFkeUBFO2jAokUq5emSlcVMHVvzg8bwwZnzmCwErPgbeftsthmPUg71AIi5mR0YmiJOLQ+bTiHVWEjOw7A==} - engines: {node: '>=18'} - peerDependencies: - '@types/node': '>=18' - peerDependenciesMeta: - '@types/node': - optional: true - inspect-with-kind@1.0.5: resolution: {integrity: sha512-MAQUJuIo7Xqk8EVNP+6d3CKq9c80hi4tjIbIAT6lmGW9W6WzlHiu9PS8uSuUYU+Do+j1baiFp3H25XEVxDIG2g==} @@ -2643,10 +2476,6 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - mute-stream@2.0.0: - resolution: {integrity: sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==} - engines: {node: ^18.17.0 || >=20.5.0} - nanoid@3.3.8: resolution: {integrity: sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -2674,10 +2503,6 @@ packages: resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} engines: {node: '>=6'} - os-tmpdir@1.0.2: - resolution: {integrity: sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==} - engines: {node: '>=0.10.0'} - p-cancelable@3.0.0: resolution: {integrity: sha512-mlVgR3PGuzlo0MmTdk4cXqXWlwQDLnONTAg6sm62XkMJEiRxN3GL3SffkYvqwonbkJBcrI7Uvv5Zh9yjvn2iUw==} engines: {node: '>=12.20'} @@ -2832,16 +2657,9 @@ packages: engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true - run-async@3.0.0: - resolution: {integrity: sha512-540WwVDOMxA6dN6We19EcT9sc3hkXPw5mzRNGM3FkdN/vtE9NFvj5lFAPNwUDmJjXidm3v7TC1cTE7t17Ulm1Q==} - engines: {node: '>=0.12.0'} - run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} - rxjs@7.8.1: - resolution: {integrity: sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==} - safe-buffer@5.2.1: resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} @@ -3069,10 +2887,6 @@ packages: resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==} engines: {node: '>=14.0.0'} - tmp@0.0.33: - resolution: {integrity: sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==} - engines: {node: '>=0.6.0'} - to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} @@ -3152,10 +2966,6 @@ packages: util@0.12.5: resolution: {integrity: sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==} - uuid@11.0.5: - resolution: {integrity: sha512-508e6IcKLrhxKdBbcA2b4KQZlLVp2+J5UwQ6F7Drckkc5N9ZJwFa4TgWtsww9UG8fGHbm6gbV19TdM5pQ4GaIA==} - hasBin: true - uuidv7@1.0.2: resolution: {integrity: sha512-8JQkH4ooXnm1JCIhqTMbtmdnYEn6oKukBxHn1Ic9878jMkL7daTI7anTExfY18VRCX7tcdn5quzvCb6EWrR8PA==} hasBin: true @@ -3258,10 +3068,6 @@ packages: engines: {node: '>=8'} hasBin: true - wrap-ansi@6.2.0: - resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} - engines: {node: '>=8'} - wrap-ansi@7.0.0: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} @@ -3306,10 +3112,6 @@ packages: resolution: {integrity: sha512-Ow9nuGZE+qp1u4JIPvg+uCiUr7xGQWdff7JQSk5VGYTAZMDe2q8lxJ10ygv10qmSj031Ty/6FNJpLO4o1Sgc+w==} engines: {node: '>=12'} - yoctocolors-cjs@2.1.2: - resolution: {integrity: sha512-cYVsTjKl8b+FrnidjibDWskAv7UKOfcwaVZdp/it9n1s9fU3IkgDbhdIRKCW4JDsAlECJY0ytoVPT3sK6kideA==} - engines: {node: '>=18'} - zod@3.24.1: resolution: {integrity: sha512-muH7gBL9sI1nciMZV67X5fTKKBLtwpZ5VBp1vsOQzj1MhrBZ4wlVCm3gedKZWLp0Oyel8sIGfeiz54Su+OVT+A==} @@ -3798,122 +3600,6 @@ snapshots: dependencies: graphql: 16.10.0 - '@inquirer/checkbox@4.1.1(@types/node@22.13.1)': - dependencies: - '@inquirer/core': 10.1.6(@types/node@22.13.1) - '@inquirer/figures': 1.0.10 - '@inquirer/type': 3.0.4(@types/node@22.13.1) - ansi-escapes: 4.3.2 - yoctocolors-cjs: 2.1.2 - optionalDependencies: - '@types/node': 22.13.1 - - '@inquirer/confirm@5.1.5(@types/node@22.13.1)': - dependencies: - '@inquirer/core': 10.1.6(@types/node@22.13.1) - '@inquirer/type': 3.0.4(@types/node@22.13.1) - optionalDependencies: - '@types/node': 22.13.1 - - '@inquirer/core@10.1.6(@types/node@22.13.1)': - dependencies: - '@inquirer/figures': 1.0.10 - '@inquirer/type': 3.0.4(@types/node@22.13.1) - ansi-escapes: 4.3.2 - cli-width: 4.1.0 - mute-stream: 2.0.0 - signal-exit: 4.1.0 - wrap-ansi: 6.2.0 - yoctocolors-cjs: 2.1.2 - optionalDependencies: - '@types/node': 22.13.1 - - '@inquirer/editor@4.2.6(@types/node@22.13.1)': - dependencies: - '@inquirer/core': 10.1.6(@types/node@22.13.1) - '@inquirer/type': 3.0.4(@types/node@22.13.1) - external-editor: 3.1.0 - optionalDependencies: - '@types/node': 22.13.1 - - '@inquirer/expand@4.0.8(@types/node@22.13.1)': - dependencies: - '@inquirer/core': 10.1.6(@types/node@22.13.1) - '@inquirer/type': 3.0.4(@types/node@22.13.1) - yoctocolors-cjs: 2.1.2 - optionalDependencies: - '@types/node': 22.13.1 - - '@inquirer/figures@1.0.10': {} - - '@inquirer/input@4.1.5(@types/node@22.13.1)': - dependencies: - '@inquirer/core': 10.1.6(@types/node@22.13.1) - '@inquirer/type': 3.0.4(@types/node@22.13.1) - optionalDependencies: - '@types/node': 22.13.1 - - '@inquirer/number@3.0.8(@types/node@22.13.1)': - dependencies: - '@inquirer/core': 10.1.6(@types/node@22.13.1) - '@inquirer/type': 3.0.4(@types/node@22.13.1) - optionalDependencies: - '@types/node': 22.13.1 - - '@inquirer/password@4.0.8(@types/node@22.13.1)': - dependencies: - '@inquirer/core': 10.1.6(@types/node@22.13.1) - '@inquirer/type': 3.0.4(@types/node@22.13.1) - ansi-escapes: 4.3.2 - optionalDependencies: - '@types/node': 22.13.1 - - '@inquirer/prompts@7.3.1(@types/node@22.13.1)': - dependencies: - '@inquirer/checkbox': 4.1.1(@types/node@22.13.1) - '@inquirer/confirm': 5.1.5(@types/node@22.13.1) - '@inquirer/editor': 4.2.6(@types/node@22.13.1) - '@inquirer/expand': 4.0.8(@types/node@22.13.1) - '@inquirer/input': 4.1.5(@types/node@22.13.1) - '@inquirer/number': 3.0.8(@types/node@22.13.1) - '@inquirer/password': 4.0.8(@types/node@22.13.1) - '@inquirer/rawlist': 4.0.8(@types/node@22.13.1) - '@inquirer/search': 3.0.8(@types/node@22.13.1) - '@inquirer/select': 4.0.8(@types/node@22.13.1) - optionalDependencies: - '@types/node': 22.13.1 - - '@inquirer/rawlist@4.0.8(@types/node@22.13.1)': - dependencies: - '@inquirer/core': 10.1.6(@types/node@22.13.1) - '@inquirer/type': 3.0.4(@types/node@22.13.1) - yoctocolors-cjs: 2.1.2 - optionalDependencies: - '@types/node': 22.13.1 - - '@inquirer/search@3.0.8(@types/node@22.13.1)': - dependencies: - '@inquirer/core': 10.1.6(@types/node@22.13.1) - '@inquirer/figures': 1.0.10 - '@inquirer/type': 3.0.4(@types/node@22.13.1) - yoctocolors-cjs: 2.1.2 - optionalDependencies: - '@types/node': 22.13.1 - - '@inquirer/select@4.0.8(@types/node@22.13.1)': - dependencies: - '@inquirer/core': 10.1.6(@types/node@22.13.1) - '@inquirer/figures': 1.0.10 - '@inquirer/type': 3.0.4(@types/node@22.13.1) - ansi-escapes: 4.3.2 - yoctocolors-cjs: 2.1.2 - optionalDependencies: - '@types/node': 22.13.1 - - '@inquirer/type@3.0.4(@types/node@22.13.1)': - optionalDependencies: - '@types/node': 22.13.1 - '@isaacs/cliui@8.0.2': dependencies: string-width: 5.1.2 @@ -4431,10 +4117,6 @@ snapshots: json-schema-traverse: 1.0.0 require-from-string: 2.0.2 - ansi-escapes@4.3.2: - dependencies: - type-fest: 0.21.3 - ansi-regex@5.0.1: {} ansi-regex@6.1.0: {} @@ -4566,14 +4248,10 @@ snapshots: loupe: 3.1.3 pathval: 2.0.0 - chardet@0.7.0: {} - check-error@2.1.1: {} clean-stack@2.2.0: {} - cli-width@4.1.0: {} - close-with-grace@2.2.0: {} color-convert@2.0.1: @@ -4849,12 +4527,6 @@ snapshots: ext-list: 2.2.2 sort-keys-length: 1.0.1 - external-editor@3.1.0: - dependencies: - chardet: 0.7.0 - iconv-lite: 0.4.24 - tmp: 0.0.33 - fast-copy@3.0.2: {} fast-decode-uri-component@1.0.1: {} @@ -5144,28 +4816,12 @@ snapshots: human-signals@2.1.0: {} - iconv-lite@0.4.24: - dependencies: - safer-buffer: 2.1.2 - ieee754@1.2.1: {} indent-string@4.0.0: {} inherits@2.0.4: {} - inquirer@12.4.1(@types/node@22.13.1): - dependencies: - '@inquirer/core': 10.1.6(@types/node@22.13.1) - '@inquirer/prompts': 7.3.1(@types/node@22.13.1) - '@inquirer/type': 3.0.4(@types/node@22.13.1) - ansi-escapes: 4.3.2 - mute-stream: 2.0.0 - run-async: 3.0.0 - rxjs: 7.8.1 - optionalDependencies: - '@types/node': 22.13.1 - inspect-with-kind@1.0.5: dependencies: kind-of: 6.0.3 @@ -5474,8 +5130,6 @@ snapshots: ms@2.1.3: {} - mute-stream@2.0.0: {} - nanoid@3.3.8: {} normalize-url@8.0.1: {} @@ -5496,8 +5150,6 @@ snapshots: dependencies: mimic-fn: 2.1.0 - os-tmpdir@1.0.2: {} - p-cancelable@3.0.0: {} p-map@4.0.0: @@ -5667,16 +5319,10 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.34.5 fsevents: 2.3.3 - run-async@3.0.0: {} - run-parallel@1.2.0: dependencies: queue-microtask: 1.2.3 - rxjs@7.8.1: - dependencies: - tslib: 2.8.1 - safe-buffer@5.2.1: {} safe-regex-test@1.1.0: @@ -5884,10 +5530,6 @@ snapshots: tinyspy@3.0.2: {} - tmp@0.0.33: - dependencies: - os-tmpdir: 1.0.2 - to-regex-range@5.0.1: dependencies: is-number: 7.0.0 @@ -5956,8 +5598,6 @@ snapshots: is-typed-array: 1.1.15 which-typed-array: 1.1.18 - uuid@11.0.5: {} - uuidv7@1.0.2: {} vite-node@3.0.5(@types/node@22.13.1)(tsx@4.19.2)(yaml@2.7.0): @@ -6065,12 +5705,6 @@ snapshots: siginfo: 2.0.0 stackback: 0.0.2 - wrap-ansi@6.2.0: - dependencies: - ansi-styles: 4.3.0 - string-width: 4.2.3 - strip-ansi: 6.0.1 - wrap-ansi@7.0.0: dependencies: ansi-styles: 4.3.0 @@ -6103,6 +5737,4 @@ snapshots: buffer-crc32: 0.2.13 pend: 1.2.0 - yoctocolors-cjs@2.1.2: {} - zod@3.24.1: {} diff --git a/schema.graphql b/schema.graphql index ce1522b2e0e..7be89d90f5c 100644 --- a/schema.graphql +++ b/schema.graphql @@ -2841,6 +2841,9 @@ type Query { """Query field to read an agenda item.""" agendaItem(input: QueryAgendaItemInput!): AgendaItem + """Query field to read all Users.""" + allUsers(after: String, before: String, first: Int, last: Int, name: String): QueryAllUsersConnection + """Query field to read a chat.""" chat(input: QueryChatInput!): Chat @@ -2913,6 +2916,16 @@ input QueryAgendaItemInput { id: String! } +type QueryAllUsersConnection { + edges: [QueryAllUsersConnectionEdge] + pageInfo: PageInfo! +} + +type QueryAllUsersConnectionEdge { + cursor: String! + node: User +} + """""" input QueryChatInput { """Global id of the chat.""" diff --git a/scripts/dbManagement/addSampleData.ts b/scripts/dbManagement/addSampleData.ts new file mode 100644 index 00000000000..6389ac7ca97 --- /dev/null +++ b/scripts/dbManagement/addSampleData.ts @@ -0,0 +1,83 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { + disconnect, + ensureAdministratorExists, + insertCollections, + pingDB, +} from "./helpers"; + +type Collection = + | "users" + | "organizations" + | "organization_memberships" + | "posts" + | "post_votes" + | "post_attachments" + | "comments" + | "comment_votes"; + +export async function main(): Promise { + const collections: Collection[] = [ + "users", + "organizations", + "organization_memberships", + "posts", + "post_votes", + "post_attachments", + "comments", + "comment_votes", + ]; + + try { + await pingDB(); + console.log("\n\x1b[32mSuccess:\x1b[0m Database connected successfully\n"); + } catch (error: unknown) { + throw new Error(`Database connection failed: ${error}`); + } + try { + await ensureAdministratorExists(); + console.log("\x1b[32mSuccess:\x1b[0m Administrator setup complete\n"); + } catch (error: unknown) { + console.error("\nError: Administrator creation failed", error); + throw new Error( + "\n\x1b[31mAdministrator access may be lost, try reimporting sample DB to restore access\x1b[0m\n", + ); + } + + try { + await insertCollections(collections); + console.log("\n\x1b[32mSuccess:\x1b[0m Sample Data added to the database"); + } catch (error: unknown) { + console.error("Error: ", error); + throw new Error("Error adding sample data"); + } + + return; +} + +const scriptPath = fileURLToPath(import.meta.url); +export const isMain = + process.argv[1] && path.resolve(process.argv[1]) === path.resolve(scriptPath); + +if (isMain) { + let exitCode = 0; + (async () => { + try { + await main(); + } catch (error: unknown) { + exitCode = 1; + } + try { + await disconnect(); + console.log( + "\n\x1b[32mSuccess:\x1b[0m Gracefully disconnecting from the database\n", + ); + } catch (error: unknown) { + console.error("Error: Cannot disconnect", error); + exitCode = 1; + } finally { + process.exit(exitCode); + } + })(); +} diff --git a/scripts/dbManagement/helpers.ts b/scripts/dbManagement/helpers.ts new file mode 100644 index 00000000000..83fdd26605e --- /dev/null +++ b/scripts/dbManagement/helpers.ts @@ -0,0 +1,595 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import readline from "node:readline"; +import { fileURLToPath } from "node:url"; +import { hash } from "@node-rs/argon2"; +import { sql } from "drizzle-orm"; +import type { AnyPgColumn, PgTable } from "drizzle-orm/pg-core"; +import { drizzle } from "drizzle-orm/postgres-js"; +import envSchema from "env-schema"; +import { Client as MinioClient } from "minio"; +import postgres from "postgres"; +import * as schema from "src/drizzle/schema"; +import { + type EnvConfig, + envConfigSchema, + envSchemaAjv, +} from "src/envConfigSchema"; +import { uuidv7 } from "uuidv7"; + +const envConfig = envSchema({ + ajv: envSchemaAjv, + dotenv: true, + schema: envConfigSchema, +}); + +// Get the directory name of the current module +export const dirname: string = path.dirname(fileURLToPath(import.meta.url)); +export const bucketName: string = envConfig.MINIO_ROOT_USER || ""; +// Create a new database client +export const queryClient = postgres({ + host: envConfig.API_POSTGRES_HOST, + port: Number(envConfig.API_POSTGRES_PORT) || 5432, + database: envConfig.API_POSTGRES_DATABASE || "", + username: envConfig.API_POSTGRES_USER || "", + password: envConfig.API_POSTGRES_PASSWORD || "", + ssl: envConfig.API_POSTGRES_SSL_MODE === "allow", +}); + +//Create a bucket client +const minioClient = new MinioClient({ + accessKey: envConfig.API_MINIO_ACCESS_KEY || "", + endPoint: envConfig.API_MINIO_END_POINT || "", + port: Number(envConfig.API_MINIO_PORT), + secretKey: envConfig.API_MINIO_SECRET_KEY || "", + useSSL: envConfig.API_MINIO_USE_SSL === true, +}); + +export const db = drizzle(queryClient, { schema }); + +/** + * Prompts the user for confirmation using the built-in readline module. + */ +export async function askUserToContinue(question: string): Promise { + return new Promise((resolve) => { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + + rl.question(`${question} (y/n): `, (answer) => { + rl.close(); + resolve(answer.trim().toLowerCase() === "y"); + }); + }); +} + +/** + * Clears all tables in the database. + */ +export async function formatDatabase(): Promise { + type TableRow = { tablename: string }; + + try { + await db.transaction(async (tx) => { + const tables: TableRow[] = await tx.execute(sql` + SELECT tablename FROM pg_catalog.pg_tables + WHERE schemaname = 'public' + `); + const tableNames = tables.map((row) => sql.identifier(row.tablename)); + + if (tableNames.length > 0) { + await tx.execute( + sql`TRUNCATE TABLE ${sql.join(tableNames, sql`, `)} RESTART IDENTITY CASCADE;`, + ); + } + }); + + return true; + } catch (error) { + return false; + } +} + +export async function ensureAdministratorExists(): Promise { + const email = envConfig.API_ADMINISTRATOR_USER_EMAIL_ADDRESS; + + if (!email) { + throw new Error("API_ADMINISTRATOR_USER_EMAIL_ADDRESS is not defined."); + } + + const existingUser = await db.query.usersTable.findFirst({ + columns: { id: true, role: true }, + where: (fields, operators) => operators.eq(fields.emailAddress, email), + }); + + if (existingUser) { + if (existingUser.role !== "administrator") { + await db + .update(schema.usersTable) + .set({ role: "administrator" }) + .where(sql`email_address = ${email}`); + console.log( + "\x1b[33mRole Change: Updated user role to administrator\x1b[0m\n", + ); + } else { + console.log("\x1b[32mFound:\x1b[0m Administrator user already exists"); + } + return true; + } + + const userId = uuidv7(); + const password = envConfig.API_ADMINISTRATOR_USER_PASSWORD; + if (!password) { + throw new Error("API_ADMINISTRATOR_USER_PASSWORD is not defined."); + } + const passwordHash = await hash(password); + + await db.insert(schema.usersTable).values({ + id: userId, + emailAddress: email, + name: envConfig.API_ADMINISTRATOR_USER_NAME || "", + passwordHash, + role: "administrator", + isEmailAddressVerified: true, + creatorId: userId, + }); + + return true; +} + +export async function emptyMinioBucket(): Promise { + try { + // List all objects in the bucket. + const objectsList: string[] = await new Promise( + (resolve, reject) => { + const objects: string[] = []; + const stream = minioClient.listObjects(bucketName, "", true); + stream.on( + "data", + (obj: { + name: string; + }) => { + objects.push(obj.name); + }, + ); + stream.on("error", (err: Error) => { + console.error("Error listing objects in bucket:", err); + reject(err); + }); + stream.on("end", () => resolve(objects)); + }, + ); + + // If there are objects, remove them all using removeObjects. + if (objectsList.length > 0) { + await minioClient.removeObjects(bucketName, objectsList); + } + + return true; + } catch (error: unknown) { + console.error("Error emptying bucket:", error); + return false; + } +} + +/** + * Lists sample data files and their document counts in the sample_data directory. + */ +export async function listSampleData(): Promise { + try { + const sampleDataPath = path.resolve(dirname, "./sample_data"); + const files = await fs.readdir(sampleDataPath); + console.log(files); + console.log("Sample Data Files:\n"); + + console.log( + `${"| File Name".padEnd(30)}| Document Count | +${"|".padEnd(30, "-")}|----------------| +`, + ); + + for (const file of files) { + const filePath = path.resolve(sampleDataPath, file); + const stats = await fs.stat(filePath); + if (stats.isFile()) { + const data = await fs.readFile(filePath, "utf8"); + const docs = JSON.parse(data); + console.log( + `| ${file.padEnd(28)}| ${docs.length.toString().padEnd(15)}|`, + ); + } + } + console.log(); + } catch (err) { + throw new Error(`\x1b[31mError listing sample data: ${err}\x1b[0m`); + } + + return true; +} + +/** + * Check database connection + */ + +export async function pingDB(): Promise { + try { + await db.execute(sql`SELECT 1`); + } catch (error) { + throw new Error("Unable to connect to the database."); + } + return true; +} + +/** + * Check duplicate data + */ + +export async function checkAndInsertData( + table: PgTable, + rows: T[], + conflictTarget: AnyPgColumn | AnyPgColumn[], + batchSize: number, +): Promise { + if (!rows.length) return false; + + await db.transaction(async (tx) => { + for (let i = 0; i < rows.length; i += batchSize) { + const batch = rows.slice(i, i + batchSize); + await tx + .insert(table) + .values(batch) + .onConflictDoNothing({ + target: Array.isArray(conflictTarget) + ? conflictTarget + : [conflictTarget], + }); + } + }); + return true; +} + +/** + * Inserts data into specified tables. + * @param collections - Array of collection/table names to insert data into + * @param options - Options for loading data + */ + +export async function insertCollections( + collections: string[], +): Promise { + try { + await checkDataSize("Before"); + + const API_ADMINISTRATOR_USER_EMAIL_ADDRESS = + envConfig.API_ADMINISTRATOR_USER_EMAIL_ADDRESS; + if (!API_ADMINISTRATOR_USER_EMAIL_ADDRESS) { + throw new Error( + "\x1b[31mAPI_ADMINISTRATOR_USER_EMAIL_ADDRESS is not defined.\x1b[0m", + ); + } + + for (const collection of collections) { + const dataPath = path.resolve( + dirname, + `./sample_data/${collection}.json`, + ); + const fileContent = await fs.readFile(dataPath, "utf8"); + + switch (collection) { + case "users": { + const users = JSON.parse(fileContent).map( + (user: { + createdAt: string | number | Date; + updatedAt: string | number | Date; + }) => ({ + ...user, + createdAt: parseDate(user.createdAt), + updatedAt: parseDate(user.updatedAt), + }), + ) as (typeof schema.usersTable.$inferInsert)[]; + + await checkAndInsertData( + schema.usersTable, + users, + schema.usersTable.id, + 1000, + ); + + console.log( + "\n\x1b[35mAdded: Users table data (skipping duplicates)\x1b[0m", + ); + break; + } + + case "organizations": { + const organizations = JSON.parse(fileContent).map( + (org: { + createdAt: string | number | Date; + updatedAt: string | number | Date; + }) => ({ + ...org, + createdAt: parseDate(org.createdAt), + updatedAt: parseDate(org.updatedAt), + }), + ) as (typeof schema.organizationsTable.$inferInsert)[]; + + await checkAndInsertData( + schema.organizationsTable, + organizations, + schema.organizationsTable.id, + 1000, + ); + + const API_ADMINISTRATOR_USER = await db.query.usersTable.findFirst({ + columns: { + id: true, + }, + where: (fields, operators) => + operators.eq( + fields.emailAddress, + API_ADMINISTRATOR_USER_EMAIL_ADDRESS, + ), + }); + if (!API_ADMINISTRATOR_USER) { + throw new Error( + "\x1b[31mAPI_ADMINISTRATOR_USER_EMAIL_ADDRESS is not found in users table\x1b[0m", + ); + } + + const organizationAdminMembership = organizations.map((org) => ({ + organizationId: org.id, + memberId: API_ADMINISTRATOR_USER.id, + creatorId: API_ADMINISTRATOR_USER.id, + createdAt: new Date(), + role: "administrator", + })) as (typeof schema.organizationMembershipsTable.$inferInsert)[]; + + await checkAndInsertData( + schema.organizationMembershipsTable, + organizationAdminMembership, + [ + schema.organizationMembershipsTable.organizationId, + schema.organizationMembershipsTable.memberId, + ], + 1000, + ); + + console.log( + "\x1b[35mAdded: Organizations table data (skipping duplicates), plus admin memberships\x1b[0m", + ); + break; + } + + case "organization_memberships": { + const organizationMemberships = JSON.parse(fileContent).map( + (membership: { + createdAt: string | number | Date; + }) => ({ + ...membership, + createdAt: parseDate(membership.createdAt), + }), + ) as (typeof schema.organizationMembershipsTable.$inferInsert)[]; + + await checkAndInsertData( + schema.organizationMembershipsTable, + organizationMemberships, + [ + schema.organizationMembershipsTable.organizationId, + schema.organizationMembershipsTable.memberId, + ], + 1000, + ); + + console.log( + "\x1b[35mAdded: Organization_memberships data (skipping duplicates)\x1b[0m", + ); + break; + } + case "posts": { + const posts = JSON.parse(fileContent).map( + (post: { createdAt: string | number | Date }) => ({ + ...post, + createdAt: parseDate(post.createdAt), + }), + ) as (typeof schema.postsTable.$inferInsert)[]; + await checkAndInsertData( + schema.postsTable, + posts, + schema.postsTable.id, + 1000, + ); + console.log( + "\x1b[35mAdded: Posts table data (skipping duplicates)\x1b[0m", + ); + break; + } + case "post_votes": { + const post_votes = JSON.parse(fileContent).map( + (post_vote: { createdAt: string | number | Date }) => ({ + ...post_vote, + createdAt: parseDate(post_vote.createdAt), + }), + ) as (typeof schema.postVotesTable.$inferInsert)[]; + await checkAndInsertData( + schema.postVotesTable, + post_votes, + schema.postVotesTable.id, + 1000, + ); + console.log( + "\x1b[35mAdded: Post_votes table data (skipping duplicates)\x1b[0m", + ); + break; + } + case "post_attachments": { + const post_attachments = JSON.parse(fileContent).map( + (post_attachment: { createdAt: string | number | Date }) => ({ + ...post_attachment, + createdAt: parseDate(post_attachment.createdAt), + }), + ) as (typeof schema.postAttachmentsTable.$inferInsert)[]; + try { + // Post Attachements are not unique. So they are inserted without checking for duplicates. + await db + .insert(schema.postAttachmentsTable) + .values(post_attachments); + } catch { + throw new Error( + "\x1b[31mError inserting post_attachments data\x1b[0m", + ); + } + // Handle file uploads to Minio. + await Promise.all( + post_attachments.map(async (attachment) => { + try { + const fileExtension = attachment.mimeType.split("/").pop(); + const filePath = path.resolve( + dirname, + `./sample_data/images/${attachment.name}.${fileExtension}`, + ); + const fileData = await fs.readFile(filePath); + await minioClient.putObject( + bucketName, + attachment.name, + fileData, + undefined, + { + "content-type": attachment.mimeType, + }, + ); + } catch (error) { + console.error( + `Failed to upload attachment ${attachment.name}:`, + error, + ); + throw error; + } + }), + ); + console.log( + "\x1b[35mAdded: Post_attachments table data and uploaded files (Duplicates Allowed)\x1b[0m", + ); + break; + } + case "comments": { + const comments = JSON.parse(fileContent).map( + (comment: { createdAt: string | number | Date }) => ({ + ...comment, + createdAt: parseDate(comment.createdAt), + }), + ) as (typeof schema.commentsTable.$inferInsert)[]; + await checkAndInsertData( + schema.commentsTable, + comments, + schema.commentsTable.id, + 1000, + ); + console.log( + "\x1b[35mAdded: Comments table data (skipping duplicates)\x1b[0m", + ); + break; + } + case "comment_votes": { + const comment_votes = JSON.parse(fileContent).map( + (comment_vote: { createdAt: string | number | Date }) => ({ + ...comment_vote, + createdAt: parseDate(comment_vote.createdAt), + }), + ) as (typeof schema.commentVotesTable.$inferInsert)[]; + await checkAndInsertData( + schema.commentVotesTable, + comment_votes, + schema.commentVotesTable.id, + 1000, + ); + console.log( + "\x1b[35mAdded: Comment_votes table data (skipping duplicates)\x1b[0m", + ); + break; + } + + default: + console.log(`\x1b[31mInvalid table name: ${collection}\x1b[0m`); + break; + } + } + + await checkDataSize("After"); + } catch (err) { + throw new Error(`\x1b[31mError adding data to tables: ${err}\x1b[0m`); + } + + return true; +} + +/** + * Parses a date string and returns a Date object. Returns null if the date is invalid. + * @param date - The date string to parse + * @returns The parsed Date object or null + */ +export function parseDate(date: string | number | Date): Date | null { + const parsedDate = new Date(date); + return Number.isNaN(parsedDate.getTime()) ? null : parsedDate; +} + +/** + * Checks record counts in specified tables after data insertion. + * @returns {Promise} - Returns true if data exists, false otherwise. + */ +export async function checkDataSize(stage: string): Promise { + try { + const tables = [ + { name: "users", table: schema.usersTable }, + { name: "organizations", table: schema.organizationsTable }, + { + name: "organization_memberships", + table: schema.organizationMembershipsTable, + }, + { name: "posts", table: schema.postsTable }, + { name: "post_votes", table: schema.postVotesTable }, + { name: "post_attachments", table: schema.postAttachmentsTable }, + { name: "comments", table: schema.commentsTable }, + { name: "comment_votes", table: schema.commentVotesTable }, + ]; + + console.log(`\nRecord Counts ${stage} Import:\n`); + + console.log( + `${"| Table Name".padEnd(30)}| Record Count | +${"|".padEnd(30, "-")}|----------------| +`, + ); + + let dataExists = false; + + for (const { name, table } of tables) { + const result = await db + .select({ count: sql`count(*)` }) + .from(table); + + const count = result?.[0]?.count ?? 0; + console.log(`| ${name.padEnd(28)}| ${count.toString().padEnd(15)}|`); + + if (count > 0) { + dataExists = true; + } + } + + return dataExists; + } catch (err) { + console.error(`\x1b[31mError checking record count: ${err}\x1b[0m`); + return false; + } +} + +export async function disconnect(): Promise { + try { + await queryClient.end(); + } catch (err) { + throw new Error( + `\x1b[31mError disconnecting from the database: ${err}\x1b[0m`, + ); + } + return true; +} diff --git a/scripts/dbManagement/resetData.ts b/scripts/dbManagement/resetData.ts new file mode 100644 index 00000000000..5cba46be687 --- /dev/null +++ b/scripts/dbManagement/resetData.ts @@ -0,0 +1,86 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { + askUserToContinue, + disconnect, + emptyMinioBucket, + ensureAdministratorExists, + formatDatabase, + pingDB, +} from "./helpers"; + +export async function main(): Promise { + const deleteExisting = await askUserToContinue( + "\x1b[31m Warning:\x1b[0m This will delete all data in the database. Are you sure you want to continue?", + ); + + if (deleteExisting) { + try { + await pingDB(); + console.log( + "\n\x1b[32mSuccess:\x1b[0m Database connected successfully\n", + ); + } catch (error: unknown) { + throw new Error(`Database connection failed: ${error}`); + } + try { + await formatDatabase(); + console.log("\n\x1b[32mSuccess:\x1b[0m Database formatted successfully"); + } catch (error: unknown) { + console.error( + "\n\x1b[31mError: Database formatting failed\n\x1b[0m", + error, + ); + console.error("\n\x1b[33mRolled back to previous state\x1b[0m"); + console.error("\n\x1b[33mPreserving administrator access\x1b[0m"); + } + try { + await emptyMinioBucket(); + console.log("\x1b[32mSuccess:\x1b[0m Bucket formatted successfully\n"); + } catch (error: unknown) { + console.error( + "\n\x1b[31mError: Bucket formatting failed\n\x1b[0m", + error, + ); + } + try { + await ensureAdministratorExists(); + console.log("\x1b[32mSuccess:\x1b[0m Administrator access restored\n"); + } catch (error: unknown) { + console.error("\nError: Administrator creation failed", error); + console.error( + "\n\x1b[31mAdministrator access may be lost, try reformatting DB to restore access\x1b[0m\n", + ); + } + } else { + console.log("Operation cancelled"); + } + + return; +} + +const scriptPath = fileURLToPath(import.meta.url); +export const isMain = + process.argv[1] && path.resolve(process.argv[1]) === path.resolve(scriptPath); + +if (isMain) { + let exitCode = 0; + (async () => { + try { + await main(); + } catch (error: unknown) { + exitCode = 1; + } + try { + await disconnect(); + console.log( + "\n\x1b[32mSuccess:\x1b[0m Gracefully disconnecting from the database\n", + ); + } catch (error: unknown) { + console.error("Error: Cannot disconnect", error); + exitCode = 1; + } finally { + process.exit(exitCode); + } + })(); +} diff --git a/scripts/dbManagement/sample_data/comment_votes.json b/scripts/dbManagement/sample_data/comment_votes.json new file mode 100644 index 00000000000..e3f993bc3ea --- /dev/null +++ b/scripts/dbManagement/sample_data/comment_votes.json @@ -0,0 +1,59 @@ +[ + { + "id": "a3990b3f-00f4-49a5-898f-ecea9de497a5", + "commentId": "14be44b7-cb4e-4d94-a115-16016674b02a", + "createdAt": "2025-02-10T12:20:00.000Z", + "creatorId": "0194e194-c6b3-7802-b074-362efea24dbc", + "type": "up_vote" + }, + { + "id": "327fc011-fa70-49cf-84ab-64747a8ca4fc", + "commentId": "ac01b0f6-7bf0-4463-a3e1-95131da20bb5", + "createdAt": "2025-02-10T12:25:00.000Z", + "creatorId": "65378abd-8500-8f17-1cf2-990d00000002", + "type": "down_vote" + }, + { + "id": "63c8d09c-cbc5-41fc-8e10-5bcf1c51dc74", + "commentId": "53960440-f930-4958-8bff-f7390e4f6e78", + "createdAt": "2025-02-09T10:30:00.000Z", + "creatorId": "66378abd-8500-8f17-1cf2-990d00000003", + "type": "up_vote" + }, + { + "id": "303c1abb-7d96-4c24-bc60-27f3532a86ee", + "commentId": "1fe6f748-7f14-48a3-a9ba-fa4f2cd3ddbc", + "createdAt": "2025-02-09T10:35:00.000Z", + "creatorId": "67378abd-8500-8f17-1cf2-990d00000005", + "type": "down_vote" + }, + { + "id": "b2007dcf-6e33-4697-add1-593623c828cd", + "commentId": "8b681992-8670-43bf-b1f8-67cd82dc4e06", + "createdAt": "2025-02-08T08:55:00.000Z", + "creatorId": "658938a6-2caa-9d8d-6908-74880000000d", + "type": "up_vote" + }, + { + "id": "b74c0222-aef5-4c33-8468-1b775b6c8c56", + "commentId": "17efa258-03cf-4535-addb-2a496f4795ea", + "createdAt": "2025-02-08T09:00:00.000Z", + "creatorId": "67378abd-8500-8f17-1cf2-990d00000004", + "type": "down_vote" + }, + { + "id": "b2edacc1-bf22-40b0-899f-d0791e80c7e6", + "commentId": "4fbcce27-b64b-4455-87c5-93f8e19997d2", + "createdAt": "2025-02-07T09:45:00.000Z", + "creatorId": "658938b0-2caa-9d8d-6908-74890000000e", + "type": "up_vote" + }, + { + "id": "7820b237-5df6-42ea-ba7e-41d9c452d2e5", + "commentId": "ba206aeb-ffe6-49fd-8599-eac9c367bdda", + "createdAt": "2025-02-07T09:50:00.000Z", + "creatorId": "0194e194-c6b3-7802-b074-362efea24dbc", + "type": "down_vote" + } + ] + \ No newline at end of file diff --git a/scripts/dbManagement/sample_data/comments.json b/scripts/dbManagement/sample_data/comments.json new file mode 100644 index 00000000000..d9602f6ecf3 --- /dev/null +++ b/scripts/dbManagement/sample_data/comments.json @@ -0,0 +1,59 @@ +[ + { + "id": "14be44b7-cb4e-4d94-a115-16016674b02a", + "body": "This is an insightful post!", + "createdAt": "2025-02-10T12:10:00.000Z", + "creatorId": "0194e194-c6b3-7802-b074-362efea24dbc", + "postId": "2778f4a2-a6f1-45cc-857f-727ca942899d" + }, + { + "id": "ac01b0f6-7bf0-4463-a3e1-95131da20bb5", + "body": "Great initiative! Looking forward to more updates.", + "createdAt": "2025-02-10T12:15:00.000Z", + "creatorId": "65378abd-8500-8f17-1cf2-990d00000002", + "postId": "98cef234-af91-4a11-b33f-5fd6cf41219c" + }, + { + "id": "53960440-f930-4958-8bff-f7390e4f6e78", + "body": "This bootcamp will help a lot of people!", + "createdAt": "2025-02-09T10:20:00.000Z", + "creatorId": "66378abd-8500-8f17-1cf2-990d00000003", + "postId": "a44d75a0-267f-4429-b03c-a26cc4ec56dc" + }, + { + "id": "1fe6f748-7f14-48a3-a9ba-fa4f2cd3ddbc", + "body": "Well organized event! Keep up the good work.", + "createdAt": "2025-02-09T10:25:00.000Z", + "creatorId": "67378abd-8500-8f17-1cf2-990d00000005", + "postId": "f3da248b-ee18-4197-bd0b-de5480c010ee" + }, + { + "id": "8b681992-8670-43bf-b1f8-67cd82dc4e06", + "body": "Providing free legal aid is crucial!", + "createdAt": "2025-02-08T08:50:00.000Z", + "creatorId": "658938a6-2caa-9d8d-6908-74880000000d", + "postId": "885bee36-2751-4d78-bfd4-7ededdd89a0f" + }, + { + "id": "17efa258-03cf-4535-addb-2a496f4795ea", + "body": "Access to justice for all is a fundamental right!", + "createdAt": "2025-02-08T08:55:00.000Z", + "creatorId": "67378abd-8500-8f17-1cf2-990d00000004", + "postId": "ecaeed44-d0b2-4559-bad6-ab6f49362325" + }, + { + "id": "4fbcce27-b64b-4455-87c5-93f8e19997d2", + "body": "Senior wellness programs are essential!", + "createdAt": "2025-02-07T09:35:00.000Z", + "creatorId": "658938b0-2caa-9d8d-6908-74890000000e", + "postId": "2022fe3e-846e-48de-8a27-230fda85e495" + }, + { + "id": "ba206aeb-ffe6-49fd-8599-eac9c367bdda", + "body": "Such programs help maintain mental and physical health.", + "createdAt": "2025-02-07T09:40:00.000Z", + "creatorId": "0194e194-c6b3-7802-b074-362efea24dbc", + "postId": "17f0e29a-f7a2-435e-8dc1-2e52c6b25b6c" + } + ] + \ No newline at end of file diff --git "a/scripts/dbManagement/sample_data/images/DALL\302\267E_talawa_1.webp" "b/scripts/dbManagement/sample_data/images/DALL\302\267E_talawa_1.webp" new file mode 100644 index 00000000000..f65dadae1d3 Binary files /dev/null and "b/scripts/dbManagement/sample_data/images/DALL\302\267E_talawa_1.webp" differ diff --git "a/scripts/dbManagement/sample_data/images/DALL\302\267E_talawa_2.webp" "b/scripts/dbManagement/sample_data/images/DALL\302\267E_talawa_2.webp" new file mode 100644 index 00000000000..b1ce5e2f72f Binary files /dev/null and "b/scripts/dbManagement/sample_data/images/DALL\302\267E_talawa_2.webp" differ diff --git "a/scripts/dbManagement/sample_data/images/DALL\302\267E_talawa_3.webp" "b/scripts/dbManagement/sample_data/images/DALL\302\267E_talawa_3.webp" new file mode 100644 index 00000000000..8f9148e68ab Binary files /dev/null and "b/scripts/dbManagement/sample_data/images/DALL\302\267E_talawa_3.webp" differ diff --git "a/scripts/dbManagement/sample_data/images/DALL\302\267E_talawa_4.webp" "b/scripts/dbManagement/sample_data/images/DALL\302\267E_talawa_4.webp" new file mode 100644 index 00000000000..f65dadae1d3 Binary files /dev/null and "b/scripts/dbManagement/sample_data/images/DALL\302\267E_talawa_4.webp" differ diff --git "a/scripts/dbManagement/sample_data/images/DALL\302\267E_talawa_5.webp" "b/scripts/dbManagement/sample_data/images/DALL\302\267E_talawa_5.webp" new file mode 100644 index 00000000000..b1ce5e2f72f Binary files /dev/null and "b/scripts/dbManagement/sample_data/images/DALL\302\267E_talawa_5.webp" differ diff --git "a/scripts/dbManagement/sample_data/images/DALL\302\267E_talawa_6.webp" "b/scripts/dbManagement/sample_data/images/DALL\302\267E_talawa_6.webp" new file mode 100644 index 00000000000..8f9148e68ab Binary files /dev/null and "b/scripts/dbManagement/sample_data/images/DALL\302\267E_talawa_6.webp" differ diff --git a/sample_data/organization_memberships.json b/scripts/dbManagement/sample_data/organization_memberships.json similarity index 100% rename from sample_data/organization_memberships.json rename to scripts/dbManagement/sample_data/organization_memberships.json diff --git a/sample_data/organizations.json b/scripts/dbManagement/sample_data/organizations.json similarity index 100% rename from sample_data/organizations.json rename to scripts/dbManagement/sample_data/organizations.json diff --git a/scripts/dbManagement/sample_data/post_attachments.json b/scripts/dbManagement/sample_data/post_attachments.json new file mode 100644 index 00000000000..e5867633888 --- /dev/null +++ b/scripts/dbManagement/sample_data/post_attachments.json @@ -0,0 +1,51 @@ +[ + { + "id": "8a0aff46-6f59-4626-9005-6528fdcab7e8", + "createdAt": "2025-02-10T12:10:00.000Z", + "creatorId": "0194e194-c6b3-7802-b074-362efea24dbc", + "postId": "2778f4a2-a6f1-45cc-857f-727ca942899d", + "mimeType": "image/webp", + "name": "DALL·E_talawa_1" + }, + { + "id": "e1bba4bb-5edd-40fd-9baf-741647d26ec0", + "createdAt": "2025-02-10T12:11:00.000Z", + "creatorId": "0194e194-c6b3-7802-b074-362efea24dbc", + "postId": "98cef234-af91-4a11-b33f-5fd6cf41219c", + "mimeType": "image/webp", + "name": "DALL·E_talawa_2" + }, + { + "id": "5065ab3d-51bd-4c6e-b3e3-389af5610cc3", + "createdAt": "2025-02-10T12:20:00.000Z", + "creatorId": "65378abd-8500-8f17-1cf2-990d00000002", + "postId": "a44d75a0-267f-4429-b03c-a26cc4ec56dc", + "mimeType": "image/webp", + "name": "DALL·E_talawa_3" + }, + { + "id": "ea15240d-269d-4c94-8f7c-fa4723bd3413", + "createdAt": "2025-02-10T12:21:00.000Z", + "creatorId": "65378abd-8500-8f17-1cf2-990d00000002", + "postId": "f3da248b-ee18-4197-bd0b-de5480c010ee", + "mimeType": "image/webp", + "name": "DALL·E_talawa_4" + }, + { + "id": "c898e282-29e4-4f0c-abb3-0829c6091575", + "createdAt": "2025-02-10T12:30:00.000Z", + "creatorId": "67378abd-8500-8f17-1cf2-990d00000005", + "postId": "885bee36-2751-4d78-bfd4-7ededdd89a0f", + "mimeType": "image/webp", + "name": "DALL·E_talawa_5" + }, + { + "id": "685b4d55-9627-474d-a8d8-d36aaf34a93d", + "createdAt": "2025-02-10T12:31:00.000Z", + "creatorId": "67378abd-8500-8f17-1cf2-990d00000005", + "postId": "ecaeed44-d0b2-4559-bad6-ab6f49362325", + "mimeType": "image/webp", + "name": "DALL·E_talawa_6" + } + ] + \ No newline at end of file diff --git a/scripts/dbManagement/sample_data/post_votes.json b/scripts/dbManagement/sample_data/post_votes.json new file mode 100644 index 00000000000..1a6bf5b7f82 --- /dev/null +++ b/scripts/dbManagement/sample_data/post_votes.json @@ -0,0 +1,59 @@ +[ + { + "id": "8e3bae34-955b-4f55-9118-e03e56c5faeb", + "createdAt": "2025-02-12T14:10:00.000Z", + "creatorId": "0194e194-c6b3-7802-b074-362efea24dbc", + "postId": "2778f4a2-a6f1-45cc-857f-727ca942899d", + "type": "up_vote" + }, + { + "id": "abe50392-5b74-4edc-a933-7e98f1106999", + "createdAt": "2025-02-12T14:12:00.000Z", + "creatorId": "65378abd-8500-8f17-1cf2-990d00000002", + "postId": "98cef234-af91-4a11-b33f-5fd6cf41219c", + "type": "down_vote" + }, + { + "id": "7491184e-d12a-4d19-a955-78a08b2fa058", + "createdAt": "2025-02-13T10:30:00.000Z", + "creatorId": "67378abd-8500-8f17-1cf2-990d00000005", + "postId": "a44d75a0-267f-4429-b03c-a26cc4ec56dc", + "type": "up_vote" + }, + { + "id": "cca57ea7-2c98-4bb7-8d76-a01a8bfbfaf4", + "createdAt": "2025-02-13T10:35:00.000Z", + "creatorId": "0194e194-c6b3-7802-b074-362efea24dbc", + "postId": "f3da248b-ee18-4197-bd0b-de5480c010ee", + "type": "down_vote" + }, + { + "id": "f5644877-31a8-443f-bd21-c79269c9f859", + "createdAt": "2025-02-14T08:45:00.000Z", + "creatorId": "67378abd-8500-8f17-1cf2-990d00000006", + "postId": "885bee36-2751-4d78-bfd4-7ededdd89a0f", + "type": "up_vote" + }, + { + "id": "da8e8ab7-3521-4596-992d-c2de4e60bb71", + "createdAt": "2025-02-14T08:50:00.000Z", + "creatorId": "66378abd-8500-8f17-1cf2-990d00000003", + "postId": "ecaeed44-d0b2-4559-bad6-ab6f49362325", + "type": "down_vote" + }, + { + "id": "2ca3cced-6dfc-4f66-b79e-c18b4477080e", + "createdAt": "2025-02-15T09:30:00.000Z", + "creatorId": "658938a6-2caa-9d8d-6908-74880000000d", + "postId": "d3288d60-11c7-4401-ac70-f36f289c385f", + "type": "up_vote" + }, + { + "id": "29c8d791-8aac-4a73-a16c-84696366d42e", + "createdAt": "2025-02-15T09:35:00.000Z", + "creatorId": "67378abd-8500-8f17-1cf2-990d00000007", + "postId": "2022fe3e-846e-48de-8a27-230fda85e495", + "type": "down_vote" + } +] + \ No newline at end of file diff --git a/scripts/dbManagement/sample_data/posts.json b/scripts/dbManagement/sample_data/posts.json new file mode 100644 index 00000000000..822180c4f45 --- /dev/null +++ b/scripts/dbManagement/sample_data/posts.json @@ -0,0 +1,66 @@ +[ + { + "id": "2778f4a2-a6f1-45cc-857f-727ca942899d", + "caption": "Launching an initiative to support local artists in NYC.", + "createdAt": "2025-02-10T12:00:00.000Z", + "creatorId": "0194e194-c6b3-7802-b074-362efea24dbc", + "organizationId": "ab1c2d3e-4f5b-6a7c-8d9e-0f1a2b3c4d5e" + }, + { + "id": "98cef234-af91-4a11-b33f-5fd6cf41219c", + "caption": "Fostering digital literacy programs for young learners.", + "createdAt": "2025-02-13T11:20:00.000Z", + "creatorId": "658938a6-2caa-9d8d-6908-74880000000d", + "organizationId": "ab1c2d3e-4f5b-6a7c-8d9e-0f1a2b3c4d5e" + }, + { + "id": "a44d75a0-267f-4429-b03c-a26cc4ec56dc", + "caption": "Developing a community-based environmental awareness campaign.", + "createdAt": "2025-02-16T08:50:00.000Z", + "creatorId": "67378abd-8500-8f17-1cf2-990d00000005", + "organizationId": "cd3e4f5b-6a7c-8d9e-0f1a-2b3c4d5e6f7a" + }, + { + "id": "f3da248b-ee18-4197-bd0b-de5480c010ee", + "caption": "Introducing an inclusive sports initiative for children with disabilities.", + "createdAt": "2025-02-18T16:30:00.000Z", + "creatorId": "67378abd-8500-8f17-1cf2-990d00000007", + "organizationId": "bc2d3e4f-5a6b-7c8d-9e0f-1a2b3c4d5e6f" + }, + { + "id": "885bee36-2751-4d78-bfd4-7ededdd89a0f", + "caption": "Building a solar-powered school in rural Africa.", + "createdAt": "2025-02-19T10:15:00.000Z", + "creatorId": "658930fd-2caa-9d8d-6908-745c00000008", + "organizationId": "cd3e4f5b-6a7c-8d9e-0f1a-2b3c4d5e6f7a" + }, + { + "id": "ecaeed44-d0b2-4559-bad6-ab6f49362325", + "caption": "Launching an initiative for ocean conservation awareness.", + "createdAt": "2025-02-20T14:45:00.000Z", + "creatorId": "67378abd-8500-8f17-1cf2-990d00000006", + "organizationId": "cd3e4f5b-6a7c-8d9e-0f1a-2b3c4d5e6f7a" + }, + { + "id": "d3288d60-11c7-4401-ac70-f36f289c385f", + "caption": "Providing mental health support for teenagers.", + "createdAt": "2025-02-21T09:30:00.000Z", + "creatorId": "658938b0-2caa-9d8d-6908-74890000000e", + "organizationId": "cd3e4f5b-6a7c-8d9e-0f1a-2b3c4d5e6f7a" + }, + { + "id": "2022fe3e-846e-48de-8a27-230fda85e495", + "caption": "Providing food relief programs in disaster-prone areas.", + "createdAt": "2025-02-22T16:00:00.000Z", + "creatorId": "6589389d-2caa-9d8d-6908-74870000000c", + "organizationId": "cd3e4f5b-6a7c-8d9e-0f1a-2b3c4d5e6f7a" + }, + { + "id": "17f0e29a-f7a2-435e-8dc1-2e52c6b25b6c", + "caption": "Setting up free WiFi access in underprivileged communities.", + "createdAt": "2025-02-23T12:10:00.000Z", + "creatorId": "658938a6-2caa-9d8d-6908-74880000000d", + "organizationId": "cd3e4f5b-6a7c-8d9e-0f1a-2b3c4d5e6f7a" + } + ] + \ No newline at end of file diff --git a/sample_data/users.json b/scripts/dbManagement/sample_data/users.json similarity index 100% rename from sample_data/users.json rename to scripts/dbManagement/sample_data/users.json diff --git a/src/envConfigSchema.ts b/src/envConfigSchema.ts index ed0dde8c057..a2a825651f8 100644 --- a/src/envConfigSchema.ts +++ b/src/envConfigSchema.ts @@ -161,6 +161,10 @@ export const envConfigSchema = Type.Object({ trace: "trace", warn: "warn", }), + /** + * More information can be found at: {@link https://github.com/minio/minio-js?tab=readme-ov-file#initialize-minio-client} + */ + MINIO_ROOT_USER: Type.Optional(Type.String({ minLength: 1 })), /** * More information can be found at: {@link https://github.com/minio/minio-js?tab=readme-ov-file#initialize-minio-client} */ diff --git a/src/graphql/inputs/MutationDeleteAgendaItemInput.ts b/src/graphql/inputs/MutationDeleteAgendaItemInput.ts index fed0693433a..30eff346c84 100644 --- a/src/graphql/inputs/MutationDeleteAgendaItemInput.ts +++ b/src/graphql/inputs/MutationDeleteAgendaItemInput.ts @@ -2,12 +2,12 @@ import { z } from "zod"; import { agendaItemsTableInsertSchema } from "~/src/drizzle/tables/agendaItems"; import { builder } from "~/src/graphql/builder"; -export const mutationDeleteAgendaItemInputSchema = z.object({ +export const MutationDeleteAgendaItemInputSchema = z.object({ id: agendaItemsTableInsertSchema.shape.id.unwrap(), }); export const MutationDeleteAgendaItemInput = builder - .inputRef>( + .inputRef>( "MutationDeleteAgendaItemInput", ) .implement({ diff --git a/src/graphql/types/Fund/updater.ts b/src/graphql/types/Fund/updater.ts index bb870ac6838..ed3ffbe28f9 100644 --- a/src/graphql/types/Fund/updater.ts +++ b/src/graphql/types/Fund/updater.ts @@ -1,90 +1,95 @@ import { User } from "~/src/graphql/types/User/User"; import { TalawaGraphQLError } from "~/src/utilities/TalawaGraphQLError"; +import type { GraphQLContext } from "../../context"; import { Fund } from "./Fund"; -Fund.implement({ - fields: (t) => ({ - updater: t.field({ - description: "User who last updated the fund.", - resolve: async (parent, _args, ctx) => { - if (!ctx.currentClient.isAuthenticated) { - throw new TalawaGraphQLError({ - extensions: { - code: "unauthenticated", - }, - }); - } +const authenticateUser = async (ctx: GraphQLContext) => { + if (!ctx.currentClient.isAuthenticated) { + throw new TalawaGraphQLError({ + extensions: { code: "unauthenticated" }, + }); + } - const currentUserId = ctx.currentClient.user.id; + const currentUserId = ctx.currentClient.user.id; - const currentUser = await ctx.drizzleClient.query.usersTable.findFirst({ - with: { - organizationMembershipsWhereMember: { - columns: { - role: true, - }, - where: (fields, operators) => - operators.eq(fields.organizationId, parent.organizationId), - }, - }, - where: (fields, operators) => operators.eq(fields.id, currentUserId), - }); - - if (currentUser === undefined) { - throw new TalawaGraphQLError({ - extensions: { - code: "unauthenticated", - }, - }); - } + const currentUser = await ctx.drizzleClient.query.usersTable.findFirst({ + with: { + organizationMembershipsWhereMember: { + columns: { role: true }, + }, + }, + where: (fields, operators) => operators.eq(fields.id, currentUserId), + }); - const currentUserOrganizationMembership = - currentUser.organizationMembershipsWhereMember[0]; + if (currentUser === undefined) { + throw new TalawaGraphQLError({ + extensions: { + code: "unauthenticated", + }, + }); + } - if ( - currentUser.role !== "administrator" && - (currentUserOrganizationMembership === undefined || - currentUserOrganizationMembership.role !== "administrator") - ) { - throw new TalawaGraphQLError({ - extensions: { - code: "unauthorized_action", - }, - }); - } + return currentUser; +}; - if (parent.updaterId === null) { - return null; - } +const resolveUpdater = async ( + parent: Fund, + _: unknown, + ctx: GraphQLContext, +) => { + const currentUser = await authenticateUser(ctx); + const currentUserId = ctx.currentClient.user?.id; - if (parent.updaterId === currentUserId) { - return currentUser; - } + const currentUserOrganizationMembership = + currentUser.organizationMembershipsWhereMember?.[0]; - const updaterId = parent.updaterId; + if ( + currentUser.role !== "administrator" && + (currentUserOrganizationMembership === undefined || + currentUserOrganizationMembership.role !== "administrator") + ) { + throw new TalawaGraphQLError({ + extensions: { + code: "unauthorized_action", + }, + }); + } - const existingUser = await ctx.drizzleClient.query.usersTable.findFirst( - { - where: (fields, operators) => operators.eq(fields.id, updaterId), - }, - ); + if (parent.updaterId === null) { + return null; + } + if (parent.updaterId === currentUserId) { + return currentUser; + } - // Updater id existing but the associated user not existing is a business logic error and probably means that the corresponding data in the database is in a corrupted state. It must be investigated and fixed as soon as possible to prevent additional data corruption. - if (existingUser === undefined) { - ctx.log.error( - "Postgres select operation returned an empty array for a fund's updater id that isn't null.", - ); + const updaterId = parent.updaterId; - throw new TalawaGraphQLError({ - extensions: { - code: "unexpected", - }, - }); - } + const existingUser = await ctx.drizzleClient.query.usersTable.findFirst({ + where: (fields, operators) => operators.eq(fields.id, updaterId), + }); - return existingUser; + if (existingUser === undefined) { + ctx.log.error( + "Postgres select operation returned an empty array for a fund's updater id that isn't null.", + ); + throw new TalawaGraphQLError({ + extensions: { + code: "unexpected", }, + }); + } + + return existingUser; +}; + +Fund.implement({ + fields: (t) => ({ + updater: t.field({ + description: "User who last updated the fund.", + resolve: resolveUpdater, type: User, }), }), }); + +export { resolveUpdater }; diff --git a/src/graphql/types/Mutation/deleteAgendaItem.ts b/src/graphql/types/Mutation/deleteAgendaItem.ts index 002562d7d26..a5470668a49 100644 --- a/src/graphql/types/Mutation/deleteAgendaItem.ts +++ b/src/graphql/types/Mutation/deleteAgendaItem.ts @@ -4,13 +4,13 @@ import { agendaItemsTable } from "~/src/drizzle/tables/agendaItems"; import { builder } from "~/src/graphql/builder"; import { MutationDeleteAgendaItemInput, - mutationDeleteAgendaItemInputSchema, + MutationDeleteAgendaItemInputSchema, } from "~/src/graphql/inputs/MutationDeleteAgendaItemInput"; import { AgendaItem } from "~/src/graphql/types/AgendaItem/AgendaItem"; import { TalawaGraphQLError } from "~/src/utilities/TalawaGraphQLError"; const mutationDeleteAgendaItemArgumentsSchema = z.object({ - input: mutationDeleteAgendaItemInputSchema, + input: MutationDeleteAgendaItemInputSchema, }); builder.mutationField("deleteAgendaItem", (t) => @@ -153,7 +153,6 @@ builder.mutationField("deleteAgendaItem", (t) => }, }); } - return deletedAgendaItem; }, type: AgendaItem, diff --git a/src/graphql/types/Query/allUsers.ts b/src/graphql/types/Query/allUsers.ts new file mode 100644 index 00000000000..a37f5fc932c --- /dev/null +++ b/src/graphql/types/Query/allUsers.ts @@ -0,0 +1,224 @@ +import { + type SQL, + and, + asc, + desc, + eq, + exists, + gt, + ilike, + lt, + or, +} from "drizzle-orm"; +import { z } from "zod"; +import { usersTable } from "~/src/drizzle/tables/users"; +import { builder } from "~/src/graphql/builder"; +import { User } from "~/src/graphql/types/User/User"; +import { TalawaGraphQLError } from "~/src/utilities/TalawaGraphQLError"; +import { + defaultGraphQLConnectionArgumentsSchema, + transformDefaultGraphQLConnectionArguments, + transformToDefaultGraphQLConnection, +} from "~/src/utilities/defaultGraphQLConnection"; + +// Extend the default connection arguments to include name search +const allUsersArgumentsSchema = defaultGraphQLConnectionArgumentsSchema + .extend({ + name: z.string().min(1).optional().nullish(), + }) + .transform(transformDefaultGraphQLConnectionArguments) + .transform((arg, ctx) => { + let cursor: z.infer | undefined = undefined; + + try { + if (arg.cursor !== undefined) { + cursor = cursorSchema.parse( + JSON.parse(Buffer.from(arg.cursor, "base64url").toString("utf-8")), + ); + } + } catch (error) { + ctx.addIssue({ + code: "custom", + message: "Not a valid cursor.", + path: [arg.isInversed ? "before" : "after"], + }); + } + + return { + cursor, + isInversed: arg.isInversed, + limit: arg.limit, + name: arg.name, + }; + }); + +const cursorSchema = z + .object({ + createdAt: z.string().datetime(), + id: z.string().uuid(), + }) + .transform((arg) => ({ + createdAt: new Date(arg.createdAt), + id: arg.id, + })); + +builder.queryField("allUsers", (t) => + t.connection({ + type: User, + args: { + name: t.arg.string({ required: false }), + }, + description: "Query field to read all Users.", + resolve: async (_parent, args, ctx) => { + if (!ctx.currentClient.isAuthenticated) { + throw new TalawaGraphQLError({ + extensions: { + code: "unauthenticated", + }, + }); + } + + const { + data: parsedArgs, + error, + success, + } = allUsersArgumentsSchema.safeParse(args); + + if (!success) { + throw new TalawaGraphQLError({ + extensions: { + code: "invalid_arguments", + issues: error.issues.map((issue) => ({ + argumentPath: issue.path, + message: issue.message, + })), + }, + }); + } + + const currentUserId = ctx.currentClient.user.id; + + const currentUser = await ctx.drizzleClient.query.usersTable.findFirst({ + columns: { + role: true, + }, + where: (fields, operators) => operators.eq(fields.id, currentUserId), + }); + + if (currentUser === undefined) { + throw new TalawaGraphQLError({ + extensions: { + code: "unauthenticated", + }, + }); + } + + if (currentUser.role !== "administrator") { + throw new TalawaGraphQLError({ + extensions: { + code: "unauthorized_action", + }, + }); + } + + const { cursor, isInversed, limit, name } = parsedArgs; + + const orderBy = isInversed + ? [asc(usersTable.createdAt), asc(usersTable.id)] + : [desc(usersTable.createdAt), desc(usersTable.id)]; + + let where: SQL | undefined; + + // Add name search condition if provided + const nameCondition = name + ? ilike(usersTable.name, `%${name}%`) + : undefined; + + if (isInversed) { + if (cursor !== undefined) { + where = and( + exists( + ctx.drizzleClient + .select() + .from(usersTable) + .where( + and( + eq(usersTable.createdAt, cursor.createdAt), + eq(usersTable.id, cursor.id), + ), + ), + ), + or( + and( + eq(usersTable.createdAt, cursor.createdAt), + gt(usersTable.id, cursor.id), + ), + gt(usersTable.createdAt, cursor.createdAt), + ), + nameCondition, + ); + } else { + where = nameCondition; + } + } else { + if (cursor !== undefined) { + where = and( + exists( + ctx.drizzleClient + .select() + .from(usersTable) + .where( + and( + eq(usersTable.createdAt, cursor.createdAt), + eq(usersTable.id, cursor.id), + ), + ), + ), + or( + and( + eq(usersTable.createdAt, cursor.createdAt), + lt(usersTable.id, cursor.id), + ), + lt(usersTable.createdAt, cursor.createdAt), + ), + nameCondition, + ); + } else { + where = nameCondition; + } + } + + const users = await ctx.drizzleClient.query.usersTable.findMany({ + limit, + orderBy, + where, + }); + + if (cursor !== undefined && users.length === 0) { + throw new TalawaGraphQLError({ + extensions: { + code: "arguments_associated_resources_not_found", + issues: [ + { + argumentPath: [isInversed ? "before" : "after"], + }, + ], + }, + }); + } + + return transformToDefaultGraphQLConnection({ + createCursor: (user) => + Buffer.from( + JSON.stringify({ + createdAt: user.createdAt.toISOString(), + id: user.id, + }), + ).toString("base64url"), + createNode: (user) => user, + parsedArgs, + rawNodes: users, + }); + }, + }), +); diff --git a/src/graphql/types/Query/index.ts b/src/graphql/types/Query/index.ts index 41c208b462a..4a3f1cf2ee1 100644 --- a/src/graphql/types/Query/index.ts +++ b/src/graphql/types/Query/index.ts @@ -19,3 +19,4 @@ import "./tag"; import "./tagFolder"; import "./user"; import "./venue"; +import "./allUsers"; diff --git a/src/utilities/defaultGraphQLConnection.ts b/src/utilities/defaultGraphQLConnection.ts index e68d877f449..04d2718f1aa 100755 --- a/src/utilities/defaultGraphQLConnection.ts +++ b/src/utilities/defaultGraphQLConnection.ts @@ -210,7 +210,7 @@ export const transformToDefaultGraphQLConnection = < if (rawNodes.length === limit) { connection.pageInfo.hasPreviousPage = true; // Remove the extra fetched node. - rawNodes.shift(); + rawNodes.pop(); } else { connection.pageInfo.hasPreviousPage = false; } diff --git a/src/utilities/loadSampleData.ts b/src/utilities/loadSampleData.ts deleted file mode 100644 index aa51bddbfb1..00000000000 --- a/src/utilities/loadSampleData.ts +++ /dev/null @@ -1,305 +0,0 @@ -import fs from "node:fs/promises"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; -import dotenv from "dotenv"; -import { sql } from "drizzle-orm"; -import { drizzle } from "drizzle-orm/postgres-js"; -import inquirer from "inquirer"; -import postgres from "postgres"; -import * as schema from "../drizzle/schema"; - -dotenv.config(); - -const dirname: string = path.dirname(fileURLToPath(import.meta.url)); - -const queryClient = postgres({ - host: process.env.API_POSTGRES_HOST, - port: Number(process.env.API_POSTGRES_PORT), - database: process.env.API_POSTGRES_DATABASE, - username: process.env.API_POSTGRES_USER, - password: process.env.API_POSTGRES_PASSWORD, - ssl: process.env.API_POSTGRES_SSL_MODE === "true", -}); - -const db = drizzle(queryClient, { schema }); - -interface LoadOptions { - items?: string[]; - format?: boolean; -} - -/** - * Lists sample data files and their document counts in the sample_data directory. - */ -export async function listSampleData(): Promise { - try { - const sampleDataPath = path.resolve(dirname, "../../sample_data"); - const files = await fs.readdir(sampleDataPath); - - console.log("Sample Data Files:\n"); - - console.log( - `${"| File Name".padEnd(30)}| Document Count | -${"|".padEnd(30, "-")}|----------------| -`, - ); - - for (const file of files) { - const filePath = path.resolve(sampleDataPath, file); - const stats = await fs.stat(filePath); - if (stats.isFile()) { - const data = await fs.readFile(filePath, "utf8"); - const docs = JSON.parse(data); - console.log( - `| ${file.padEnd(28)}| ${docs.length.toString().padEnd(15)}|`, - ); - } - } - console.log(); - } catch (err) { - console.error("\x1b[31m", `Error listing sample data: ${err}`); - } -} - -/** - * Clears all tables in the database except for the specified user. - */ -async function formatDatabase(): Promise { - const emailToKeep = "administrator@email.com"; - - const tables = [ - schema.postsTable, - schema.organizationsTable, - schema.eventsTable, - schema.organizationMembershipsTable, - ]; - - for (const table of tables) { - await db.delete(table); - } - - // Delete all users except the specified one - await db - .delete(schema.usersTable) - .where(sql`email_address != ${emailToKeep}`); - - console.log("Cleared all tables except the specified user\n"); -} - -/** - * Inserts data into specified tables. - * @param collections - Array of collection/table names to insert data into - * @param options - Options for loading data - */ -async function insertCollections( - collections: string[], - options: LoadOptions = {}, -): Promise { - try { - if (options.format) { - await formatDatabase(); - } - - for (const collection of collections) { - const data = await fs.readFile( - path.resolve(dirname, `../../sample_data/${collection}.json`), - "utf8", - ); - - switch (collection) { - case "users": { - const users = JSON.parse(data).map( - (user: { - createdAt: string | number | Date; - updatedAt: string | number | Date; - }) => ({ - ...user, - createdAt: parseDate(user.createdAt), - updatedAt: parseDate(user.updatedAt), - }), - ) as (typeof schema.usersTable.$inferInsert)[]; - await db.insert(schema.usersTable).values(users); - break; - } - case "organizations": { - const organizations = JSON.parse(data).map( - (org: { - createdAt: string | number | Date; - updatedAt: string | number | Date; - }) => ({ - ...org, - createdAt: parseDate(org.createdAt), - updatedAt: parseDate(org.updatedAt), - }), - ) as (typeof schema.organizationsTable.$inferInsert)[]; - await db.insert(schema.organizationsTable).values(organizations); - - // Add API_ADMINISTRATOR_USER_EMAIL_ADDRESS as administrator of the all organization - const API_ADMINISTRATOR_USER_EMAIL_ADDRESS = - process.env.API_ADMINISTRATOR_USER_EMAIL_ADDRESS; - if (!API_ADMINISTRATOR_USER_EMAIL_ADDRESS) { - console.error( - "\x1b[31m", - "API_ADMINISTRATOR_USER_EMAIL_ADDRESS is not defined in .env file", - ); - return; - } - - const API_ADMINISTRATOR_USER = await db.query.usersTable.findFirst({ - columns: { - id: true, - }, - where: (fields, operators) => - operators.eq( - fields.emailAddress, - API_ADMINISTRATOR_USER_EMAIL_ADDRESS, - ), - }); - if (!API_ADMINISTRATOR_USER) { - console.error( - "\x1b[31m", - "API_ADMINISTRATOR_USER_EMAIL_ADDRESS is not found in users table", - ); - return; - } - - const organizationAdminMembership = organizations.map((org) => ({ - organizationId: org.id, - memberId: API_ADMINISTRATOR_USER.id, - creatorId: API_ADMINISTRATOR_USER.id, - createdAt: new Date(), - role: "administrator", - })) as (typeof schema.organizationMembershipsTable.$inferInsert)[]; - await db - .insert(schema.organizationMembershipsTable) - .values(organizationAdminMembership); - console.log( - "\x1b[35m", - "Added API_ADMINISTRATOR_USER as administrator of the all organization", - ); - break; - } - case "organization_memberships": { - // Add case for organization memberships - const organizationMemberships = JSON.parse(data).map( - (membership: { createdAt: string | number | Date }) => ({ - ...membership, - createdAt: parseDate(membership.createdAt), - }), - ) as (typeof schema.organizationMembershipsTable.$inferInsert)[]; - await db - .insert(schema.organizationMembershipsTable) - .values(organizationMemberships); - break; - } - - default: - console.log("\x1b[31m", `Invalid table name: ${collection}`); - break; - } - - console.log("\x1b[35m", `Added ${collection} table data`); - } - - await checkCountAfterImport(); - await queryClient.end(); - - console.log("\nTables populated successfully"); - } catch (err) { - console.error("\x1b[31m", `Error adding data to tables: ${err}`); - } finally { - process.exit(0); - } -} - -/** - * Parses a date string and returns a Date object. Returns null if the date is invalid. - * @param date - The date string to parse - * @returns The parsed Date object or null - */ -function parseDate(date: string | number | Date): Date | null { - const parsedDate = new Date(date); - return Number.isNaN(parsedDate.getTime()) ? null : parsedDate; -} - -/** - * Checks record counts in specified tables after data insertion. - * @returns {Promise} - Returns true if data exists, false otherwise. - */ -async function checkCountAfterImport(): Promise { - try { - const tables = [ - { name: "users", table: schema.usersTable }, - { name: "organizations", table: schema.organizationsTable }, - { - name: "organization_memberships", - table: schema.organizationMembershipsTable, - }, - ]; - - console.log("\nRecord Counts After Import:\n"); - - console.log( - `${"| Table Name".padEnd(30)}| Record Count | -${"|".padEnd(30, "-")}|----------------| -`, - ); - - let dataExists = false; - - for (const { name, table } of tables) { - const result = await db - .select({ count: sql`count(*)` }) - .from(table); - - const count = result?.[0]?.count ?? 0; - console.log(`| ${name.padEnd(28)}| ${count.toString().padEnd(15)}|`); - - if (count > 0) { - dataExists = true; - } - } - - return dataExists; - } catch (err) { - console.error("\x1b[31m", `Error checking record count: ${err}`); - return false; - } -} - -const collections = ["users", "organizations", "organization_memberships"]; // Add organization memberships to collections - -const args = process.argv.slice(2); -const options: LoadOptions = { - format: args.includes("--format") || args.includes("-f"), - items: undefined, -}; - -const itemsIndex = args.findIndex((arg) => arg === "--items" || arg === "-i"); -if (itemsIndex !== -1 && args[itemsIndex + 1]) { - const items = args[itemsIndex + 1]; - options.items = items ? items.split(",") : undefined; -} - -(async (): Promise => { - await listSampleData(); - - const existingData = await checkCountAfterImport(); - if (existingData) { - const { deleteExisting } = await inquirer.prompt([ - { - type: "confirm", - name: "deleteExisting", - message: - "Existing data found. Do you want to delete existing data and import the new data?", - default: false, - }, - ]); - - if (deleteExisting) { - options.format = true; - } - } - - await insertCollections(options.items || collections, options); -})(); diff --git a/test/graphql/types/Fund/updater.test.ts b/test/graphql/types/Fund/updater.test.ts new file mode 100644 index 00000000000..331328b69ee --- /dev/null +++ b/test/graphql/types/Fund/updater.test.ts @@ -0,0 +1,265 @@ +import type { FastifyBaseLogger } from "fastify"; +import { createMockLogger } from "test/utilities/mockLogger"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { Fund } from "~/src/graphql/types/Fund/Fund"; +import { resolveUpdater } from "~/src/graphql/types/Fund/updater"; +import type { User } from "~/src/graphql/types/User/User"; +import { TalawaGraphQLError } from "~/src/utilities/TalawaGraphQLError"; +import type { GraphQLContext } from "../../../../src/graphql/context"; + +interface OrganizationMembership { + role: "administrator" | "member"; +} +interface ExtendedUser extends User { + organizationMembershipsWhereMember: OrganizationMembership[]; + isAuthenticated: boolean; +} + +interface TestContext extends Omit { + log: FastifyBaseLogger; + currentClient: { + isAuthenticated: boolean; + user: { id: string; role: "member" }; + token?: string; + }; + drizzleClient: { + query: { + usersTable: { + findFirst: ReturnType; + }; + fundsTable: { + findFirst: ReturnType; + }; + }; + } & GraphQLContext["drizzleClient"]; + jwt: { + sign: (payload: Record) => string; + }; +} + +describe("Fund Resolver - Updater Field", () => { + let ctx: TestContext; + let mockFund: Fund; + + beforeEach(() => { + const mockUser: Partial = { + id: "123", + name: "John Doe", + role: "administrator", + organizationMembershipsWhereMember: [ + { + role: "administrator", + }, + ], + isAuthenticated: true, + createdAt: new Date(), + }; + + mockFund = { + createdAt: new Date(), + name: "Student Fund", + id: "fund-111", + creatorId: "000", + updatedAt: new Date(), + updaterId: "id-222", + organizationId: "org-01", + isTaxDeductible: false, + }; + + const mockLogger = createMockLogger(); + + ctx = { + drizzleClient: { + query: { + usersTable: { + findFirst: vi.fn().mockResolvedValue(mockUser), + }, + fundsTable: { + findFirst: vi.fn().mockResolvedValue(mockFund), + }, + }, + } as unknown as TestContext["drizzleClient"], + log: mockLogger, + currentClient: { + isAuthenticated: true, + user: { + id: "123", + role: "member", + }, + token: "sample-token", + }, + jwt: {}, + } as TestContext; + vi.clearAllMocks(); + }); + + it("should throw unauthenticated error when user is not authenticated", async () => { + ctx.currentClient.isAuthenticated = false; + + await expect( + resolveUpdater(mockFund, {}, ctx as GraphQLContext), + ).rejects.toThrow(TalawaGraphQLError); + }); + + it("should throw unauthenticated error when user is undefined", async () => { + ctx.drizzleClient.query.usersTable.findFirst.mockResolvedValue(undefined); + + await expect( + resolveUpdater(mockFund, {}, ctx as GraphQLContext), + ).rejects.toThrow(TalawaGraphQLError); + }); + + it("should throw unauthorized_action when user is not an administrator", async () => { + ctx.drizzleClient.query.usersTable.findFirst.mockResolvedValue({ + id: "user123", + role: "member", + }); + + ctx.drizzleClient.query.fundsTable.findFirst.mockResolvedValue({ + fund: { + organization: { membershipsWhereOrganization: [{ role: "member" }] }, + }, + }); + + await expect( + resolveUpdater(mockFund, {}, ctx as GraphQLContext), + ).rejects.toThrow( + new TalawaGraphQLError({ + extensions: { + code: "unauthorized_action", + }, + }), + ); + }); + + it("should throw unauthorized_action when user has no organization memberships", async () => { + ctx.drizzleClient.query.usersTable.findFirst.mockResolvedValue({ + id: "user123", + role: "member", + }); + + ctx.drizzleClient.query.fundsTable.findFirst.mockResolvedValue({ + fund: { + organization: { membershipsWhereOrganization: undefined }, + }, + }); + + await expect( + resolveUpdater(mockFund, {}, ctx as GraphQLContext), + ).rejects.toThrow( + new TalawaGraphQLError({ + extensions: { + code: "unauthorized_action", + }, + }), + ); + }); + + it("should throw unauthorized_action when membershipsWhereOrganization.role is not an administrator", async () => { + ctx.drizzleClient.query.usersTable.findFirst.mockResolvedValue({ + id: "user123", + role: "member", + organizationMembershipsWhereMember: [ + { + role: "member", + }, + ], + }); + + ctx.drizzleClient.query.fundsTable.findFirst.mockResolvedValue({ + fund: { + organization: { membershipsWhereOrganization: [{ role: "member" }] }, + }, + }); + + await expect( + resolveUpdater(mockFund, {}, ctx as GraphQLContext), + ).rejects.toThrow( + new TalawaGraphQLError({ + extensions: { + code: "unauthorized_action", + }, + }), + ); + }); + + it("returns null if updaterId is null", async () => { + ctx.drizzleClient.query.usersTable.findFirst.mockResolvedValue({ + id: "user123", + role: "administrator", + }); + ctx.drizzleClient.query.fundsTable.findFirst.mockResolvedValue({ + fund: { + organization: { + membershipsWhereOrganization: [{ role: "administrator" }], + }, + }, + }); + + const result = await resolveUpdater( + { ...mockFund, updaterId: null }, + {}, + ctx as GraphQLContext, + ); + expect(result).toBeNull(); + }); + + it("returns current user if they are the updater", async () => { + ctx.drizzleClient.query.usersTable.findFirst.mockResolvedValue({ + id: "user123", + role: "administrator", + }); + + ctx.currentClient.user.id = "user123"; + + await expect( + resolveUpdater( + { ...mockFund, updaterId: "user123" }, + {}, + ctx as GraphQLContext, + ), + ).resolves.toEqual({ id: "user123", role: "administrator" }); + }); + + it("throws unexpected error if updater user does not exist", async () => { + ctx.drizzleClient.query.usersTable.findFirst + .mockResolvedValueOnce({ id: "user123", role: "administrator" }) + .mockResolvedValueOnce(undefined); + ctx.drizzleClient.query.fundsTable.findFirst.mockResolvedValue({ + fund: { + organization: { + membershipsWhereOrganization: [{ role: "administrator" }], + }, + }, + }); + + await expect( + resolveUpdater(mockFund, {}, ctx as GraphQLContext), + ).rejects.toThrow( + new TalawaGraphQLError({ + extensions: { + code: "unexpected", + }, + }), + ); + expect(ctx.log.error).toHaveBeenCalledWith( + "Postgres select operation returned an empty array for a fund's updater id that isn't null.", + ); + }); + + it("returns the existing user if updaterId is set and user exists", async () => { + ctx.drizzleClient.query.usersTable.findFirst + .mockResolvedValueOnce({ id: "user123", role: "administrator" }) + .mockResolvedValueOnce({ id: "user456", role: "member" }); + ctx.drizzleClient.query.fundsTable.findFirst.mockResolvedValue({ + fund: { + organization: { + membershipsWhereOrganization: [{ role: "administrator" }], + }, + }, + }); + + const result = await resolveUpdater(mockFund, {}, ctx as GraphQLContext); + expect(result).toEqual({ id: "user456", role: "member" }); + }); +}); diff --git a/test/routes/graphql/Mutation/deleteAgendaItem.test.ts b/test/routes/graphql/Mutation/deleteAgendaItem.test.ts new file mode 100644 index 00000000000..a259886efa5 --- /dev/null +++ b/test/routes/graphql/Mutation/deleteAgendaItem.test.ts @@ -0,0 +1,644 @@ +import { faker } from "@faker-js/faker"; + +import { afterEach, expect, suite, test } from "vitest"; + +import { eq } from "drizzle-orm"; +import { usersTable } from "~/src/drizzle/schema"; +import type { + TalawaGraphQLFormattedError, + UnauthenticatedExtensions, +} from "~/src/utilities/TalawaGraphQLError"; +import { assertToBeNonNullish } from "../../../helpers"; +import { server } from "../../../server"; +import { mercuriusClient } from "../client"; +import { + Mutation_createAgendaFolder, + Mutation_createAgendaItem, + Mutation_createEvent, + Mutation_createOrganization, + Mutation_createOrganizationMembership, + Mutation_createUser, + Mutation_deleteAgendaItem, + Mutation_deleteEvent, + Mutation_deleteOrganization, + Mutation_deleteOrganizationMembership, + Mutation_deleteUser, + Query_signIn, +} from "../documentNodes"; + +// Helper Types +interface TestUser { + authToken: string; + userId: string; + cleanup: () => Promise; +} +interface TestAgendaItem { + agendaItemId: string; + orgId: string; + eventId: string; + folderId: string; + cleanup: () => Promise; +} + +/** + * Helper function to get admin auth token with proper error handling + * @throws {Error} If admin credentials are invalid or missing + * @returns {Promise} Admin authentication token + */ +let cachedAdminToken: string | null = null; +let cachedAdminId: string | null = null; +async function getAdminAuthTokenAndId(): Promise<{ + cachedAdminToken: string; + cachedAdminId: string; +}> { + if (cachedAdminToken && cachedAdminId) { + return { cachedAdminToken, cachedAdminId }; + } + + try { + // Check if admin credentials exist + if ( + !server.envConfig.API_ADMINISTRATOR_USER_EMAIL_ADDRESS || + !server.envConfig.API_ADMINISTRATOR_USER_PASSWORD + ) { + throw new Error( + "Admin credentials are missing in environment configuration", + ); + } + const adminSignInResult = await mercuriusClient.query(Query_signIn, { + variables: { + input: { + emailAddress: server.envConfig.API_ADMINISTRATOR_USER_EMAIL_ADDRESS, + password: server.envConfig.API_ADMINISTRATOR_USER_PASSWORD, + }, + }, + }); + // Check for GraphQL errors + if (adminSignInResult.errors) { + throw new Error( + `Admin authentication failed: ${adminSignInResult.errors[0]?.message || "Unknown error"}`, + ); + } + // Check for missing data + if (!adminSignInResult.data?.signIn?.authenticationToken) { + throw new Error( + "Admin authentication succeeded but no token was returned", + ); + } + if (!adminSignInResult.data?.signIn?.user?.id) { + throw new Error( + "Admin authentication succeeded but no user id was returned", + ); + } + cachedAdminToken = adminSignInResult.data.signIn.authenticationToken; + cachedAdminId = adminSignInResult.data.signIn.user.id; + return { cachedAdminToken, cachedAdminId }; + } catch (error) { + // Wrap and rethrow with more context + throw new Error( + `Failed to get admin authentication token: ${error instanceof Error ? error.message : "Unknown error"}`, + ); + } +} + +async function createRegularUser(): Promise { + const { cachedAdminToken: adminAuthToken } = await getAdminAuthTokenAndId(); + + const userResult = await mercuriusClient.mutate(Mutation_createUser, { + headers: { + authorization: `bearer ${adminAuthToken}`, + }, + variables: { + input: { + emailAddress: `email${faker.string.uuid()}@test.com`, + password: "password123", + role: "regular", + name: "Test User", + isEmailAddressVerified: false, + }, + }, + }); + + // Assert data exists + assertToBeNonNullish(userResult.data); + // Assert createUser exists + assertToBeNonNullish(userResult.data.createUser); + // Assert user exists and has id + assertToBeNonNullish(userResult.data.createUser.user); + assertToBeNonNullish(userResult.data.createUser.user.id); + // Assert authenticationToken exists + assertToBeNonNullish(userResult.data.createUser.authenticationToken); + + const userId = userResult.data.createUser.user.id; + const authToken = userResult.data.createUser.authenticationToken; + + return { + authToken, + userId, + cleanup: async () => { + await mercuriusClient.mutate(Mutation_deleteUser, { + headers: { authorization: `bearer ${adminAuthToken}` }, + variables: { input: { id: userId } }, + }); + }, + }; +} + +async function createTestAgendaItem(): Promise { + const { cachedAdminToken: adminAuthToken } = await getAdminAuthTokenAndId(); + + // Create organization + const createOrgResult = await mercuriusClient.mutate( + Mutation_createOrganization, + { + headers: { + authorization: `bearer ${adminAuthToken}`, + }, + variables: { + input: { + name: `Org ${faker.string.uuid()}`, + countryCode: "us", + }, + }, + }, + ); + + assertToBeNonNullish(createOrgResult.data); + assertToBeNonNullish(createOrgResult.data.createOrganization); + const orgId = createOrgResult.data.createOrganization.id; + + // Create event + const createEventResult = await mercuriusClient.mutate(Mutation_createEvent, { + headers: { + authorization: `bearer ${adminAuthToken}`, + }, + variables: { + input: { + name: `Event ${faker.string.uuid()}`, + organizationId: orgId, + startAt: new Date().toISOString(), + endAt: new Date(Date.now() + 86400000).toISOString(), + description: "Test event", + }, + }, + }); + + assertToBeNonNullish(createEventResult.data); + assertToBeNonNullish(createEventResult.data.createEvent); + const eventId = createEventResult.data.createEvent.id; + + // Create agenda folder + const createFolderResult = await mercuriusClient.mutate( + Mutation_createAgendaFolder, + { + headers: { + authorization: `bearer ${adminAuthToken}`, + }, + variables: { + input: { + name: `Folder ${faker.string.uuid()}`, + eventId: eventId, + isAgendaItemFolder: true, + }, + }, + }, + ); + + assertToBeNonNullish(createFolderResult.data); + assertToBeNonNullish(createFolderResult.data.createAgendaFolder); + const folderId = createFolderResult.data.createAgendaFolder.id; + + // Create agenda item + const createAgendaItemResult = await mercuriusClient.mutate( + Mutation_createAgendaItem, + { + headers: { + authorization: `bearer ${adminAuthToken}`, + }, + variables: { + input: { + name: `Agenda Item ${faker.string.uuid()}`, + folderId: folderId, + type: "general", + duration: "30m", + description: "Test agenda item description", + }, + }, + }, + ); + + assertToBeNonNullish(createAgendaItemResult.data); + assertToBeNonNullish(createAgendaItemResult.data.createAgendaItem); + const agendaItemId = createAgendaItemResult.data.createAgendaItem.id; + + return { + agendaItemId, + orgId, + eventId, + folderId, + cleanup: async () => { + const errors: Error[] = []; + try { + await mercuriusClient.mutate(Mutation_deleteAgendaItem, { + headers: { authorization: `bearer ${adminAuthToken}` }, + variables: { input: { id: agendaItemId } }, + }); + } catch (error) { + errors.push(error as Error); + console.error("Failed to delete agenda item:", error); + } + try { + await mercuriusClient.mutate(Mutation_deleteEvent, { + headers: { authorization: `bearer ${adminAuthToken}` }, + variables: { input: { id: eventId } }, + }); + } catch (error) { + errors.push(error as Error); + console.error("Failed to delete event:", error); + } + try { + await mercuriusClient.mutate(Mutation_deleteOrganization, { + headers: { authorization: `bearer ${adminAuthToken}` }, + variables: { input: { id: orgId } }, + }); + } catch (error) { + errors.push(error as Error); + console.error("Failed to delete organization:", error); + } + if (errors.length > 0) { + throw new AggregateError(errors, "One or more cleanup steps failed"); + } + }, + }; +} + +async function createOrganizationMembership( + authToken: string, + memberId: string, + orgId: string, + role?: "administrator" | "regular", +) { + const createOrganizationMembershipResult = await mercuriusClient.mutate( + Mutation_createOrganizationMembership, + { + headers: { + authorization: `bearer ${authToken}`, + }, + variables: { + input: { + memberId, + organizationId: orgId, + role, + }, + }, + }, + ); + + assertToBeNonNullish(createOrganizationMembershipResult.data); + assertToBeNonNullish( + createOrganizationMembershipResult.data.createOrganizationMembership, + ); + assertToBeNonNullish( + createOrganizationMembershipResult.data.createOrganizationMembership.id, + ); + const organizationMembershipId = + createOrganizationMembershipResult.data.createOrganizationMembership.id; + return { + organizationMembershipId, + cleanup: async () => { + await mercuriusClient.mutate(Mutation_deleteOrganizationMembership, { + headers: { + authorization: `bearer ${authToken}`, + }, + variables: { + input: { + memberId, + organizationId: orgId, + }, + }, + }); + }, + }; +} +suite("Mutation field deleteAgendaItem", () => { + suite("Authorization and Authentication", () => { + const testCleanupFunctions: Array<() => Promise> = []; + + afterEach(async () => { + for (const cleanup of testCleanupFunctions.reverse()) { + try { + await cleanup(); + } catch (error) { + console.error("Cleanup failed:", error); + } + } + // Reset the cleanup functions array + testCleanupFunctions.length = 0; + }); + test("Returns an error if the client is not authenticated", async () => { + const agendaItemResult = await mercuriusClient.mutate( + Mutation_deleteAgendaItem, + { + variables: { + input: { + id: faker.string.uuid(), + }, + }, + }, + ); + expect(agendaItemResult.data.deleteAgendaItem).toEqual(null); + expect(agendaItemResult.errors).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + extensions: expect.objectContaining({ + code: "unauthenticated", + }), + message: expect.any(String), + path: ["deleteAgendaItem"], + }), + ]), + ); + }); + + test("Returns an error if the user is present in the token but not in the database", async () => { + // create a user + const regularUser = await createRegularUser(); + testCleanupFunctions.push(regularUser.cleanup); + // delete the user + await server.drizzleClient + .delete(usersTable) + .where(eq(usersTable.id, regularUser.userId)); + + const agendaItemResult = await mercuriusClient.mutate( + Mutation_deleteAgendaItem, + { + headers: { + authorization: `bearer ${regularUser.authToken}`, + }, + variables: { + input: { + id: faker.string.uuid(), + }, + }, + }, + ); + expect(agendaItemResult.data.deleteAgendaItem).toEqual(null); + expect(agendaItemResult.errors).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + extensions: expect.objectContaining({ + code: "unauthenticated", + }), + message: expect.any(String), + path: ["deleteAgendaItem"], + }), + ]), + ); + }); + test("Returns an error when a non-admin, non-organization member tries to delete an agenda item", async () => { + const regularUser = await createRegularUser(); + testCleanupFunctions.push(regularUser.cleanup); + // create a agendaItem + const agendaItem = await createTestAgendaItem(); + testCleanupFunctions.push(agendaItem.cleanup); + // delete the agendaItem + + const agendaItemResult = await mercuriusClient.mutate( + Mutation_deleteAgendaItem, + { + headers: { + authorization: `bearer ${regularUser.authToken}`, + }, + variables: { + input: { + id: agendaItem.agendaItemId, + }, + }, + }, + ); + expect(agendaItemResult.data.deleteAgendaItem).toEqual(null); + expect(agendaItemResult.errors).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + extensions: expect.objectContaining({ + code: "unauthorized_action_on_arguments_associated_resources", + issues: expect.arrayContaining([ + expect.objectContaining({ + argumentPath: ["input", "id"], + }), + ]), + }), + message: expect.any(String), + path: ["deleteAgendaItem"], + }), + ]), + ); + }); + test("Returns an error when a regular member of the organization tries to delete an agenda item", async () => { + const regularUser = await createRegularUser(); + testCleanupFunctions.push(regularUser.cleanup); + // create a agendaItem + const agendaItem = await createTestAgendaItem(); + testCleanupFunctions.push(agendaItem.cleanup); + // create organization membership + + const orgMemberShip = await createOrganizationMembership( + regularUser.authToken, + regularUser.userId, + agendaItem.orgId, + ); + testCleanupFunctions.push(orgMemberShip.cleanup); + // delete the agendaItem + + const agendaItemResult = await mercuriusClient.mutate( + Mutation_deleteAgendaItem, + { + headers: { + authorization: `bearer ${regularUser.authToken}`, + }, + variables: { + input: { + id: agendaItem.agendaItemId, + }, + }, + }, + ); + expect(agendaItemResult.data.deleteAgendaItem).toEqual(null); + expect(agendaItemResult.errors).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + extensions: expect.objectContaining({ + code: "unauthorized_action_on_arguments_associated_resources", + issues: expect.arrayContaining([ + expect.objectContaining({ + argumentPath: ["input", "id"], + }), + ]), + }), + message: expect.any(String), + path: ["deleteAgendaItem"], + }), + ]), + ); + }); + test("Deletes the agenda item successfully when an admin (non-organization member) tries to delete it", async () => { + const { cachedAdminToken: adminAuthToken } = + await getAdminAuthTokenAndId(); + const agendaItem = await createTestAgendaItem(); + testCleanupFunctions.push(agendaItem.cleanup); + + // delete the agendaItem + + const agendaItemResult = await mercuriusClient.mutate( + Mutation_deleteAgendaItem, + { + headers: { + authorization: `bearer ${adminAuthToken}`, + }, + variables: { + input: { + id: agendaItem.agendaItemId, + }, + }, + }, + ); + assertToBeNonNullish(agendaItemResult.data); + assertToBeNonNullish(agendaItemResult.data.deleteAgendaItem); + expect(agendaItemResult.data.deleteAgendaItem.id).toEqual( + agendaItem.agendaItemId, + ); + expect(agendaItemResult.errors).toBeUndefined(); + }); + + test("Deletes the agenda item successfully when an admin (organization member) tries to delete it", async () => { + const { cachedAdminToken: adminAuthToken, cachedAdminId: adminId } = + await getAdminAuthTokenAndId(); + const agendaItem = await createTestAgendaItem(); + testCleanupFunctions.push(agendaItem.cleanup); + // create organization membership + const orgMemberShip = await createOrganizationMembership( + adminAuthToken, + adminId, + agendaItem.orgId, + ); + testCleanupFunctions.push(orgMemberShip.cleanup); + // delete the agendaItem + + const agendaItemResult = await mercuriusClient.mutate( + Mutation_deleteAgendaItem, + { + headers: { + authorization: `bearer ${adminAuthToken}`, + }, + variables: { + input: { + id: agendaItem.agendaItemId, + }, + }, + }, + ); + assertToBeNonNullish(agendaItemResult.data); + assertToBeNonNullish(agendaItemResult.data.deleteAgendaItem); + expect(agendaItemResult.data.deleteAgendaItem.id).toEqual( + agendaItem.agendaItemId, + ); + expect(agendaItemResult.errors).toBeUndefined(); + }); + }); + suite("Input Validation", () => { + test("Returns an error when an invalid UUID format is provided", async () => { + const { cachedAdminToken: adminAuthToken } = + await getAdminAuthTokenAndId(); + const agendaItemResult = await mercuriusClient.mutate( + Mutation_deleteAgendaItem, + { + headers: { + authorization: `bearer ${adminAuthToken}`, + }, + variables: { + input: { + id: "invalid-id", + }, + }, + }, + ); + expect(agendaItemResult.data.deleteAgendaItem).toEqual(null); + expect(agendaItemResult.errors).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + extensions: expect.objectContaining({ + code: "invalid_arguments", + issues: expect.arrayContaining([ + expect.objectContaining({ + argumentPath: ["input", "id"], + }), + ]), + }), + message: expect.any(String), + path: ["deleteAgendaItem"], + }), + ]), + ); + }); + }); + + suite("Resource Existence", () => { + const testCleanupFunctions: Array<() => Promise> = []; + afterEach(async () => { + for (const cleanup of testCleanupFunctions.reverse()) { + try { + await cleanup(); + } catch (error) { + console.error("Cleanup failed:", error); + } + } + // Reset the cleanup functions array + testCleanupFunctions.length = 0; + }); + test("Returns an error when the agenda item does not exist", async () => { + const { cachedAdminToken: adminAuthToken, cachedAdminId: adminId } = + await getAdminAuthTokenAndId(); + // create a user + const agendaItem = await createTestAgendaItem(); + testCleanupFunctions.push(agendaItem.cleanup); + // create organization membership + const orgMemberShip = await createOrganizationMembership( + adminAuthToken, + adminId, + agendaItem.orgId, + ); + testCleanupFunctions.push(orgMemberShip.cleanup); + // delete the agendaItem + const agendaItemResult = await mercuriusClient.mutate( + Mutation_deleteAgendaItem, + { + headers: { + authorization: `bearer ${adminAuthToken}`, + }, + variables: { + input: { + id: faker.string.uuid(), + }, + }, + }, + ); + expect(agendaItemResult.data.deleteAgendaItem).toEqual(null); + expect(agendaItemResult.errors).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + extensions: expect.objectContaining({ + code: "arguments_associated_resources_not_found", + issues: expect.arrayContaining([ + expect.objectContaining({ + argumentPath: ["input", "id"], + }), + ]), + }), + message: expect.any(String), + path: ["deleteAgendaItem"], + }), + ]), + ); + }); + }); +}); diff --git a/test/routes/graphql/Query/agendaItem.test.ts b/test/routes/graphql/Query/agendaItem.test.ts new file mode 100644 index 00000000000..9d4d2900401 --- /dev/null +++ b/test/routes/graphql/Query/agendaItem.test.ts @@ -0,0 +1,864 @@ +import { faker } from "@faker-js/faker"; +import { eq } from "drizzle-orm"; +import { afterEach, expect, suite, test } from "vitest"; +import { usersTable } from "~/src/drizzle/schema"; +import { agendaItemsTableInsertSchema } from "~/src/drizzle/tables/agendaItems"; +import type { + TalawaGraphQLFormattedError, + UnauthenticatedExtensions, +} from "~/src/utilities/TalawaGraphQLError"; +import { assertToBeNonNullish } from "../../../helpers"; +import { server } from "../../../server"; +import { mercuriusClient } from "../client"; +import { + Mutation_createAgendaFolder, + Mutation_createAgendaItem, + Mutation_createEvent, + Mutation_createOrganization, + Mutation_createOrganizationMembership, + Mutation_createUser, + Mutation_deleteAgendaItem, + Mutation_deleteEvent, + Mutation_deleteOrganization, + Mutation_deleteUser, + Query_agendaItem, + Query_signIn, +} from "../documentNodes"; + +/** + * Helper function to get admin auth token with proper error handling + * @throws {Error} If admin credentials are invalid or missing + * @returns {Promise} Admin authentication token + */ +let cachedAdminToken: string | null = null; +async function getAdminAuthToken(): Promise { + if (cachedAdminToken) { + return cachedAdminToken; + } + + try { + // Check if admin credentials exist + if ( + !server.envConfig.API_ADMINISTRATOR_USER_EMAIL_ADDRESS || + !server.envConfig.API_ADMINISTRATOR_USER_PASSWORD + ) { + throw new Error( + "Admin credentials are missing in environment configuration", + ); + } + const adminSignInResult = await mercuriusClient.query(Query_signIn, { + variables: { + input: { + emailAddress: server.envConfig.API_ADMINISTRATOR_USER_EMAIL_ADDRESS, + password: server.envConfig.API_ADMINISTRATOR_USER_PASSWORD, + }, + }, + }); + // Check for GraphQL errors + if (adminSignInResult.errors) { + throw new Error( + `Admin authentication failed: ${adminSignInResult.errors[0]?.message || "Unknown error"}`, + ); + } + // Check for missing data + if (!adminSignInResult.data?.signIn?.authenticationToken) { + throw new Error( + "Admin authentication succeeded but no token was returned", + ); + } + cachedAdminToken = adminSignInResult.data.signIn.authenticationToken; + return cachedAdminToken; + } catch (error) { + // Wrap and rethrow with more context + throw new Error( + `Failed to get admin authentication token: ${error instanceof Error ? error.message : "Unknown error"}`, + ); + } +} + +suite("Query field agendaItem", () => { + suite("Authentication Tests", () => { + test("returns error if client is not authenticated", async () => { + const agendaItemResult = await mercuriusClient.query(Query_agendaItem, { + variables: { + input: { + id: faker.string.uuid(), + }, + }, + }); + + expect(agendaItemResult.data.agendaItem).toEqual(null); + expect(agendaItemResult.errors).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + extensions: expect.objectContaining({ + code: "unauthenticated", + }), + message: expect.any(String), + path: ["agendaItem"], + }), + ]), + ); + }); + + test("returns error with invalid authentication token", async () => { + // Generate a random invalid token + const invalidToken = Buffer.from(faker.string.alphanumeric(32)).toString( + "base64", + ); + + const agendaItemResult = await mercuriusClient.query(Query_agendaItem, { + headers: { + authorization: `bearer ${invalidToken}`, + }, + variables: { + input: { + id: faker.string.uuid(), + }, + }, + }); + + expect(agendaItemResult.data.agendaItem).toEqual(null); + expect(agendaItemResult.errors?.[0]?.extensions?.code).toBe( + "unauthenticated", + ); + }); + + test("returns error if user exists in token but not in database", async () => { + // First create a user and get their token + const regularUserResult = await createRegularUser(); + + // Delete the user from database while their token is still valid + await server.drizzleClient + .delete(usersTable) + .where(eq(usersTable.id, regularUserResult.userId)); + + // Try to query using the token of deleted user + const agendaItemResult = await mercuriusClient.query(Query_agendaItem, { + headers: { + authorization: `bearer ${regularUserResult.authToken}`, + }, + variables: { + input: { + id: faker.string.uuid(), + }, + }, + }); + + expect(agendaItemResult.data.agendaItem).toEqual(null); + expect(agendaItemResult.errors).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + extensions: expect.objectContaining({ + code: "unauthenticated", + }), + message: expect.any(String), + path: ["agendaItem"], + }), + ]), + ); + }); + }); + + suite("Resource Validation Tests", () => { + test("returns error if agenda item doesn't exist", async () => { + const adminAuthToken = await getAdminAuthToken(); + const nonExistentId = faker.string.uuid(); + + const agendaItemResult = await mercuriusClient.query(Query_agendaItem, { + headers: { + authorization: `bearer ${adminAuthToken}`, + }, + variables: { + input: { + id: nonExistentId, + }, + }, + }); + + expect(agendaItemResult.data.agendaItem).toEqual(null); + expect(agendaItemResult.errors?.[0]?.extensions?.code).toBe( + "arguments_associated_resources_not_found", + ); + }); + }); +}); + +suite("Schema Validation Tests", () => { + test("validates agenda item name constraints", () => { + const validInput = { + name: "Test Agenda Item", + type: "general", + folderId: faker.string.uuid(), + }; + + // Test empty name + expect( + agendaItemsTableInsertSchema.safeParse({ + ...validInput, + name: "", + }).success, + ).toBe(false); + + // Test too long name + expect( + agendaItemsTableInsertSchema.safeParse({ + ...validInput, + name: "a".repeat(257), + }).success, + ).toBe(false); + + // Test valid name + expect(agendaItemsTableInsertSchema.safeParse(validInput).success).toBe( + true, + ); + }); + + test("validates description field constraints", () => { + const validInput = { + name: "Test Agenda Item", + type: "general", + folderId: faker.string.uuid(), + }; + + // Test too long description + expect( + agendaItemsTableInsertSchema.safeParse({ + ...validInput, + description: "a".repeat(2049), + }).success, + ).toBe(false); + + // Test valid description + expect( + agendaItemsTableInsertSchema.safeParse({ + ...validInput, + description: "Valid description", + }).success, + ).toBe(true); + + // Test optional description + expect( + agendaItemsTableInsertSchema.safeParse({ + ...validInput, + description: undefined, + }).success, + ).toBe(true); + }); + + test("validates required fields", () => { + // Test missing name + expect( + agendaItemsTableInsertSchema.safeParse({ + type: "general", + folderId: faker.string.uuid(), + }).success, + ).toBe(false); + + // Test missing type + expect( + agendaItemsTableInsertSchema.safeParse({ + name: "Test Item", + folderId: faker.string.uuid(), + }).success, + ).toBe(false); + + // Test missing folderId + expect( + agendaItemsTableInsertSchema.safeParse({ + name: "Test Item", + type: "general", + }).success, + ).toBe(false); + }); +}); + +// Helper Types +interface TestUser { + authToken: string; + userId: string; + cleanup: () => Promise; +} + +interface TestAgendaItem { + agendaItemId: string; + orgId: string; + eventId: string; + folderId: string; + cleanup: () => Promise; +} + +interface TokenPayload { + exp: number; + iat: number; + sub: string; + jti?: string; + iss?: string; +} + +// Helper Functions +/** + * Creates test tokens with proper JWT structure + * Simulates real JWT format without needing external library + */ +function createTestToken(payload: Partial = {}): string { + const header = { + alg: "HS256", + typ: "JWT", + }; + + const defaultPayload = { + iat: Math.floor(Date.now() / 1000), + sub: faker.string.uuid(), + jti: faker.string.uuid(), + ...payload, + }; + + const headerBase64 = Buffer.from(JSON.stringify(header)).toString( + "base64url", + ); + const payloadBase64 = Buffer.from(JSON.stringify(defaultPayload)).toString( + "base64url", + ); + + // In real JWT this would be signed, but for testing we can use a consistent string + const signature = "test-signature"; + + return `${headerBase64}.${payloadBase64}.${signature}`; +} + +async function createRegularUser(): Promise { + const adminAuthToken = await getAdminAuthToken(); + + const userResult = await mercuriusClient.mutate(Mutation_createUser, { + headers: { + authorization: `bearer ${adminAuthToken}`, + }, + variables: { + input: { + emailAddress: `email${faker.string.uuid()}@test.com`, + password: "password123", + role: "regular", + name: "Test User", + isEmailAddressVerified: false, + }, + }, + }); + + // Assert data exists + assertToBeNonNullish(userResult.data); + // Assert createUser exists + assertToBeNonNullish(userResult.data.createUser); + // Assert user exists and has id + assertToBeNonNullish(userResult.data.createUser.user); + assertToBeNonNullish(userResult.data.createUser.user.id); + // Assert authenticationToken exists + assertToBeNonNullish(userResult.data.createUser.authenticationToken); + + const userId = userResult.data.createUser.user.id; + const authToken = userResult.data.createUser.authenticationToken; + + return { + authToken, + userId, + cleanup: async () => { + await mercuriusClient.mutate(Mutation_deleteUser, { + headers: { authorization: `bearer ${adminAuthToken}` }, + variables: { input: { id: userId } }, + }); + }, + }; +} + +async function createTestAgendaItem(): Promise { + const adminAuthToken = await getAdminAuthToken(); + + // Create organization + const createOrgResult = await mercuriusClient.mutate( + Mutation_createOrganization, + { + headers: { + authorization: `bearer ${adminAuthToken}`, + }, + variables: { + input: { + name: `Org ${faker.string.uuid()}`, + countryCode: "us", + }, + }, + }, + ); + + assertToBeNonNullish(createOrgResult.data); + assertToBeNonNullish(createOrgResult.data.createOrganization); + const orgId = createOrgResult.data.createOrganization.id; + + // Create event + const createEventResult = await mercuriusClient.mutate(Mutation_createEvent, { + headers: { + authorization: `bearer ${adminAuthToken}`, + }, + variables: { + input: { + name: `Event ${faker.string.uuid()}`, + organizationId: orgId, + startAt: new Date().toISOString(), + endAt: new Date(Date.now() + 86400000).toISOString(), + description: "Test event", + }, + }, + }); + + assertToBeNonNullish(createEventResult.data); + assertToBeNonNullish(createEventResult.data.createEvent); + const eventId = createEventResult.data.createEvent.id; + + // Create agenda folder + const createFolderResult = await mercuriusClient.mutate( + Mutation_createAgendaFolder, + { + headers: { + authorization: `bearer ${adminAuthToken}`, + }, + variables: { + input: { + name: `Folder ${faker.string.uuid()}`, + eventId: eventId, + isAgendaItemFolder: true, + }, + }, + }, + ); + + assertToBeNonNullish(createFolderResult.data); + assertToBeNonNullish(createFolderResult.data.createAgendaFolder); + const folderId = createFolderResult.data.createAgendaFolder.id; + + // Create agenda item + const createAgendaItemResult = await mercuriusClient.mutate( + Mutation_createAgendaItem, + { + headers: { + authorization: `bearer ${adminAuthToken}`, + }, + variables: { + input: { + name: `Agenda Item ${faker.string.uuid()}`, + folderId: folderId, + type: "general", + duration: "30m", + description: "Test agenda item description", + }, + }, + }, + ); + + assertToBeNonNullish(createAgendaItemResult.data); + assertToBeNonNullish(createAgendaItemResult.data.createAgendaItem); + const agendaItemId = createAgendaItemResult.data.createAgendaItem.id; + + return { + agendaItemId, + orgId, + eventId, + folderId, + cleanup: async () => { + const errors: Error[] = []; + try { + await mercuriusClient.mutate(Mutation_deleteAgendaItem, { + headers: { authorization: `bearer ${adminAuthToken}` }, + variables: { input: { id: agendaItemId } }, + }); + } catch (error) { + errors.push(error as Error); + console.error("Failed to delete agenda item:", error); + } + try { + await mercuriusClient.mutate(Mutation_deleteEvent, { + headers: { authorization: `bearer ${adminAuthToken}` }, + variables: { input: { id: eventId } }, + }); + } catch (error) { + errors.push(error as Error); + console.error("Failed to delete event:", error); + } + try { + await mercuriusClient.mutate(Mutation_deleteOrganization, { + headers: { authorization: `bearer ${adminAuthToken}` }, + variables: { input: { id: orgId } }, + }); + } catch (error) { + errors.push(error as Error); + console.error("Failed to delete organization:", error); + } + if (errors.length > 0) { + throw new AggregateError(errors, "One or more cleanup steps failed"); + } + }, + }; +} + +suite("Input Validation Tests", () => { + test("returns error with 'invalid_arguments' code for invalid input format", async () => { + const adminAuthToken = await getAdminAuthToken(); + + // Create an invalid input by providing a malformed ID + const agendaItemResult = await mercuriusClient.query(Query_agendaItem, { + headers: { + authorization: `bearer ${adminAuthToken}`, + }, + variables: { + input: { + id: "invalid-uuid-format", // Invalid UUID format + }, + }, + }); + + expect(agendaItemResult.data.agendaItem).toEqual(null); + expect(agendaItemResult.errors).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + extensions: expect.objectContaining({ + code: "invalid_arguments", + issues: expect.arrayContaining([ + expect.objectContaining({ + argumentPath: ["input", "id"], + message: expect.any(String), + }), + ]), + }), + message: expect.any(String), + path: ["agendaItem"], + }), + ]), + ); + }); + + suite("Authorization Tests", () => { + const testCleanupFunctions: Array<() => Promise> = []; + + afterEach(async () => { + for (const cleanup of testCleanupFunctions.reverse()) { + try { + await cleanup(); + } catch (error) { + console.error("Cleanup failed:", error); + } + } + // Reset the cleanup functions array + testCleanupFunctions.length = 0; + }); + + test("denies access if user is not organization member and not admin", async () => { + const { authToken, cleanup: userCleanup } = await createRegularUser(); + testCleanupFunctions.push(userCleanup); + + const { agendaItemId, cleanup: agendaCleanup } = + await createTestAgendaItem(); + testCleanupFunctions.push(agendaCleanup); + + const agendaItemResult = await mercuriusClient.query(Query_agendaItem, { + headers: { + authorization: `bearer ${authToken}`, + }, + variables: { + input: { + id: agendaItemId, + }, + }, + }); + + expect(agendaItemResult.data.agendaItem).toEqual(null); + expect(agendaItemResult.errors).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + extensions: expect.objectContaining({ + code: "unauthorized_action_on_arguments_associated_resources", + issues: expect.arrayContaining([ + expect.objectContaining({ + argumentPath: ["input", "id"], + }), + ]), + }), + message: expect.any(String), + path: ["agendaItem"], + }), + ]), + ); + }); + + test("allows access if user is organization member", async () => { + const { + userId, + authToken, + cleanup: userCleanup, + } = await createRegularUser(); + testCleanupFunctions.push(userCleanup); + + const { + agendaItemId, + orgId, + cleanup: agendaCleanup, + } = await createTestAgendaItem(); + testCleanupFunctions.push(agendaCleanup); + + const membershipResult = await mercuriusClient.mutate( + Mutation_createOrganizationMembership, + { + headers: { + authorization: `bearer ${await getAdminAuthToken()}`, + }, + variables: { + input: { + memberId: userId, + organizationId: orgId, // Use orgId captured from createTestAgendaItem + role: "regular", + }, + }, + }, + ); + + assertToBeNonNullish(membershipResult.data); + assertToBeNonNullish(membershipResult.data.createOrganizationMembership); + + const agendaItemResult = await mercuriusClient.query(Query_agendaItem, { + headers: { + authorization: `bearer ${authToken}`, + }, + variables: { + input: { + id: agendaItemId, + }, + }, + }); + + expect(agendaItemResult.errors).toBeUndefined(); + expect(agendaItemResult.data.agendaItem).toEqual( + expect.objectContaining({ + id: agendaItemId, + name: expect.any(String), + }), + ); + }); + + test("allows access if user is organization admin", async () => { + // Create test resources + const { + userId, + authToken, + cleanup: userCleanup, + } = await createRegularUser(); + testCleanupFunctions.push(userCleanup); + + const { + agendaItemId, + orgId, + cleanup: agendaCleanup, + } = await createTestAgendaItem(); + testCleanupFunctions.push(agendaCleanup); + + // Add user as organization admin + await mercuriusClient.mutate(Mutation_createOrganizationMembership, { + headers: { + authorization: `bearer ${await getAdminAuthToken()}`, + }, + variables: { + input: { + memberId: userId, + organizationId: orgId, + }, + }, + }); + + const agendaItemResult = await mercuriusClient.query(Query_agendaItem, { + headers: { + authorization: `bearer ${authToken}`, + }, + variables: { + input: { + id: agendaItemId, + }, + }, + }); + + expect(agendaItemResult.errors).toBeUndefined(); + expect(agendaItemResult.data.agendaItem).toEqual( + expect.objectContaining({ + id: agendaItemId, + name: expect.any(String), + }), + ); + }); + + test("allows access if user is organization admin", async () => { + const { + userId, + authToken, + cleanup: userCleanup, + } = await createRegularUser(); + testCleanupFunctions.push(userCleanup); + + const { + agendaItemId, + orgId, + cleanup: agendaCleanup, + } = await createTestAgendaItem(); + testCleanupFunctions.push(agendaCleanup); + + // Add user as organization admin + await mercuriusClient.mutate(Mutation_createOrganizationMembership, { + headers: { + authorization: `bearer ${await getAdminAuthToken()}`, + }, + variables: { + input: { + memberId: userId, + organizationId: orgId, + role: "administrator", + }, + }, + }); + + const agendaItemResult = await mercuriusClient.query(Query_agendaItem, { + headers: { + authorization: `bearer ${authToken}`, + }, + variables: { + input: { + id: agendaItemId, + }, + }, + }); + + expect(agendaItemResult.errors).toBeUndefined(); + expect(agendaItemResult.data.agendaItem).toEqual( + expect.objectContaining({ + id: agendaItemId, + name: expect.any(String), + }), + ); + }); + }); + + suite("Token Validation Tests", () => { + test("returns error with malformed JWT token", async () => { + const header = faker.string.alphanumeric(10); + const payload = faker.string.alphanumeric(15); + const malformedToken = `${header}.${payload}`; + + const result = await mercuriusClient.query(Query_agendaItem, { + headers: { + authorization: `bearer ${malformedToken}`, + }, + variables: { + input: { + id: faker.string.uuid(), + }, + }, + }); + + expect(result.data.agendaItem).toEqual(null); + expect(result.errors?.[0]?.extensions?.code).toBe("unauthenticated"); + }); + + test("returns error with expired token", async () => { + // Create token that expired 1 hour ago + const expiredToken = createTestToken({ + exp: Math.floor(Date.now() / 1000) - 3600, // Expired 1 hour ago + iat: Math.floor(Date.now() / 1000) - 7200, // Created 2 hours ago + }); + + const result = await mercuriusClient.query(Query_agendaItem, { + headers: { + authorization: `bearer ${expiredToken}`, + }, + variables: { + input: { + id: faker.string.uuid(), + }, + }, + }); + + expect(result.data.agendaItem).toEqual(null); + expect(result.errors).toEqual([ + expect.objectContaining({ + extensions: { + code: "unauthenticated", + }, + message: "You must be authenticated to perform this action.", + path: ["agendaItem"], + locations: expect.arrayContaining([ + expect.objectContaining({ + line: expect.any(Number), + column: expect.any(Number), + }), + ]), + }), + ]); + }); + + test("returns error with token containing invalid signature", async () => { + // Valid format but invalid signature + const invalidSignatureToken = [ + Buffer.from(JSON.stringify({ alg: "HS256", typ: "JWT" })).toString( + "base64", + ), + Buffer.from(JSON.stringify({ sub: faker.string.uuid() })).toString( + "base64", + ), + "invalidsignature", + ].join("."); + + const result = await mercuriusClient.query(Query_agendaItem, { + headers: { + authorization: `bearer ${invalidSignatureToken}`, + }, + variables: { + input: { + id: faker.string.uuid(), + }, + }, + }); + + expect(result.data.agendaItem).toEqual(null); + expect(result.errors?.[0]?.extensions?.code).toBe("unauthenticated"); + }); + + test("returns error with empty token", async () => { + const result = await mercuriusClient.query(Query_agendaItem, { + headers: { + authorization: "bearer ", + }, + variables: { + input: { + id: faker.string.uuid(), + }, + }, + }); + + expect(result.data.agendaItem).toEqual(null); + expect(result.errors?.[0]?.extensions?.code).toBe("unauthenticated"); + }); + + test("returns error with token containing invalid character encoding", async () => { + // Create token with invalid UTF-8 sequence + const invalidEncodingToken = Buffer.from([0xff, 0xfe, 0xfd]).toString( + "base64", + ); + + const result = await mercuriusClient.query(Query_agendaItem, { + headers: { + authorization: `bearer ${invalidEncodingToken}`, + }, + variables: { + input: { + id: faker.string.uuid(), + }, + }, + }); + + expect(result.data.agendaItem).toEqual(null); + expect(result.errors?.[0]?.extensions?.code).toBe("unauthenticated"); + }); + }); +}); diff --git a/test/routes/graphql/Query/allUsers.test.ts b/test/routes/graphql/Query/allUsers.test.ts new file mode 100644 index 00000000000..c528a4dc6c8 --- /dev/null +++ b/test/routes/graphql/Query/allUsers.test.ts @@ -0,0 +1,509 @@ +import { faker } from "@faker-js/faker"; +import { afterAll, beforeAll, expect, suite, test } from "vitest"; +import { assertToBeNonNullish } from "../../../helpers"; +import { server } from "../../../server"; +import { mercuriusClient } from "../client"; +import { + Mutation_createUser, + Mutation_deleteUser, + Query_allUsers, + Query_signIn, +} from "../documentNodes"; + +suite("Query field allUsers", () => { + let adminAuthToken: string; + let regularUserAuthToken: string; + let regularUserId: string; + + // Setup: Create admin and regular user tokens + beforeAll(async () => { + // Sign in as admin + const adminSignInResult = await mercuriusClient.query(Query_signIn, { + variables: { + input: { + emailAddress: server.envConfig.API_ADMINISTRATOR_USER_EMAIL_ADDRESS, + password: server.envConfig.API_ADMINISTRATOR_USER_PASSWORD, + }, + }, + }); + + if (!adminSignInResult.data?.signIn?.authenticationToken) { + throw new Error( + "Failed to get admin authentication token: Sign in response did not contain auth token", + ); + } + adminAuthToken = adminSignInResult.data.signIn.authenticationToken; + + // Create and sign in as regular user + const createUserResult = await mercuriusClient.mutate(Mutation_createUser, { + headers: { + authorization: `bearer ${adminAuthToken}`, + }, + variables: { + input: { + emailAddress: `${faker.string.ulid()}@test.com`, + isEmailAddressVerified: false, + name: "Regular User", + password: "password123", + role: "regular", + }, + }, + }); + + if (!createUserResult.data?.createUser?.user?.id) { + throw new Error( + "Failed to create regular user: Create user mutation response did not contain user ID", + ); + } + regularUserId = createUserResult.data.createUser.user?.id; + regularUserAuthToken = + createUserResult.data.createUser.authenticationToken || ""; + }); + + // Cleanup + afterAll(async () => { + if (regularUserId) { + await mercuriusClient.mutate(Mutation_deleteUser, { + headers: { + authorization: `bearer ${adminAuthToken}`, + }, + variables: { + input: { + id: regularUserId, + }, + }, + }); + } + }); + + suite("Authentication and Authorization", () => { + test("returns error when user is not authenticated", async () => { + const result = await mercuriusClient.query(Query_allUsers, { + variables: { + first: 5, + }, + }); + + expect(result.data?.allUsers).toBeNull(); + expect(result.errors).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + message: expect.stringContaining( + "You must be authenticated to perform this action.", + ), + extensions: expect.objectContaining({ + code: "unauthenticated", + }), + }), + ]), + ); + }); + + test("returns error when authenticated user is not an administrator", async () => { + const result = await mercuriusClient.query(Query_allUsers, { + headers: { + authorization: `bearer ${regularUserAuthToken}`, + }, + variables: { + first: 5, + }, + }); + + expect(result.data?.allUsers).toBeNull(); + expect(result.errors).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + extensions: expect.objectContaining({ + code: "unauthorized_action", + }), + }), + ]), + ); + }); + + test("returns error when authenticated user is deleted but token is still valid", async () => { + //user2 + // Create and sign in as regular user + const createUser2Result = await mercuriusClient.mutate( + Mutation_createUser, + { + headers: { + authorization: `bearer ${adminAuthToken}`, + }, + variables: { + input: { + emailAddress: `${faker.string.ulid()}2@test.com`, + isEmailAddressVerified: false, + name: "Regular User 2", + password: "password123", + role: "regular", + }, + }, + }, + ); + + assertToBeNonNullish(createUser2Result.data?.createUser); + const regularUser2Id = createUser2Result.data.createUser.user?.id; + const regularUser2AuthToken = + createUser2Result.data.createUser.authenticationToken || ""; + + if (regularUser2Id) { + await mercuriusClient.mutate(Mutation_deleteUser, { + headers: { + authorization: `bearer ${adminAuthToken}`, + }, + variables: { + input: { + id: regularUser2Id, + }, + }, + }); + } + const result = await mercuriusClient.query(Query_allUsers, { + headers: { + authorization: `bearer ${regularUser2AuthToken}`, + }, + variables: { + first: 5, + }, + }); + + expect(result.data?.allUsers).toBeNull(); + expect(result.errors).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + extensions: expect.objectContaining({ + code: "unauthenticated", + }), + }), + ]), + ); + }); + }); + + suite("Pagination", () => { + test("returns first page of results with default pagination", async () => { + const result = await mercuriusClient.query(Query_allUsers, { + headers: { + authorization: `bearer ${adminAuthToken}`, + }, + variables: { + first: 10, + }, + }); + + expect(result.errors).toBeUndefined(); + expect(result.data?.allUsers?.edges).toBeDefined(); + expect(result.data?.allUsers?.pageInfo).toBeDefined(); + expect(Array.isArray(result.data?.allUsers?.edges)).toBe(true); + expect(result.data?.allUsers?.edges?.length).toBeLessThanOrEqual(10); + expect(result.data?.allUsers?.pageInfo).toEqual( + expect.objectContaining({ + hasNextPage: expect.any(Boolean), + hasPreviousPage: expect.any(Boolean), + }), + ); + }); + + test("handles forward pagination with cursor", async () => { + // First page + const firstResult = await mercuriusClient.query(Query_allUsers, { + headers: { + authorization: `bearer ${adminAuthToken}`, + }, + variables: { + first: 2, + }, + }); + + if (!firstResult.data?.allUsers?.edges?.[1]) { + throw new Error("Failed to get first page of results"); + } + const cursor = firstResult.data.allUsers.edges[1].cursor; + + // Next page + const nextResult = await mercuriusClient.query(Query_allUsers, { + headers: { + authorization: `bearer ${adminAuthToken}`, + }, + variables: { + first: 2, + after: cursor, + }, + }); + + expect(nextResult.errors).toBeUndefined(); + expect(nextResult.data?.allUsers?.edges).toBeDefined(); + if (!nextResult.data?.allUsers?.edges?.[0]) { + throw new Error("Failed to get next page of results"); + } + expect(nextResult.data.allUsers.edges[0].cursor).not.toBe(cursor); + }); + + test("handles backward pagination with cursor", async () => { + // Get some initial data + const initialResult = await mercuriusClient.query(Query_allUsers, { + headers: { + authorization: `bearer ${adminAuthToken}`, + }, + variables: { + first: 3, + }, + }); + + if (!initialResult.data?.allUsers?.edges?.[2]) { + throw new Error("Failed to get initial page of results"); + } + const cursor = initialResult.data.allUsers.edges[2].cursor; + + // Get previous page + const previousResult = await mercuriusClient.query(Query_allUsers, { + headers: { + authorization: `bearer ${adminAuthToken}`, + }, + variables: { + last: 2, + before: cursor, + }, + }); + + expect(previousResult.errors).toBeUndefined(); + expect(previousResult.data?.allUsers?.edges).toBeDefined(); + expect(previousResult.data?.allUsers?.edges?.length).toBeLessThanOrEqual( + 2, + ); + }); + }); + + suite("Name Search", () => { + test("filters users by name search", async () => { + const uniqueName = `Test${faker.string.alphanumeric(10)}`; + let userId: string | undefined; + + // Create a user with unique name + const createResult = await mercuriusClient.mutate(Mutation_createUser, { + headers: { + authorization: `bearer ${adminAuthToken}`, + }, + variables: { + input: { + emailAddress: `${faker.string.ulid()}@test.com`, + isEmailAddressVerified: false, + name: uniqueName, + password: "password123", + role: "regular", + }, + }, + }); + + userId = createResult.data?.createUser?.user?.id; + + const result = await mercuriusClient.query(Query_allUsers, { + headers: { + authorization: `bearer ${adminAuthToken}`, + }, + variables: { + name: uniqueName, + first: 5, + }, + }); + + expect(result.errors).toBeUndefined(); + expect(result.data?.allUsers?.edges).toBeDefined(); + expect(result.data?.allUsers?.edges?.length).toBeGreaterThan(0); + if (!result.data?.allUsers?.edges?.[0]?.node) { + throw new Error("Failed to find user with unique name"); + } + expect(result.data.allUsers.edges[0].node.name).toBe(uniqueName); + + // Cleanup + if (userId) { + await mercuriusClient.mutate(Mutation_deleteUser, { + headers: { + authorization: `bearer ${adminAuthToken}`, + }, + variables: { + input: { + id: userId, + }, + }, + }); + } + }); + + test("returns empty result for non-matching name search", async () => { + const result = await mercuriusClient.query(Query_allUsers, { + headers: { + authorization: `bearer ${adminAuthToken}`, + }, + variables: { + name: `NonExistentUserName${faker.string.alphanumeric(10)}`, + first: 5, + }, + }); + + expect(result.errors).toBeUndefined(); + expect(result.data?.allUsers?.edges).toHaveLength(0); + }); + + test("returns empty result for non-matching name search using last", async () => { + const result = await mercuriusClient.query(Query_allUsers, { + headers: { + authorization: `bearer ${adminAuthToken}`, + }, + variables: { + name: `NonExistentUserName${faker.string.alphanumeric(10)}`, + last: 5, + }, + }); + + expect(result.errors).toBeUndefined(); + expect(result.data?.allUsers?.edges).toHaveLength(0); + }); + }); + + suite("Input Validation", () => { + test("validates minimum name length", async () => { + const result = await mercuriusClient.query(Query_allUsers, { + headers: { + authorization: `bearer ${adminAuthToken}`, + }, + variables: { + name: "", + }, + }); + + expect(result.data?.allUsers).toBeNull(); + expect(result.errors).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + extensions: expect.objectContaining({ + code: "invalid_arguments", + }), + }), + ]), + ); + }); + + test("validates pagination arguments", async () => { + const result = await mercuriusClient.query(Query_allUsers, { + headers: { + authorization: `bearer ${adminAuthToken}`, + }, + variables: { + first: -1, + }, + }); + + expect(result.data?.allUsers).toBeNull(); + expect(result.errors).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + extensions: expect.objectContaining({ + code: "invalid_arguments", + }), + }), + ]), + ); + }); + + test("returns error for invalid cursor using first", async () => { + const result = await mercuriusClient.query(Query_allUsers, { + headers: { + authorization: `bearer ${adminAuthToken}`, + }, + variables: { + first: 5, + after: "eyJjcmVhdGVkQXQiOiIyMDI1LTAyLTA4VD", + }, + }); + + expect(result.data?.allUsers).toBeNull(); + expect(result.errors).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + extensions: expect.objectContaining({ + code: "invalid_arguments", + }), + }), + ]), + ); + }); + + test("returns error for invalid cursor using last", async () => { + const result = await mercuriusClient.query(Query_allUsers, { + headers: { + authorization: `bearer ${adminAuthToken}`, + }, + variables: { + last: 5, + before: "eyJjcmVhdGVkQXQiOiIyMDI1LTAyLTA4VD", + }, + }); + + expect(result.data?.allUsers).toBeNull(); + expect(result.errors).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + extensions: expect.objectContaining({ + code: "invalid_arguments", + }), + }), + ]), + ); + }); + + test("returns error for cursor of non-existing user", async () => { + const result = await mercuriusClient.query(Query_allUsers, { + headers: { + authorization: `bearer ${adminAuthToken}`, + }, + variables: { + first: 5, + after: + "eyJjcmVhdGVkQXQiOiIyMDI1LTAyLTA4VDEzOjM2OjQ4LjkxNVoiLCJpZCI6IjAxOTRlNWM2LWY1MTMtNzM1OS05ZTBiLTgyYzkxZWIxOTYwZiJ9", + }, + }); + + expect(result.data?.allUsers).toBeNull(); + expect(result.errors).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + extensions: expect.objectContaining({ + code: "arguments_associated_resources_not_found", + }), + }), + ]), + ); + }); + + test("returns error for cursor of non-existing user using last", async () => { + const result = await mercuriusClient.query(Query_allUsers, { + headers: { + authorization: `bearer ${adminAuthToken}`, + }, + variables: { + last: 5, + before: + "eyJjcmVhdGVkQXQiOiIyMDI1LTAyLTA4VDEzOjM2OjQ4LjkxNVoiLCJpZCI6IjAxOTRlNWM2LWY1MTMtNzM1OS05ZTBiLTgyYzkxZWIxOTYwZiJ9", + }, + }); + + expect(result.data?.allUsers).toBeNull(); + expect(result.errors).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + extensions: expect.objectContaining({ + code: "arguments_associated_resources_not_found", + issues: expect.arrayContaining([ + expect.objectContaining({ + argumentPath: ["before"], + }), + ]), + }), + }), + ]), + ); + }); + }); +}); diff --git a/test/routes/graphql/Query/event.test.ts b/test/routes/graphql/Query/event.test.ts new file mode 100644 index 00000000000..5fc96027a64 --- /dev/null +++ b/test/routes/graphql/Query/event.test.ts @@ -0,0 +1,626 @@ +import { faker } from "@faker-js/faker"; +import { expect, suite, test } from "vitest"; +import type { + InvalidArgumentsExtensions, + TalawaGraphQLFormattedError, + UnauthenticatedExtensions, + UnauthorizedActionOnArgumentsAssociatedResourcesExtensions, +} from "~/src/utilities/TalawaGraphQLError"; +import { assertToBeNonNullish } from "../../../helpers"; +import { server } from "../../../server"; +import { mercuriusClient } from "../client"; +import { + Mutation_createEvent, + Mutation_createOrganization, + Mutation_createUser, + Mutation_deleteUser, + Query_event, + Query_signIn, +} from "../documentNodes"; + +suite("Query field event", () => { + // Helper function to get admin auth token + async function getAdminToken() { + const signInResult = await mercuriusClient.query(Query_signIn, { + variables: { + input: { + emailAddress: server.envConfig.API_ADMINISTRATOR_USER_EMAIL_ADDRESS, + password: server.envConfig.API_ADMINISTRATOR_USER_PASSWORD, + }, + }, + }); + + const authToken = signInResult.data?.signIn?.authenticationToken; + assertToBeNonNullish(authToken); + return authToken; + } + + // Helper function to create an organization + async function createTestOrganization(authToken: string) { + const orgResult = await mercuriusClient.mutate( + Mutation_createOrganization, + { + headers: { + authorization: `bearer ${authToken}`, + }, + variables: { + input: { + countryCode: "us", + name: `Test Organization ${faker.string.alphanumeric(8)}`, + }, + }, + }, + ); + + const organization = orgResult.data?.createOrganization; + assertToBeNonNullish(organization); + return organization; + } + + async function createTestEvent( + authToken: string, + organizationId: string, + options: { + durationInHours?: number; + startOffset?: number; // milliseconds from now + description?: string; + name?: string; + } = {}, + ) { + const { + durationInHours = 24, + startOffset = 0, + description = "Test Event", + name = "Test Event", + } = options; + + const startAt = new Date(Date.now() + startOffset); + const endAt = new Date( + startAt.getTime() + durationInHours * 60 * 60 * 1000, + ); + + const eventResult = await mercuriusClient.mutate(Mutation_createEvent, { + headers: { + authorization: `bearer ${authToken}`, + }, + variables: { + input: { + description, + endAt: endAt.toISOString(), + name, + organizationId, + startAt: startAt.toISOString(), + }, + }, + }); + + const event = eventResult.data?.createEvent; + assertToBeNonNullish(event); + return event; + } + + async function setupTestData(authToken: string) { + const organization = await createTestOrganization(authToken); + const event = await createTestEvent(authToken, organization.id); + return { organization, event }; + } + + // Helper function to create and delete a test user + async function createAndDeleteTestUser(authToken: string) { + const userResult = await mercuriusClient.mutate(Mutation_createUser, { + headers: { + authorization: `bearer ${authToken}`, + }, + variables: { + input: { + emailAddress: `${faker.string.ulid()}@test.com`, + isEmailAddressVerified: true, + name: "Test User", + password: "password123", + role: "regular", + }, + }, + }); + + const user = userResult.data?.createUser; + assertToBeNonNullish(user); + assertToBeNonNullish(user.authenticationToken); + assertToBeNonNullish(user.user); + + await mercuriusClient.mutate(Mutation_deleteUser, { + headers: { + authorization: `bearer ${authToken}`, + }, + variables: { + input: { + id: user.user.id, + }, + }, + }); + + return user; + } + + suite( + `results in a graphql error with "unauthenticated" extensions code in the "errors" field and "null" as the value of "data.event" field if`, + () => { + test("client triggering the graphql operation is not authenticated.", async () => { + const eventResult = await mercuriusClient.query(Query_event, { + variables: { + input: { + id: faker.string.ulid(), + }, + }, + }); + + expect(eventResult.data.event).toBeNull(); + expect(eventResult.errors).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + extensions: expect.objectContaining({ + code: "unauthenticated", + }), + message: expect.any(String), + path: ["event"], + }), + ]), + ); + }); + + test("client triggering the graphql operation has no existing user associated to their authentication context.", async () => { + const authToken = await getAdminToken(); + const { event } = await setupTestData(authToken); + const deletedUser = await createAndDeleteTestUser(authToken); + + // Try to access event with deleted user's token + const queryResult = await mercuriusClient.query(Query_event, { + headers: { + authorization: `bearer ${deletedUser.authenticationToken}`, + }, + variables: { + input: { + id: event.id, + }, + }, + }); + + expect(queryResult.data.event).toBeNull(); + expect(queryResult.errors).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + extensions: expect.objectContaining({ + code: "unauthenticated", + }), + message: expect.any(String), + path: ["event"], + }), + ]), + ); + }); + }, + ); + + suite( + `results in a graphql error with "invalid_arguments" extensions code in the "errors" field and "null" as the value of "data.event" field if`, + () => { + test("fails with ULID of wrong length", async () => { + const authToken = await getAdminToken(); + const result = await mercuriusClient.query(Query_event, { + headers: { + authorization: `bearer ${authToken}`, + }, + variables: { + input: { + id: "01ARZ3NDEKTSV4RRFFQ69G5FAV", // 26 chars instead of 27 + }, + }, + }); + + expect(result.data.event).toBeNull(); + expect(result.errors).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + extensions: expect.objectContaining({ + code: "invalid_arguments", + issues: expect.arrayContaining([ + expect.objectContaining({ + argumentPath: ["input", "id"], + message: "Invalid uuid", + }), + ]), + }), + message: "You have provided invalid arguments for this action.", + path: ["event"], + }), + ]), + ); + }); + + test("fails with ULID containing invalid characters", async () => { + const authToken = await getAdminToken(); + const result = await mercuriusClient.query(Query_event, { + headers: { + authorization: `bearer ${authToken}`, + }, + variables: { + input: { + id: "01ARZ3NDEKTSV4RRFFQ69G5FA!", // Contains invalid '!' + }, + }, + }); + + expect(result.data.event).toBeNull(); + expect(result.errors).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + extensions: expect.objectContaining({ + code: "invalid_arguments", + issues: expect.arrayContaining([ + expect.objectContaining({ + argumentPath: ["input", "id"], + message: "Invalid uuid", + }), + ]), + }), + message: "You have provided invalid arguments for this action.", + path: ["event"], + }), + ]), + ); + }); + + test("provided event ID is not a valid ULID.", async () => { + const signInResult = await mercuriusClient.query(Query_signIn, { + variables: { + input: { + emailAddress: + server.envConfig.API_ADMINISTRATOR_USER_EMAIL_ADDRESS, + password: server.envConfig.API_ADMINISTRATOR_USER_PASSWORD, + }, + }, + }); + + const authToken = signInResult.data?.signIn?.authenticationToken; + assertToBeNonNullish(authToken); + + const eventResult = await mercuriusClient.query(Query_event, { + headers: { + authorization: `bearer ${authToken}`, + }, + variables: { + input: { + id: "invalid-id", + }, + }, + }); + + expect(eventResult.data.event).toBeNull(); + expect(eventResult.errors).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + extensions: expect.objectContaining({ + code: "invalid_arguments", + issues: expect.arrayContaining([ + expect.objectContaining({ + argumentPath: ["input", "id"], + message: "Invalid uuid", + }), + ]), + }), + message: "You have provided invalid arguments for this action.", + path: ["event"], + }), + ]), + ); + }); + }, + ); + + test("unauthorized regular user cannot access event from an organization they are not a member of", async () => { + const adminAuthToken = await getAdminToken(); + const organization = await createTestOrganization(adminAuthToken); + const event = await createTestEvent(adminAuthToken, organization.id); + + // Create a regular user who is not a member of the organization + const userResult = await mercuriusClient.mutate(Mutation_createUser, { + headers: { + authorization: `bearer ${adminAuthToken}`, + }, + variables: { + input: { + emailAddress: `${faker.string.ulid()}@test.com`, + isEmailAddressVerified: true, + name: "Test User", + password: "password123", + role: "regular", + }, + }, + }); + + const user = userResult.data?.createUser; + assertToBeNonNullish(user); + assertToBeNonNullish(user.authenticationToken); + + // Try to access event as regular user + const queryResult = await mercuriusClient.query(Query_event, { + headers: { + authorization: `bearer ${user.authenticationToken}`, + }, + variables: { + input: { + id: event.id, + }, + }, + }); + + expect(queryResult.data.event).toBeNull(); + expect(queryResult.errors).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + extensions: + expect.objectContaining( + { + code: "unauthorized_action_on_arguments_associated_resources", + issues: expect.arrayContaining([ + expect.objectContaining({ + argumentPath: ["input", "id"], + }), + ]), + }, + ), + message: expect.any(String), + path: ["event"], + }), + ]), + ); + }); + + // Then refactor the "admin user can access event" test to: + test("admin user can access event from any organization", async () => { + const adminAuthToken = await getAdminToken(); + + // Create test organization + const orgResult = await mercuriusClient.mutate( + Mutation_createOrganization, + { + headers: { + authorization: `bearer ${adminAuthToken}`, + }, + variables: { + input: { + countryCode: "us", + name: "Test Organization", + }, + }, + }, + ); + + const organization = orgResult.data?.createOrganization; + assertToBeNonNullish(organization); + + // Create test event using helper + const event = await createTestEvent(adminAuthToken, organization.id); + + // Try to access event as admin + const queryResult = await mercuriusClient.query(Query_event, { + headers: { + authorization: `bearer ${adminAuthToken}`, + }, + variables: { + input: { + id: event.id, + }, + }, + }); + + const queriedEvent = queryResult.data.event; + expect(queriedEvent).not.toBeNull(); + assertToBeNonNullish(queriedEvent); + expect(queriedEvent.id).toBe(event.id); + expect(queryResult.errors).toBeUndefined(); + }); + + // These additional test cases do not improve coverage from the actual files, However -> They help testing the application better + suite("Additional event tests", () => { + test("handles events with past dates correctly", async () => { + const authToken = await getAdminToken(); + const organization = await createTestOrganization(authToken); + + // Create an event in the past + const pastEventResult = await mercuriusClient.mutate( + Mutation_createEvent, + { + headers: { + authorization: `bearer ${authToken}`, + }, + variables: { + input: { + description: "Past Event", + // Set dates to last week + startAt: new Date( + Date.now() - 7 * 24 * 60 * 60 * 1000, + ).toISOString(), + endAt: new Date( + Date.now() - 6 * 24 * 60 * 60 * 1000, + ).toISOString(), + name: "Past Event", + organizationId: organization.id, + }, + }, + }, + ); + + const pastEvent = pastEventResult.data?.createEvent; + assertToBeNonNullish(pastEvent); + + // Query the past event + const queryResult = await mercuriusClient.query(Query_event, { + headers: { + authorization: `bearer ${authToken}`, + }, + variables: { + input: { + id: pastEvent.id, + }, + }, + }); + + const queriedEvent = queryResult.data.event; + expect(queriedEvent).not.toBeNull(); + assertToBeNonNullish(queriedEvent); + expect(queriedEvent.id).toBe(pastEvent.id); + assertToBeNonNullish(queriedEvent.startAt); + assertToBeNonNullish(queriedEvent.endAt); + expect(new Date(queriedEvent.startAt).getTime()).toBeLessThan(Date.now()); + expect(new Date(queriedEvent.endAt).getTime()).toBeLessThan(Date.now()); + }); + + test("handles multi-day events correctly", async () => { + const authToken = await getAdminToken(); + const organization = await createTestOrganization(authToken); + + // Create a multi-day event + const multiDayEventResult = await mercuriusClient.mutate( + Mutation_createEvent, + { + headers: { + authorization: `bearer ${authToken}`, + }, + variables: { + input: { + description: "Multi-day Conference", + startAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(), // Tomorrow + endAt: new Date( + Date.now() + 3 * 24 * 60 * 60 * 1000, + ).toISOString(), // 3 days from now + name: "Annual Conference", + organizationId: organization.id, + }, + }, + }, + ); + + const multiDayEvent = multiDayEventResult.data?.createEvent; + assertToBeNonNullish(multiDayEvent); + + // Query the multi-day event + const queryResult = await mercuriusClient.query(Query_event, { + headers: { + authorization: `bearer ${authToken}`, + }, + variables: { + input: { + id: multiDayEvent.id, + }, + }, + }); + + const queriedEvent = queryResult.data.event; + expect(queriedEvent).not.toBeNull(); + assertToBeNonNullish(queriedEvent); + expect(queriedEvent.id).toBe(multiDayEvent.id); + + assertToBeNonNullish(queriedEvent.startAt); + assertToBeNonNullish(queriedEvent.endAt); + const startDate = new Date(queriedEvent.startAt); + const endDate = new Date(queriedEvent.endAt); + const durationInDays = + (endDate.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24); + expect(durationInDays).toBeGreaterThan(1); + }); + + test("handles events with minimal fields correctly", async () => { + const authToken = await getAdminToken(); + const organization = await createTestOrganization(authToken); + + // Create an event with only required fields + const minimalEventResult = await mercuriusClient.mutate( + Mutation_createEvent, + { + headers: { + authorization: `bearer ${authToken}`, + }, + variables: { + input: { + name: "Minimal Event", + startAt: new Date(Date.now() + 60 * 60 * 1000).toISOString(), // 1 hour from now + endAt: new Date(Date.now() + 2 * 60 * 60 * 1000).toISOString(), // 2 hours from now + organizationId: organization.id, + }, + }, + }, + ); + + const minimalEvent = minimalEventResult.data?.createEvent; + assertToBeNonNullish(minimalEvent); + + // Query the minimal event + const queryResult = await mercuriusClient.query(Query_event, { + headers: { + authorization: `bearer ${authToken}`, + }, + variables: { + input: { + id: minimalEvent.id, + }, + }, + }); + + const queriedEvent = queryResult.data.event; + expect(queriedEvent).not.toBeNull(); + assertToBeNonNullish(queriedEvent); + expect(queriedEvent.id).toBe(minimalEvent.id); + expect(queriedEvent.description).toBeNull(); + }); + + test("handles concurrent access patterns correctly", async () => { + const authToken = await getAdminToken(); + const organization = await createTestOrganization(authToken); + + // Create an initial event + const eventResult = await mercuriusClient.mutate(Mutation_createEvent, { + headers: { + authorization: `bearer ${authToken}`, + }, + variables: { + input: { + name: "Concurrent Access Event", + startAt: new Date(Date.now() + 60 * 60 * 1000).toISOString(), + endAt: new Date(Date.now() + 2 * 60 * 60 * 1000).toISOString(), + organizationId: organization.id, + }, + }, + }); + + const event = eventResult.data?.createEvent; + assertToBeNonNullish(event); + + // Perform multiple concurrent queries + const concurrentQueries = Array(5) + .fill(null) + .map(() => + mercuriusClient.query(Query_event, { + headers: { + authorization: `bearer ${authToken}`, + }, + variables: { + input: { + id: event.id, + }, + }, + }), + ); + + const results = await Promise.all(concurrentQueries); + + // Verify all queries returned the same data + for (const result of results) { + const queriedEvent = result.data.event; + expect(queriedEvent).not.toBeNull(); + assertToBeNonNullish(queriedEvent); + expect(queriedEvent.id).toBe(event.id); + expect(queriedEvent.name).toBe("Concurrent Access Event"); + } + }); + }); +}); diff --git a/test/routes/graphql/Query/post.test.ts b/test/routes/graphql/Query/post.test.ts new file mode 100644 index 00000000000..ba5372277eb --- /dev/null +++ b/test/routes/graphql/Query/post.test.ts @@ -0,0 +1,336 @@ +import { faker } from "@faker-js/faker"; +import { hash } from "@node-rs/argon2"; +import { eq } from "drizzle-orm"; +import { organizationsTable, postsTable, usersTable } from "src/drizzle/schema"; +import { uuidv7 } from "uuidv7"; +import { beforeEach, expect, suite, test } from "vitest"; +import type { + ArgumentsAssociatedResourcesNotFoundExtensions, + InvalidArgumentsExtensions, + TalawaGraphQLFormattedError, + UnauthenticatedExtensions, + UnauthorizedActionOnArgumentsAssociatedResourcesExtensions, +} from "~/src/utilities/TalawaGraphQLError"; +import { server } from "../../../server"; +import { mercuriusClient } from "../client"; +import { Query_post, Query_signIn } from "../documentNodes"; + +suite("Query field post", () => { + let adminUserId: string; + + // Add the helper function for creating test users + const createTestUser = async ( + role: "regular" | "administrator" = "regular", + ) => { + const testEmail = `test.user.${faker.string.ulid()}@email.com`; + const testPassword = "password"; + + const hashedPassword = await hash(testPassword); + + const [userRow] = await server.drizzleClient + .insert(usersTable) + .values({ + emailAddress: testEmail, + passwordHash: hashedPassword, + role, + name: faker.person.fullName(), + isEmailAddressVerified: true, + }) + .returning({ + id: usersTable.id, + }); + + if (!userRow) + throw new Error( + "Failed to create test user: Database insert operation returned no rows", + ); + + return { userId: userRow.id, email: testEmail, password: testPassword }; + }; + + beforeEach(async () => { + const [existingAdmin] = await server.drizzleClient + .select({ id: usersTable.id }) + .from(usersTable) + .where( + eq( + usersTable.emailAddress, + server.envConfig.API_ADMINISTRATOR_USER_EMAIL_ADDRESS, + ), + ) + .limit(1); + if (existingAdmin) { + adminUserId = existingAdmin.id; + return; + } + const [newAdmin] = await server.drizzleClient + .insert(usersTable) + .values({ + emailAddress: server.envConfig.API_ADMINISTRATOR_USER_EMAIL_ADDRESS, + passwordHash: server.envConfig.API_ADMINISTRATOR_USER_PASSWORD, + role: "administrator", + name: server.envConfig.API_ADMINISTRATOR_USER_NAME, + isEmailAddressVerified: true, + }) + .onConflictDoNothing() + .returning({ id: usersTable.id }); + if (!newAdmin) throw new Error("Failed to create admin user"); + adminUserId = newAdmin.id; + }); + + const getAuthToken = async () => { + const signInResult = await mercuriusClient.query(Query_signIn, { + variables: { + input: { + emailAddress: server.envConfig.API_ADMINISTRATOR_USER_EMAIL_ADDRESS, + password: server.envConfig.API_ADMINISTRATOR_USER_PASSWORD, + }, + }, + }); + if (!signInResult.data?.signIn?.authenticationToken) { + throw new Error( + "Failed to get authentication token: Sign-in operation failed", + ); + } + return signInResult.data.signIn?.authenticationToken; + }; + + const createTestPost = async (creatorId: string) => { + const [organizationRow] = await server.drizzleClient + .insert(organizationsTable) + .values({ + name: faker.company.name(), + countryCode: "us", + }) + .returning({ id: organizationsTable.id }); + + const organizationId = organizationRow?.id; + if (!organizationId) throw new Error("Failed to create organization."); + + const [postRow] = await server.drizzleClient + .insert(postsTable) + .values({ + caption: faker.lorem.paragraph(), + creatorId, + organizationId, + }) + .returning({ id: postsTable.id }); + + const postId = postRow?.id; + if (!postId) throw new Error("Failed to create post."); + + return { postId, organizationId }; + }; + + suite( + `results in a graphql error with "unauthenticated" extensions code in the "errors" field and "null" as the value of "data.post" field if`, + () => { + test("client triggering the graphql operation is not authenticated.", async () => { + const result = await mercuriusClient.query(Query_post, { + variables: { + input: { + id: uuidv7(), + }, + }, + }); + expect(result.data.post).toEqual(null); + expect(result.errors).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + extensions: expect.objectContaining({ + code: "unauthenticated", + }), + message: expect.any(String), + path: ["post"], + }), + ]), + ); + }); + }, + ); + suite( + `results in a graphql error with "invalid_arguments" extensions code in the "errors" field and "null" as the value of "data.post" field if`, + () => { + test("the provided post ID is not a valid UUID.", async () => { + const authToken = await getAuthToken(); + const result = await mercuriusClient.query(Query_post, { + headers: { + authorization: `bearer ${authToken}`, + }, + variables: { + input: { + id: "not-a-uuid", + }, + }, + }); + expect(result.data.post).toEqual(null); + expect(result.errors).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + extensions: expect.objectContaining({ + code: "invalid_arguments", + issues: expect.arrayContaining([ + { + argumentPath: ["input", "id"], + message: expect.any(String), + }, + ]), + }), + message: expect.any(String), + path: ["post"], + }), + ]), + ); + }); + }, + ); + suite( + `results in a graphql error with "arguments_associated_resources_not_found" extensions code in the "errors" field and "null" as the value of "data.post" field if`, + () => { + test(`value of the "input.id" does not correspond to an existing post.`, async () => { + const authToken = await getAuthToken(); + const result = await mercuriusClient.query(Query_post, { + headers: { + authorization: `bearer ${authToken}`, + }, + variables: { + input: { + id: uuidv7(), + }, + }, + }); + expect(result.data.post).toEqual(null); + expect(result.errors).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + extensions: + expect.objectContaining( + { + code: "arguments_associated_resources_not_found", + issues: expect.arrayContaining< + ArgumentsAssociatedResourcesNotFoundExtensions["issues"][number] + >([ + { + argumentPath: ["input", "id"], + }, + ]), + }, + ), + message: expect.any(String), + path: ["post"], + }), + ]), + ); + }); + }, + ); + suite( + `results in a graphql error with "unauthenticated" extensions code in the "errors" field and "null" as the value of "data.post" field if`, + () => { + test("authenticated user exists in token but not in database.", async () => { + const { userId, email, password } = + await createTestUser("administrator"); + const signInResult = await mercuriusClient.query(Query_signIn, { + variables: { + input: { + emailAddress: email, + password: password, + }, + }, + }); + + const authToken = signInResult.data?.signIn?.authenticationToken; + if (!authToken) + throw new Error( + "Failed to get authentication token from sign-in result", + ); + + await server.drizzleClient + .delete(usersTable) + .where(eq(usersTable.id, userId)); + const result = await mercuriusClient.query(Query_post, { + headers: { + authorization: `bearer ${authToken}`, + }, + variables: { + input: { + id: uuidv7(), + }, + }, + }); + expect(result.data.post).toEqual(null); + expect(result.errors).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + extensions: expect.objectContaining({ + code: "unauthenticated", + }), + message: expect.any(String), + path: ["post"], + }), + ]), + ); + }); + }, + ); + suite( + `results in a graphql error with "unauthorized_action_on_arguments_associated_resources" extensions code in the "errors" field and "null" as the value of "data.post" field if`, + () => { + test("regular user attempts to access a post from an organization they are not a member of", async () => { + // Create test user using the helper function + const { email, password } = await createTestUser(); + // Create a different user's post + const { postId } = await createTestPost(adminUserId); + // Sign in with plain password + const signInResult = await mercuriusClient.query(Query_signIn, { + variables: { + input: { + emailAddress: email, + password: password, + }, + }, + }); + const authToken = signInResult.data?.signIn?.authenticationToken; + // validation for authentication token + if (!authToken) { + console.error( + "SignIn Result:", + JSON.stringify(signInResult, null, 2), + ); + throw new Error("Failed to get authentication token"); + } + // Attempt to access post + const result = await mercuriusClient.query(Query_post, { + headers: { + authorization: `bearer ${authToken}`, + }, + variables: { + input: { + id: postId, + }, + }, + }); + expect(result.data.post).toEqual(null); + expect(result.errors).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + extensions: + expect.objectContaining( + { + code: "unauthorized_action_on_arguments_associated_resources", + issues: expect.arrayContaining([ + { + argumentPath: ["input", "id"], + }, + ]), + }, + ), + message: expect.any(String), + path: ["post"], + }), + ]), + ); + }); + }, + ); +}); diff --git a/test/routes/graphql/documentNodes.ts b/test/routes/graphql/documentNodes.ts index 7aa69f44ffd..02dc58bc038 100644 --- a/test/routes/graphql/documentNodes.ts +++ b/test/routes/graphql/documentNodes.ts @@ -18,6 +18,7 @@ export const Mutation_createUser = addressLine2 birthDate city + id countryCode createdAt description @@ -25,7 +26,6 @@ export const Mutation_createUser = emailAddress employmentStatus homePhoneNumber - id isEmailAddressVerified maritalStatus mobilePhoneNumber @@ -243,6 +243,57 @@ export const Query_user = gql(`query Query_user($input: QueryUserInput!) { } }`); +export const Query_allUsers = gql(` + query Query_allUsers( + $first: Int, + $after: String, + $last: Int, + $before: String, + $name: String + ) { + allUsers( + first: $first, + after: $after, + last: $last, + before: $before, + name: $name + ) { + pageInfo { + hasNextPage + hasPreviousPage + startCursor + endCursor + } + edges { + cursor + node { + id + name + emailAddress + role + createdAt + isEmailAddressVerified + addressLine1 + addressLine2 + birthDate + city + countryCode + description + educationGrade + employmentStatus + homePhoneNumber + maritalStatus + mobilePhoneNumber + natalSex + postalCode + state + workPhoneNumber + } + } + } + } + `); + export const Query_user_creator = gql(`query Query_user_creator($input: QueryUserInput!) { user(input: $input) { @@ -368,6 +419,76 @@ export const Mutation_deleteOrganizationMembership = } }`); +export const Query_post = gql(`query Query_post($input: QueryPostInput!) { + post(input: $input) { + id + organization { + countryCode + } + } +}`); + +export const Query_event = gql(`query Query_event($input: QueryEventInput!) { + event(input: $input) { + id + name + description + startAt + endAt + creator { + id + name + } + organization { + id + countryCode + } + } +}`); + +export const Mutation_createEvent = + gql(`mutation Mutation_createEvent($input: MutationCreateEventInput!) { + createEvent(input: $input) { + id + name + description + startAt + endAt + createdAt + creator{ + id + name + } + organization { + id + countryCode + } + } +}`); + +export const Mutation_deleteEvent = + gql(`mutation Mutation_deleteEvent($input: MutationDeleteEventInput!) { + deleteEvent(input: $input) { + id + } +}`); + +export const Mutation_updateEvent = + gql(`mutation Mutation_updateEvent($input: MutationUpdateEventInput!) { + updateEvent(input: $input) { + id + name + description + startAt + endAt + updatedAt + organization { + id + countryCode + } + } +}`); + export const Query_tag = gql(` query tag($input:QueryTagInput!) { tag(input: $input) { @@ -411,3 +532,48 @@ export const Query_organization = gql(` } } `); + +export const Query_agendaItem = + gql(`query Query_agendaItem($input: QueryAgendaItemInput!) { + agendaItem(input: $input) { + id + name + description + duration + key + type + } +}`); + +export const Mutation_createAgendaFolder = gql(` + mutation Mutation_createAgendaFolder($input: MutationCreateAgendaFolderInput!) { + createAgendaFolder(input: $input) { + id + name + event { + id + } + } + } +`); + +export const Mutation_createAgendaItem = gql(` + mutation Mutation_createAgendaItem($input: MutationCreateAgendaItemInput!) { + createAgendaItem(input: $input) { + id + name + description + duration + type + } + } +`); + +export const Mutation_deleteAgendaItem = gql(` + mutation Mutation_deleteAgendaItem($input: MutationDeleteAgendaItemInput!) { + deleteAgendaItem(input: $input) { + id + name + } + } +`); diff --git a/test/routes/graphql/gql.tada-cache.d.ts b/test/routes/graphql/gql.tada-cache.d.ts index 9350c22239a..3f7bac120e0 100644 --- a/test/routes/graphql/gql.tada-cache.d.ts +++ b/test/routes/graphql/gql.tada-cache.d.ts @@ -4,8 +4,8 @@ import type { TadaDocumentNode, $tada } from 'gql.tada'; declare module 'gql.tada' { interface setupCache { - "mutation Mutation_createUser($input: MutationCreateUserInput!) {\n createUser(input: $input){\n authenticationToken\n user {\n addressLine1\n addressLine2\n birthDate\n city\n countryCode\n createdAt\n description\n educationGrade\n emailAddress\n employmentStatus\n homePhoneNumber\n id\n isEmailAddressVerified\n maritalStatus\n mobilePhoneNumber\n name\n natalSex\n postalCode\n role\n state\n workPhoneNumber\n }\n }\n}": - TadaDocumentNode<{ createUser: { authenticationToken: string | null; user: { addressLine1: string | null; addressLine2: string | null; birthDate: string | null; city: string | null; countryCode: "at" | "pg" | "ad" | "ae" | "af" | "ag" | "ai" | "al" | "am" | "ao" | "aq" | "ar" | "as" | "au" | "aw" | "ax" | "az" | "ba" | "bb" | "bd" | "be" | "bf" | "bg" | "bh" | "bi" | "bj" | "bl" | "bm" | "bn" | "bo" | "bq" | "br" | "bs" | "bt" | "bv" | "bw" | "by" | "bz" | "ca" | "cc" | "cd" | "cf" | "cg" | "ch" | "ci" | "ck" | "cl" | "cm" | "cn" | "co" | "cr" | "cu" | "cv" | "cw" | "cx" | "cy" | "cz" | "de" | "dj" | "dk" | "dm" | "do" | "dz" | "ec" | "ee" | "eg" | "eh" | "er" | "es" | "et" | "fi" | "fj" | "fk" | "fm" | "fo" | "fr" | "ga" | "gb" | "gd" | "ge" | "gf" | "gg" | "gh" | "gi" | "gl" | "gm" | "gn" | "gp" | "gq" | "gr" | "gs" | "gt" | "gu" | "gw" | "gy" | "hk" | "hm" | "hn" | "hr" | "ht" | "hu" | "id" | "ie" | "il" | "im" | "in" | "io" | "iq" | "ir" | "is" | "it" | "je" | "jm" | "jo" | "jp" | "ke" | "kg" | "kh" | "ki" | "km" | "kn" | "kp" | "kr" | "kw" | "ky" | "kz" | "la" | "lb" | "lc" | "li" | "lk" | "lr" | "ls" | "lt" | "lu" | "lv" | "ly" | "ma" | "mc" | "md" | "me" | "mf" | "mg" | "mh" | "mk" | "ml" | "mm" | "mn" | "mo" | "mp" | "mq" | "mr" | "ms" | "mt" | "mu" | "mv" | "mw" | "mx" | "my" | "mz" | "na" | "nc" | "ne" | "nf" | "ng" | "ni" | "nl" | "no" | "np" | "nr" | "nu" | "nz" | "om" | "pa" | "pe" | "pf" | "ph" | "pk" | "pl" | "pm" | "pn" | "pr" | "ps" | "pt" | "pw" | "py" | "qa" | "re" | "ro" | "rs" | "ru" | "rw" | "sa" | "sb" | "sc" | "sd" | "se" | "sg" | "sh" | "si" | "sj" | "sk" | "sl" | "sm" | "sn" | "so" | "sr" | "ss" | "st" | "sv" | "sx" | "sy" | "sz" | "tc" | "td" | "tf" | "tg" | "th" | "tj" | "tk" | "tl" | "tm" | "tn" | "to" | "tr" | "tt" | "tv" | "tw" | "tz" | "ua" | "ug" | "um" | "us" | "uy" | "uz" | "va" | "vc" | "ve" | "vg" | "vi" | "vn" | "vu" | "wf" | "ws" | "ye" | "yt" | "za" | "zm" | "zw" | null; createdAt: string | null; description: string | null; educationGrade: "kg" | "grade_1" | "grade_2" | "grade_3" | "grade_4" | "grade_5" | "grade_6" | "grade_7" | "grade_8" | "grade_9" | "grade_10" | "grade_11" | "grade_12" | "graduate" | "no_grade" | "pre_kg" | null; emailAddress: string | null; employmentStatus: "full_time" | "part_time" | "unemployed" | null; homePhoneNumber: string | null; id: string; isEmailAddressVerified: boolean | null; maritalStatus: "divorced" | "engaged" | "married" | "seperated" | "single" | "widowed" | null; mobilePhoneNumber: string | null; name: string | null; natalSex: "female" | "intersex" | "male" | null; postalCode: string | null; role: "administrator" | "regular" | null; state: string | null; workPhoneNumber: string | null; } | null; } | null; }, { input: { workPhoneNumber?: string | null | undefined; state?: string | null | undefined; role: "administrator" | "regular"; postalCode?: string | null | undefined; password: string; naturalLanguageCode?: "ae" | "af" | "am" | "ar" | "as" | "az" | "ba" | "be" | "bg" | "bi" | "bm" | "bn" | "bo" | "br" | "bs" | "ca" | "ch" | "co" | "cr" | "cu" | "cv" | "cy" | "de" | "dz" | "ee" | "es" | "et" | "fi" | "fj" | "fo" | "fr" | "ga" | "gd" | "gl" | "gn" | "gu" | "hr" | "ht" | "hu" | "id" | "ie" | "io" | "is" | "it" | "kg" | "ki" | "km" | "kn" | "kr" | "kw" | "ky" | "la" | "lb" | "li" | "lt" | "lu" | "lv" | "mg" | "mh" | "mk" | "ml" | "mn" | "mr" | "ms" | "mt" | "my" | "na" | "ne" | "ng" | "nl" | "no" | "nr" | "om" | "pa" | "pl" | "ps" | "pt" | "ro" | "ru" | "rw" | "sa" | "sc" | "sd" | "se" | "sg" | "si" | "sk" | "sl" | "sm" | "sn" | "so" | "sr" | "ss" | "st" | "sv" | "tg" | "th" | "tk" | "tl" | "tn" | "to" | "tr" | "tt" | "tw" | "ug" | "uz" | "ve" | "vi" | "za" | "aa" | "ab" | "ak" | "an" | "av" | "ay" | "ce" | "cs" | "da" | "dv" | "el" | "en" | "eo" | "eu" | "fa" | "ff" | "fy" | "gv" | "ha" | "he" | "hi" | "ho" | "hy" | "hz" | "ia" | "ig" | "ii" | "ik" | "iu" | "ja" | "jv" | "ka" | "kj" | "kk" | "kl" | "ko" | "ks" | "ku" | "kv" | "lg" | "ln" | "lo" | "mi" | "nb" | "nd" | "nn" | "nv" | "ny" | "oc" | "oj" | "or" | "os" | "pi" | "qu" | "rm" | "rn" | "sq" | "su" | "sw" | "ta" | "te" | "ti" | "ts" | "ty" | "uk" | "ur" | "vo" | "wa" | "wo" | "xh" | "yi" | "yo" | "zh" | "zu" | null | undefined; natalSex?: "female" | "intersex" | "male" | null | undefined; name: string; mobilePhoneNumber?: string | null | undefined; maritalStatus?: "divorced" | "engaged" | "married" | "seperated" | "single" | "widowed" | null | undefined; isEmailAddressVerified: boolean; homePhoneNumber?: string | null | undefined; employmentStatus?: "full_time" | "part_time" | "unemployed" | null | undefined; emailAddress: string; educationGrade?: "kg" | "grade_1" | "grade_2" | "grade_3" | "grade_4" | "grade_5" | "grade_6" | "grade_7" | "grade_8" | "grade_9" | "grade_10" | "grade_11" | "grade_12" | "graduate" | "no_grade" | "pre_kg" | null | undefined; description?: string | null | undefined; countryCode?: "at" | "pg" | "ad" | "ae" | "af" | "ag" | "ai" | "al" | "am" | "ao" | "aq" | "ar" | "as" | "au" | "aw" | "ax" | "az" | "ba" | "bb" | "bd" | "be" | "bf" | "bg" | "bh" | "bi" | "bj" | "bl" | "bm" | "bn" | "bo" | "bq" | "br" | "bs" | "bt" | "bv" | "bw" | "by" | "bz" | "ca" | "cc" | "cd" | "cf" | "cg" | "ch" | "ci" | "ck" | "cl" | "cm" | "cn" | "co" | "cr" | "cu" | "cv" | "cw" | "cx" | "cy" | "cz" | "de" | "dj" | "dk" | "dm" | "do" | "dz" | "ec" | "ee" | "eg" | "eh" | "er" | "es" | "et" | "fi" | "fj" | "fk" | "fm" | "fo" | "fr" | "ga" | "gb" | "gd" | "ge" | "gf" | "gg" | "gh" | "gi" | "gl" | "gm" | "gn" | "gp" | "gq" | "gr" | "gs" | "gt" | "gu" | "gw" | "gy" | "hk" | "hm" | "hn" | "hr" | "ht" | "hu" | "id" | "ie" | "il" | "im" | "in" | "io" | "iq" | "ir" | "is" | "it" | "je" | "jm" | "jo" | "jp" | "ke" | "kg" | "kh" | "ki" | "km" | "kn" | "kp" | "kr" | "kw" | "ky" | "kz" | "la" | "lb" | "lc" | "li" | "lk" | "lr" | "ls" | "lt" | "lu" | "lv" | "ly" | "ma" | "mc" | "md" | "me" | "mf" | "mg" | "mh" | "mk" | "ml" | "mm" | "mn" | "mo" | "mp" | "mq" | "mr" | "ms" | "mt" | "mu" | "mv" | "mw" | "mx" | "my" | "mz" | "na" | "nc" | "ne" | "nf" | "ng" | "ni" | "nl" | "no" | "np" | "nr" | "nu" | "nz" | "om" | "pa" | "pe" | "pf" | "ph" | "pk" | "pl" | "pm" | "pn" | "pr" | "ps" | "pt" | "pw" | "py" | "qa" | "re" | "ro" | "rs" | "ru" | "rw" | "sa" | "sb" | "sc" | "sd" | "se" | "sg" | "sh" | "si" | "sj" | "sk" | "sl" | "sm" | "sn" | "so" | "sr" | "ss" | "st" | "sv" | "sx" | "sy" | "sz" | "tc" | "td" | "tf" | "tg" | "th" | "tj" | "tk" | "tl" | "tm" | "tn" | "to" | "tr" | "tt" | "tv" | "tw" | "tz" | "ua" | "ug" | "um" | "us" | "uy" | "uz" | "va" | "vc" | "ve" | "vg" | "vi" | "vn" | "vu" | "wf" | "ws" | "ye" | "yt" | "za" | "zm" | "zw" | null | undefined; city?: string | null | undefined; birthDate?: string | null | undefined; avatar?: unknown; addressLine2?: string | null | undefined; addressLine1?: string | null | undefined; }; }, void>; + "mutation Mutation_createUser($input: MutationCreateUserInput!) {\n createUser(input: $input){\n authenticationToken\n user {\n addressLine1\n addressLine2\n birthDate\n city\n id\n countryCode\n createdAt\n description\n educationGrade\n emailAddress\n employmentStatus\n homePhoneNumber\n isEmailAddressVerified\n maritalStatus\n mobilePhoneNumber\n name\n natalSex\n postalCode\n role\n state\n workPhoneNumber\n }\n }\n}": + TadaDocumentNode<{ createUser: { authenticationToken: string | null; user: { addressLine1: string | null; addressLine2: string | null; birthDate: string | null; city: string | null; id: string; countryCode: "at" | "pg" | "ad" | "ae" | "af" | "ag" | "ai" | "al" | "am" | "ao" | "aq" | "ar" | "as" | "au" | "aw" | "ax" | "az" | "ba" | "bb" | "bd" | "be" | "bf" | "bg" | "bh" | "bi" | "bj" | "bl" | "bm" | "bn" | "bo" | "bq" | "br" | "bs" | "bt" | "bv" | "bw" | "by" | "bz" | "ca" | "cc" | "cd" | "cf" | "cg" | "ch" | "ci" | "ck" | "cl" | "cm" | "cn" | "co" | "cr" | "cu" | "cv" | "cw" | "cx" | "cy" | "cz" | "de" | "dj" | "dk" | "dm" | "do" | "dz" | "ec" | "ee" | "eg" | "eh" | "er" | "es" | "et" | "fi" | "fj" | "fk" | "fm" | "fo" | "fr" | "ga" | "gb" | "gd" | "ge" | "gf" | "gg" | "gh" | "gi" | "gl" | "gm" | "gn" | "gp" | "gq" | "gr" | "gs" | "gt" | "gu" | "gw" | "gy" | "hk" | "hm" | "hn" | "hr" | "ht" | "hu" | "id" | "ie" | "il" | "im" | "in" | "io" | "iq" | "ir" | "is" | "it" | "je" | "jm" | "jo" | "jp" | "ke" | "kg" | "kh" | "ki" | "km" | "kn" | "kp" | "kr" | "kw" | "ky" | "kz" | "la" | "lb" | "lc" | "li" | "lk" | "lr" | "ls" | "lt" | "lu" | "lv" | "ly" | "ma" | "mc" | "md" | "me" | "mf" | "mg" | "mh" | "mk" | "ml" | "mm" | "mn" | "mo" | "mp" | "mq" | "mr" | "ms" | "mt" | "mu" | "mv" | "mw" | "mx" | "my" | "mz" | "na" | "nc" | "ne" | "nf" | "ng" | "ni" | "nl" | "no" | "np" | "nr" | "nu" | "nz" | "om" | "pa" | "pe" | "pf" | "ph" | "pk" | "pl" | "pm" | "pn" | "pr" | "ps" | "pt" | "pw" | "py" | "qa" | "re" | "ro" | "rs" | "ru" | "rw" | "sa" | "sb" | "sc" | "sd" | "se" | "sg" | "sh" | "si" | "sj" | "sk" | "sl" | "sm" | "sn" | "so" | "sr" | "ss" | "st" | "sv" | "sx" | "sy" | "sz" | "tc" | "td" | "tf" | "tg" | "th" | "tj" | "tk" | "tl" | "tm" | "tn" | "to" | "tr" | "tt" | "tv" | "tw" | "tz" | "ua" | "ug" | "um" | "us" | "uy" | "uz" | "va" | "vc" | "ve" | "vg" | "vi" | "vn" | "vu" | "wf" | "ws" | "ye" | "yt" | "za" | "zm" | "zw" | null; createdAt: string | null; description: string | null; educationGrade: "kg" | "grade_1" | "grade_2" | "grade_3" | "grade_4" | "grade_5" | "grade_6" | "grade_7" | "grade_8" | "grade_9" | "grade_10" | "grade_11" | "grade_12" | "graduate" | "no_grade" | "pre_kg" | null; emailAddress: string | null; employmentStatus: "full_time" | "part_time" | "unemployed" | null; homePhoneNumber: string | null; isEmailAddressVerified: boolean | null; maritalStatus: "divorced" | "engaged" | "married" | "seperated" | "single" | "widowed" | null; mobilePhoneNumber: string | null; name: string | null; natalSex: "female" | "intersex" | "male" | null; postalCode: string | null; role: "administrator" | "regular" | null; state: string | null; workPhoneNumber: string | null; } | null; } | null; }, { input: { workPhoneNumber?: string | null | undefined; state?: string | null | undefined; role: "administrator" | "regular"; postalCode?: string | null | undefined; password: string; naturalLanguageCode?: "ae" | "af" | "am" | "ar" | "as" | "az" | "ba" | "be" | "bg" | "bi" | "bm" | "bn" | "bo" | "br" | "bs" | "ca" | "ch" | "co" | "cr" | "cu" | "cv" | "cy" | "de" | "dz" | "ee" | "es" | "et" | "fi" | "fj" | "fo" | "fr" | "ga" | "gd" | "gl" | "gn" | "gu" | "hr" | "ht" | "hu" | "id" | "ie" | "io" | "is" | "it" | "kg" | "ki" | "km" | "kn" | "kr" | "kw" | "ky" | "la" | "lb" | "li" | "lt" | "lu" | "lv" | "mg" | "mh" | "mk" | "ml" | "mn" | "mr" | "ms" | "mt" | "my" | "na" | "ne" | "ng" | "nl" | "no" | "nr" | "om" | "pa" | "pl" | "ps" | "pt" | "ro" | "ru" | "rw" | "sa" | "sc" | "sd" | "se" | "sg" | "si" | "sk" | "sl" | "sm" | "sn" | "so" | "sr" | "ss" | "st" | "sv" | "tg" | "th" | "tk" | "tl" | "tn" | "to" | "tr" | "tt" | "tw" | "ug" | "uz" | "ve" | "vi" | "za" | "aa" | "ab" | "ak" | "an" | "av" | "ay" | "ce" | "cs" | "da" | "dv" | "el" | "en" | "eo" | "eu" | "fa" | "ff" | "fy" | "gv" | "ha" | "he" | "hi" | "ho" | "hy" | "hz" | "ia" | "ig" | "ii" | "ik" | "iu" | "ja" | "jv" | "ka" | "kj" | "kk" | "kl" | "ko" | "ks" | "ku" | "kv" | "lg" | "ln" | "lo" | "mi" | "nb" | "nd" | "nn" | "nv" | "ny" | "oc" | "oj" | "or" | "os" | "pi" | "qu" | "rm" | "rn" | "sq" | "su" | "sw" | "ta" | "te" | "ti" | "ts" | "ty" | "uk" | "ur" | "vo" | "wa" | "wo" | "xh" | "yi" | "yo" | "zh" | "zu" | null | undefined; natalSex?: "female" | "intersex" | "male" | null | undefined; name: string; mobilePhoneNumber?: string | null | undefined; maritalStatus?: "divorced" | "engaged" | "married" | "seperated" | "single" | "widowed" | null | undefined; isEmailAddressVerified: boolean; homePhoneNumber?: string | null | undefined; employmentStatus?: "full_time" | "part_time" | "unemployed" | null | undefined; emailAddress: string; educationGrade?: "kg" | "grade_1" | "grade_2" | "grade_3" | "grade_4" | "grade_5" | "grade_6" | "grade_7" | "grade_8" | "grade_9" | "grade_10" | "grade_11" | "grade_12" | "graduate" | "no_grade" | "pre_kg" | null | undefined; description?: string | null | undefined; countryCode?: "at" | "pg" | "ad" | "ae" | "af" | "ag" | "ai" | "al" | "am" | "ao" | "aq" | "ar" | "as" | "au" | "aw" | "ax" | "az" | "ba" | "bb" | "bd" | "be" | "bf" | "bg" | "bh" | "bi" | "bj" | "bl" | "bm" | "bn" | "bo" | "bq" | "br" | "bs" | "bt" | "bv" | "bw" | "by" | "bz" | "ca" | "cc" | "cd" | "cf" | "cg" | "ch" | "ci" | "ck" | "cl" | "cm" | "cn" | "co" | "cr" | "cu" | "cv" | "cw" | "cx" | "cy" | "cz" | "de" | "dj" | "dk" | "dm" | "do" | "dz" | "ec" | "ee" | "eg" | "eh" | "er" | "es" | "et" | "fi" | "fj" | "fk" | "fm" | "fo" | "fr" | "ga" | "gb" | "gd" | "ge" | "gf" | "gg" | "gh" | "gi" | "gl" | "gm" | "gn" | "gp" | "gq" | "gr" | "gs" | "gt" | "gu" | "gw" | "gy" | "hk" | "hm" | "hn" | "hr" | "ht" | "hu" | "id" | "ie" | "il" | "im" | "in" | "io" | "iq" | "ir" | "is" | "it" | "je" | "jm" | "jo" | "jp" | "ke" | "kg" | "kh" | "ki" | "km" | "kn" | "kp" | "kr" | "kw" | "ky" | "kz" | "la" | "lb" | "lc" | "li" | "lk" | "lr" | "ls" | "lt" | "lu" | "lv" | "ly" | "ma" | "mc" | "md" | "me" | "mf" | "mg" | "mh" | "mk" | "ml" | "mm" | "mn" | "mo" | "mp" | "mq" | "mr" | "ms" | "mt" | "mu" | "mv" | "mw" | "mx" | "my" | "mz" | "na" | "nc" | "ne" | "nf" | "ng" | "ni" | "nl" | "no" | "np" | "nr" | "nu" | "nz" | "om" | "pa" | "pe" | "pf" | "ph" | "pk" | "pl" | "pm" | "pn" | "pr" | "ps" | "pt" | "pw" | "py" | "qa" | "re" | "ro" | "rs" | "ru" | "rw" | "sa" | "sb" | "sc" | "sd" | "se" | "sg" | "sh" | "si" | "sj" | "sk" | "sl" | "sm" | "sn" | "so" | "sr" | "ss" | "st" | "sv" | "sx" | "sy" | "sz" | "tc" | "td" | "tf" | "tg" | "th" | "tj" | "tk" | "tl" | "tm" | "tn" | "to" | "tr" | "tt" | "tv" | "tw" | "tz" | "ua" | "ug" | "um" | "us" | "uy" | "uz" | "va" | "vc" | "ve" | "vg" | "vi" | "vn" | "vu" | "wf" | "ws" | "ye" | "yt" | "za" | "zm" | "zw" | null | undefined; city?: string | null | undefined; birthDate?: string | null | undefined; avatar?: unknown; addressLine2?: string | null | undefined; addressLine1?: string | null | undefined; }; }, void>; "mutation Mutation_deleteCurrentUser {\n deleteCurrentUser {\n id \n }\n}": TadaDocumentNode<{ deleteCurrentUser: { id: string; } | null; }, {}, void>; "mutation Mutation_deleteUser($input: MutationDeleteUserInput!) {\n deleteUser(input: $input) {\n addressLine1\n addressLine2\n birthDate\n city\n countryCode\n createdAt\n description\n educationGrade\n emailAddress\n employmentStatus\n homePhoneNumber\n id\n isEmailAddressVerified\n maritalStatus\n mobilePhoneNumber\n name\n natalSex\n postalCode\n role\n state\n workPhoneNumber \n }\n}": @@ -24,6 +24,8 @@ declare module 'gql.tada' { TadaDocumentNode<{ signIn: { authenticationToken: string | null; user: { addressLine1: string | null; addressLine2: string | null; birthDate: string | null; city: string | null; countryCode: "at" | "pg" | "ad" | "ae" | "af" | "ag" | "ai" | "al" | "am" | "ao" | "aq" | "ar" | "as" | "au" | "aw" | "ax" | "az" | "ba" | "bb" | "bd" | "be" | "bf" | "bg" | "bh" | "bi" | "bj" | "bl" | "bm" | "bn" | "bo" | "bq" | "br" | "bs" | "bt" | "bv" | "bw" | "by" | "bz" | "ca" | "cc" | "cd" | "cf" | "cg" | "ch" | "ci" | "ck" | "cl" | "cm" | "cn" | "co" | "cr" | "cu" | "cv" | "cw" | "cx" | "cy" | "cz" | "de" | "dj" | "dk" | "dm" | "do" | "dz" | "ec" | "ee" | "eg" | "eh" | "er" | "es" | "et" | "fi" | "fj" | "fk" | "fm" | "fo" | "fr" | "ga" | "gb" | "gd" | "ge" | "gf" | "gg" | "gh" | "gi" | "gl" | "gm" | "gn" | "gp" | "gq" | "gr" | "gs" | "gt" | "gu" | "gw" | "gy" | "hk" | "hm" | "hn" | "hr" | "ht" | "hu" | "id" | "ie" | "il" | "im" | "in" | "io" | "iq" | "ir" | "is" | "it" | "je" | "jm" | "jo" | "jp" | "ke" | "kg" | "kh" | "ki" | "km" | "kn" | "kp" | "kr" | "kw" | "ky" | "kz" | "la" | "lb" | "lc" | "li" | "lk" | "lr" | "ls" | "lt" | "lu" | "lv" | "ly" | "ma" | "mc" | "md" | "me" | "mf" | "mg" | "mh" | "mk" | "ml" | "mm" | "mn" | "mo" | "mp" | "mq" | "mr" | "ms" | "mt" | "mu" | "mv" | "mw" | "mx" | "my" | "mz" | "na" | "nc" | "ne" | "nf" | "ng" | "ni" | "nl" | "no" | "np" | "nr" | "nu" | "nz" | "om" | "pa" | "pe" | "pf" | "ph" | "pk" | "pl" | "pm" | "pn" | "pr" | "ps" | "pt" | "pw" | "py" | "qa" | "re" | "ro" | "rs" | "ru" | "rw" | "sa" | "sb" | "sc" | "sd" | "se" | "sg" | "sh" | "si" | "sj" | "sk" | "sl" | "sm" | "sn" | "so" | "sr" | "ss" | "st" | "sv" | "sx" | "sy" | "sz" | "tc" | "td" | "tf" | "tg" | "th" | "tj" | "tk" | "tl" | "tm" | "tn" | "to" | "tr" | "tt" | "tv" | "tw" | "tz" | "ua" | "ug" | "um" | "us" | "uy" | "uz" | "va" | "vc" | "ve" | "vg" | "vi" | "vn" | "vu" | "wf" | "ws" | "ye" | "yt" | "za" | "zm" | "zw" | null; createdAt: string | null; description: string | null; educationGrade: "kg" | "grade_1" | "grade_2" | "grade_3" | "grade_4" | "grade_5" | "grade_6" | "grade_7" | "grade_8" | "grade_9" | "grade_10" | "grade_11" | "grade_12" | "graduate" | "no_grade" | "pre_kg" | null; emailAddress: string | null; employmentStatus: "full_time" | "part_time" | "unemployed" | null; homePhoneNumber: string | null; id: string; isEmailAddressVerified: boolean | null; maritalStatus: "divorced" | "engaged" | "married" | "seperated" | "single" | "widowed" | null; mobilePhoneNumber: string | null; name: string | null; natalSex: "female" | "intersex" | "male" | null; postalCode: string | null; role: "administrator" | "regular" | null; state: string | null; workPhoneNumber: string | null; } | null; } | null; }, { input: { password: string; emailAddress: string; }; }, void>; "query Query_user($input: QueryUserInput!) {\n user(input: $input) {\n addressLine1\n addressLine2\n birthDate\n city\n countryCode\n createdAt\n description\n educationGrade\n emailAddress\n employmentStatus\n homePhoneNumber\n id\n isEmailAddressVerified\n maritalStatus\n mobilePhoneNumber\n name\n natalSex\n postalCode\n role\n state\n workPhoneNumber\n }\n}": TadaDocumentNode<{ user: { addressLine1: string | null; addressLine2: string | null; birthDate: string | null; city: string | null; countryCode: "at" | "pg" | "ad" | "ae" | "af" | "ag" | "ai" | "al" | "am" | "ao" | "aq" | "ar" | "as" | "au" | "aw" | "ax" | "az" | "ba" | "bb" | "bd" | "be" | "bf" | "bg" | "bh" | "bi" | "bj" | "bl" | "bm" | "bn" | "bo" | "bq" | "br" | "bs" | "bt" | "bv" | "bw" | "by" | "bz" | "ca" | "cc" | "cd" | "cf" | "cg" | "ch" | "ci" | "ck" | "cl" | "cm" | "cn" | "co" | "cr" | "cu" | "cv" | "cw" | "cx" | "cy" | "cz" | "de" | "dj" | "dk" | "dm" | "do" | "dz" | "ec" | "ee" | "eg" | "eh" | "er" | "es" | "et" | "fi" | "fj" | "fk" | "fm" | "fo" | "fr" | "ga" | "gb" | "gd" | "ge" | "gf" | "gg" | "gh" | "gi" | "gl" | "gm" | "gn" | "gp" | "gq" | "gr" | "gs" | "gt" | "gu" | "gw" | "gy" | "hk" | "hm" | "hn" | "hr" | "ht" | "hu" | "id" | "ie" | "il" | "im" | "in" | "io" | "iq" | "ir" | "is" | "it" | "je" | "jm" | "jo" | "jp" | "ke" | "kg" | "kh" | "ki" | "km" | "kn" | "kp" | "kr" | "kw" | "ky" | "kz" | "la" | "lb" | "lc" | "li" | "lk" | "lr" | "ls" | "lt" | "lu" | "lv" | "ly" | "ma" | "mc" | "md" | "me" | "mf" | "mg" | "mh" | "mk" | "ml" | "mm" | "mn" | "mo" | "mp" | "mq" | "mr" | "ms" | "mt" | "mu" | "mv" | "mw" | "mx" | "my" | "mz" | "na" | "nc" | "ne" | "nf" | "ng" | "ni" | "nl" | "no" | "np" | "nr" | "nu" | "nz" | "om" | "pa" | "pe" | "pf" | "ph" | "pk" | "pl" | "pm" | "pn" | "pr" | "ps" | "pt" | "pw" | "py" | "qa" | "re" | "ro" | "rs" | "ru" | "rw" | "sa" | "sb" | "sc" | "sd" | "se" | "sg" | "sh" | "si" | "sj" | "sk" | "sl" | "sm" | "sn" | "so" | "sr" | "ss" | "st" | "sv" | "sx" | "sy" | "sz" | "tc" | "td" | "tf" | "tg" | "th" | "tj" | "tk" | "tl" | "tm" | "tn" | "to" | "tr" | "tt" | "tv" | "tw" | "tz" | "ua" | "ug" | "um" | "us" | "uy" | "uz" | "va" | "vc" | "ve" | "vg" | "vi" | "vn" | "vu" | "wf" | "ws" | "ye" | "yt" | "za" | "zm" | "zw" | null; createdAt: string | null; description: string | null; educationGrade: "kg" | "grade_1" | "grade_2" | "grade_3" | "grade_4" | "grade_5" | "grade_6" | "grade_7" | "grade_8" | "grade_9" | "grade_10" | "grade_11" | "grade_12" | "graduate" | "no_grade" | "pre_kg" | null; emailAddress: string | null; employmentStatus: "full_time" | "part_time" | "unemployed" | null; homePhoneNumber: string | null; id: string; isEmailAddressVerified: boolean | null; maritalStatus: "divorced" | "engaged" | "married" | "seperated" | "single" | "widowed" | null; mobilePhoneNumber: string | null; name: string | null; natalSex: "female" | "intersex" | "male" | null; postalCode: string | null; role: "administrator" | "regular" | null; state: string | null; workPhoneNumber: string | null; } | null; }, { input: { id: string; }; }, void>; + "\n query Query_allUsers(\n $first: Int,\n $after: String,\n $last: Int,\n $before: String,\n $name: String\n ) {\n allUsers(\n first: $first,\n after: $after,\n last: $last,\n before: $before,\n name: $name\n ) {\n pageInfo {\n hasNextPage\n hasPreviousPage\n startCursor\n endCursor\n }\n edges {\n cursor\n node {\n id\n name\n emailAddress\n role\n createdAt\n isEmailAddressVerified\n addressLine1\n addressLine2\n birthDate\n city\n countryCode\n description\n educationGrade\n employmentStatus\n homePhoneNumber\n maritalStatus\n mobilePhoneNumber\n natalSex\n postalCode\n state\n workPhoneNumber\n }\n }\n }\n }\n ": + TadaDocumentNode<{ allUsers: { pageInfo: { hasNextPage: boolean; hasPreviousPage: boolean; startCursor: string | null; endCursor: string | null; }; edges: ({ cursor: string; node: { id: string; name: string | null; emailAddress: string | null; role: "administrator" | "regular" | null; createdAt: string | null; isEmailAddressVerified: boolean | null; addressLine1: string | null; addressLine2: string | null; birthDate: string | null; city: string | null; countryCode: "at" | "pg" | "ad" | "ae" | "af" | "ag" | "ai" | "al" | "am" | "ao" | "aq" | "ar" | "as" | "au" | "aw" | "ax" | "az" | "ba" | "bb" | "bd" | "be" | "bf" | "bg" | "bh" | "bi" | "bj" | "bl" | "bm" | "bn" | "bo" | "bq" | "br" | "bs" | "bt" | "bv" | "bw" | "by" | "bz" | "ca" | "cc" | "cd" | "cf" | "cg" | "ch" | "ci" | "ck" | "cl" | "cm" | "cn" | "co" | "cr" | "cu" | "cv" | "cw" | "cx" | "cy" | "cz" | "de" | "dj" | "dk" | "dm" | "do" | "dz" | "ec" | "ee" | "eg" | "eh" | "er" | "es" | "et" | "fi" | "fj" | "fk" | "fm" | "fo" | "fr" | "ga" | "gb" | "gd" | "ge" | "gf" | "gg" | "gh" | "gi" | "gl" | "gm" | "gn" | "gp" | "gq" | "gr" | "gs" | "gt" | "gu" | "gw" | "gy" | "hk" | "hm" | "hn" | "hr" | "ht" | "hu" | "id" | "ie" | "il" | "im" | "in" | "io" | "iq" | "ir" | "is" | "it" | "je" | "jm" | "jo" | "jp" | "ke" | "kg" | "kh" | "ki" | "km" | "kn" | "kp" | "kr" | "kw" | "ky" | "kz" | "la" | "lb" | "lc" | "li" | "lk" | "lr" | "ls" | "lt" | "lu" | "lv" | "ly" | "ma" | "mc" | "md" | "me" | "mf" | "mg" | "mh" | "mk" | "ml" | "mm" | "mn" | "mo" | "mp" | "mq" | "mr" | "ms" | "mt" | "mu" | "mv" | "mw" | "mx" | "my" | "mz" | "na" | "nc" | "ne" | "nf" | "ng" | "ni" | "nl" | "no" | "np" | "nr" | "nu" | "nz" | "om" | "pa" | "pe" | "pf" | "ph" | "pk" | "pl" | "pm" | "pn" | "pr" | "ps" | "pt" | "pw" | "py" | "qa" | "re" | "ro" | "rs" | "ru" | "rw" | "sa" | "sb" | "sc" | "sd" | "se" | "sg" | "sh" | "si" | "sj" | "sk" | "sl" | "sm" | "sn" | "so" | "sr" | "ss" | "st" | "sv" | "sx" | "sy" | "sz" | "tc" | "td" | "tf" | "tg" | "th" | "tj" | "tk" | "tl" | "tm" | "tn" | "to" | "tr" | "tt" | "tv" | "tw" | "tz" | "ua" | "ug" | "um" | "us" | "uy" | "uz" | "va" | "vc" | "ve" | "vg" | "vi" | "vn" | "vu" | "wf" | "ws" | "ye" | "yt" | "za" | "zm" | "zw" | null; description: string | null; educationGrade: "kg" | "grade_1" | "grade_2" | "grade_3" | "grade_4" | "grade_5" | "grade_6" | "grade_7" | "grade_8" | "grade_9" | "grade_10" | "grade_11" | "grade_12" | "graduate" | "no_grade" | "pre_kg" | null; employmentStatus: "full_time" | "part_time" | "unemployed" | null; homePhoneNumber: string | null; maritalStatus: "divorced" | "engaged" | "married" | "seperated" | "single" | "widowed" | null; mobilePhoneNumber: string | null; natalSex: "female" | "intersex" | "male" | null; postalCode: string | null; state: string | null; workPhoneNumber: string | null; } | null; } | null)[] | null; } | null; }, { name?: string | null | undefined; before?: string | null | undefined; last?: number | null | undefined; after?: string | null | undefined; first?: number | null | undefined; }, void>; "query Query_user_creator($input: QueryUserInput!) {\n user(input: $input) {\n creator {\n addressLine1\n addressLine2\n birthDate\n city\n countryCode\n createdAt\n description\n educationGrade\n emailAddress\n employmentStatus\n homePhoneNumber\n id\n isEmailAddressVerified\n maritalStatus\n mobilePhoneNumber\n name\n natalSex\n postalCode\n role\n state\n workPhoneNumber \n }\n }\n}": TadaDocumentNode<{ user: { creator: { addressLine1: string | null; addressLine2: string | null; birthDate: string | null; city: string | null; countryCode: "at" | "pg" | "ad" | "ae" | "af" | "ag" | "ai" | "al" | "am" | "ao" | "aq" | "ar" | "as" | "au" | "aw" | "ax" | "az" | "ba" | "bb" | "bd" | "be" | "bf" | "bg" | "bh" | "bi" | "bj" | "bl" | "bm" | "bn" | "bo" | "bq" | "br" | "bs" | "bt" | "bv" | "bw" | "by" | "bz" | "ca" | "cc" | "cd" | "cf" | "cg" | "ch" | "ci" | "ck" | "cl" | "cm" | "cn" | "co" | "cr" | "cu" | "cv" | "cw" | "cx" | "cy" | "cz" | "de" | "dj" | "dk" | "dm" | "do" | "dz" | "ec" | "ee" | "eg" | "eh" | "er" | "es" | "et" | "fi" | "fj" | "fk" | "fm" | "fo" | "fr" | "ga" | "gb" | "gd" | "ge" | "gf" | "gg" | "gh" | "gi" | "gl" | "gm" | "gn" | "gp" | "gq" | "gr" | "gs" | "gt" | "gu" | "gw" | "gy" | "hk" | "hm" | "hn" | "hr" | "ht" | "hu" | "id" | "ie" | "il" | "im" | "in" | "io" | "iq" | "ir" | "is" | "it" | "je" | "jm" | "jo" | "jp" | "ke" | "kg" | "kh" | "ki" | "km" | "kn" | "kp" | "kr" | "kw" | "ky" | "kz" | "la" | "lb" | "lc" | "li" | "lk" | "lr" | "ls" | "lt" | "lu" | "lv" | "ly" | "ma" | "mc" | "md" | "me" | "mf" | "mg" | "mh" | "mk" | "ml" | "mm" | "mn" | "mo" | "mp" | "mq" | "mr" | "ms" | "mt" | "mu" | "mv" | "mw" | "mx" | "my" | "mz" | "na" | "nc" | "ne" | "nf" | "ng" | "ni" | "nl" | "no" | "np" | "nr" | "nu" | "nz" | "om" | "pa" | "pe" | "pf" | "ph" | "pk" | "pl" | "pm" | "pn" | "pr" | "ps" | "pt" | "pw" | "py" | "qa" | "re" | "ro" | "rs" | "ru" | "rw" | "sa" | "sb" | "sc" | "sd" | "se" | "sg" | "sh" | "si" | "sj" | "sk" | "sl" | "sm" | "sn" | "so" | "sr" | "ss" | "st" | "sv" | "sx" | "sy" | "sz" | "tc" | "td" | "tf" | "tg" | "th" | "tj" | "tk" | "tl" | "tm" | "tn" | "to" | "tr" | "tt" | "tv" | "tw" | "tz" | "ua" | "ug" | "um" | "us" | "uy" | "uz" | "va" | "vc" | "ve" | "vg" | "vi" | "vn" | "vu" | "wf" | "ws" | "ye" | "yt" | "za" | "zm" | "zw" | null; createdAt: string | null; description: string | null; educationGrade: "kg" | "grade_1" | "grade_2" | "grade_3" | "grade_4" | "grade_5" | "grade_6" | "grade_7" | "grade_8" | "grade_9" | "grade_10" | "grade_11" | "grade_12" | "graduate" | "no_grade" | "pre_kg" | null; emailAddress: string | null; employmentStatus: "full_time" | "part_time" | "unemployed" | null; homePhoneNumber: string | null; id: string; isEmailAddressVerified: boolean | null; maritalStatus: "divorced" | "engaged" | "married" | "seperated" | "single" | "widowed" | null; mobilePhoneNumber: string | null; name: string | null; natalSex: "female" | "intersex" | "male" | null; postalCode: string | null; role: "administrator" | "regular" | null; state: string | null; workPhoneNumber: string | null; } | null; } | null; }, { input: { id: string; }; }, void>; "query Query_user_updatedAt($input: QueryUserInput!) {\n user(input: $input) {\n updatedAt\n }\n}": @@ -44,11 +46,29 @@ declare module 'gql.tada' { TadaDocumentNode<{ deleteOrganization: { id: string; name: string | null; countryCode: "at" | "pg" | "ad" | "ae" | "af" | "ag" | "ai" | "al" | "am" | "ao" | "aq" | "ar" | "as" | "au" | "aw" | "ax" | "az" | "ba" | "bb" | "bd" | "be" | "bf" | "bg" | "bh" | "bi" | "bj" | "bl" | "bm" | "bn" | "bo" | "bq" | "br" | "bs" | "bt" | "bv" | "bw" | "by" | "bz" | "ca" | "cc" | "cd" | "cf" | "cg" | "ch" | "ci" | "ck" | "cl" | "cm" | "cn" | "co" | "cr" | "cu" | "cv" | "cw" | "cx" | "cy" | "cz" | "de" | "dj" | "dk" | "dm" | "do" | "dz" | "ec" | "ee" | "eg" | "eh" | "er" | "es" | "et" | "fi" | "fj" | "fk" | "fm" | "fo" | "fr" | "ga" | "gb" | "gd" | "ge" | "gf" | "gg" | "gh" | "gi" | "gl" | "gm" | "gn" | "gp" | "gq" | "gr" | "gs" | "gt" | "gu" | "gw" | "gy" | "hk" | "hm" | "hn" | "hr" | "ht" | "hu" | "id" | "ie" | "il" | "im" | "in" | "io" | "iq" | "ir" | "is" | "it" | "je" | "jm" | "jo" | "jp" | "ke" | "kg" | "kh" | "ki" | "km" | "kn" | "kp" | "kr" | "kw" | "ky" | "kz" | "la" | "lb" | "lc" | "li" | "lk" | "lr" | "ls" | "lt" | "lu" | "lv" | "ly" | "ma" | "mc" | "md" | "me" | "mf" | "mg" | "mh" | "mk" | "ml" | "mm" | "mn" | "mo" | "mp" | "mq" | "mr" | "ms" | "mt" | "mu" | "mv" | "mw" | "mx" | "my" | "mz" | "na" | "nc" | "ne" | "nf" | "ng" | "ni" | "nl" | "no" | "np" | "nr" | "nu" | "nz" | "om" | "pa" | "pe" | "pf" | "ph" | "pk" | "pl" | "pm" | "pn" | "pr" | "ps" | "pt" | "pw" | "py" | "qa" | "re" | "ro" | "rs" | "ru" | "rw" | "sa" | "sb" | "sc" | "sd" | "se" | "sg" | "sh" | "si" | "sj" | "sk" | "sl" | "sm" | "sn" | "so" | "sr" | "ss" | "st" | "sv" | "sx" | "sy" | "sz" | "tc" | "td" | "tf" | "tg" | "th" | "tj" | "tk" | "tl" | "tm" | "tn" | "to" | "tr" | "tt" | "tv" | "tw" | "tz" | "ua" | "ug" | "um" | "us" | "uy" | "uz" | "va" | "vc" | "ve" | "vg" | "vi" | "vn" | "vu" | "wf" | "ws" | "ye" | "yt" | "za" | "zm" | "zw" | null; } | null; }, { input: { id: string; }; }, void>; "mutation Mutation_deleteOrganizationMembership($input: MutationDeleteOrganizationMembershipInput!) {\n deleteOrganizationMembership(input: $input) {\n id\n name\n countryCode\n }\n}": TadaDocumentNode<{ deleteOrganizationMembership: { id: string; name: string | null; countryCode: "at" | "pg" | "ad" | "ae" | "af" | "ag" | "ai" | "al" | "am" | "ao" | "aq" | "ar" | "as" | "au" | "aw" | "ax" | "az" | "ba" | "bb" | "bd" | "be" | "bf" | "bg" | "bh" | "bi" | "bj" | "bl" | "bm" | "bn" | "bo" | "bq" | "br" | "bs" | "bt" | "bv" | "bw" | "by" | "bz" | "ca" | "cc" | "cd" | "cf" | "cg" | "ch" | "ci" | "ck" | "cl" | "cm" | "cn" | "co" | "cr" | "cu" | "cv" | "cw" | "cx" | "cy" | "cz" | "de" | "dj" | "dk" | "dm" | "do" | "dz" | "ec" | "ee" | "eg" | "eh" | "er" | "es" | "et" | "fi" | "fj" | "fk" | "fm" | "fo" | "fr" | "ga" | "gb" | "gd" | "ge" | "gf" | "gg" | "gh" | "gi" | "gl" | "gm" | "gn" | "gp" | "gq" | "gr" | "gs" | "gt" | "gu" | "gw" | "gy" | "hk" | "hm" | "hn" | "hr" | "ht" | "hu" | "id" | "ie" | "il" | "im" | "in" | "io" | "iq" | "ir" | "is" | "it" | "je" | "jm" | "jo" | "jp" | "ke" | "kg" | "kh" | "ki" | "km" | "kn" | "kp" | "kr" | "kw" | "ky" | "kz" | "la" | "lb" | "lc" | "li" | "lk" | "lr" | "ls" | "lt" | "lu" | "lv" | "ly" | "ma" | "mc" | "md" | "me" | "mf" | "mg" | "mh" | "mk" | "ml" | "mm" | "mn" | "mo" | "mp" | "mq" | "mr" | "ms" | "mt" | "mu" | "mv" | "mw" | "mx" | "my" | "mz" | "na" | "nc" | "ne" | "nf" | "ng" | "ni" | "nl" | "no" | "np" | "nr" | "nu" | "nz" | "om" | "pa" | "pe" | "pf" | "ph" | "pk" | "pl" | "pm" | "pn" | "pr" | "ps" | "pt" | "pw" | "py" | "qa" | "re" | "ro" | "rs" | "ru" | "rw" | "sa" | "sb" | "sc" | "sd" | "se" | "sg" | "sh" | "si" | "sj" | "sk" | "sl" | "sm" | "sn" | "so" | "sr" | "ss" | "st" | "sv" | "sx" | "sy" | "sz" | "tc" | "td" | "tf" | "tg" | "th" | "tj" | "tk" | "tl" | "tm" | "tn" | "to" | "tr" | "tt" | "tv" | "tw" | "tz" | "ua" | "ug" | "um" | "us" | "uy" | "uz" | "va" | "vc" | "ve" | "vg" | "vi" | "vn" | "vu" | "wf" | "ws" | "ye" | "yt" | "za" | "zm" | "zw" | null; } | null; }, { input: { organizationId: string; memberId: string; }; }, void>; + "query Query_post($input: QueryPostInput!) {\n post(input: $input) {\n id\n organization {\n countryCode\n }\n }\n}": + TadaDocumentNode<{ post: { id: string; organization: { countryCode: "at" | "pg" | "ad" | "ae" | "af" | "ag" | "ai" | "al" | "am" | "ao" | "aq" | "ar" | "as" | "au" | "aw" | "ax" | "az" | "ba" | "bb" | "bd" | "be" | "bf" | "bg" | "bh" | "bi" | "bj" | "bl" | "bm" | "bn" | "bo" | "bq" | "br" | "bs" | "bt" | "bv" | "bw" | "by" | "bz" | "ca" | "cc" | "cd" | "cf" | "cg" | "ch" | "ci" | "ck" | "cl" | "cm" | "cn" | "co" | "cr" | "cu" | "cv" | "cw" | "cx" | "cy" | "cz" | "de" | "dj" | "dk" | "dm" | "do" | "dz" | "ec" | "ee" | "eg" | "eh" | "er" | "es" | "et" | "fi" | "fj" | "fk" | "fm" | "fo" | "fr" | "ga" | "gb" | "gd" | "ge" | "gf" | "gg" | "gh" | "gi" | "gl" | "gm" | "gn" | "gp" | "gq" | "gr" | "gs" | "gt" | "gu" | "gw" | "gy" | "hk" | "hm" | "hn" | "hr" | "ht" | "hu" | "id" | "ie" | "il" | "im" | "in" | "io" | "iq" | "ir" | "is" | "it" | "je" | "jm" | "jo" | "jp" | "ke" | "kg" | "kh" | "ki" | "km" | "kn" | "kp" | "kr" | "kw" | "ky" | "kz" | "la" | "lb" | "lc" | "li" | "lk" | "lr" | "ls" | "lt" | "lu" | "lv" | "ly" | "ma" | "mc" | "md" | "me" | "mf" | "mg" | "mh" | "mk" | "ml" | "mm" | "mn" | "mo" | "mp" | "mq" | "mr" | "ms" | "mt" | "mu" | "mv" | "mw" | "mx" | "my" | "mz" | "na" | "nc" | "ne" | "nf" | "ng" | "ni" | "nl" | "no" | "np" | "nr" | "nu" | "nz" | "om" | "pa" | "pe" | "pf" | "ph" | "pk" | "pl" | "pm" | "pn" | "pr" | "ps" | "pt" | "pw" | "py" | "qa" | "re" | "ro" | "rs" | "ru" | "rw" | "sa" | "sb" | "sc" | "sd" | "se" | "sg" | "sh" | "si" | "sj" | "sk" | "sl" | "sm" | "sn" | "so" | "sr" | "ss" | "st" | "sv" | "sx" | "sy" | "sz" | "tc" | "td" | "tf" | "tg" | "th" | "tj" | "tk" | "tl" | "tm" | "tn" | "to" | "tr" | "tt" | "tv" | "tw" | "tz" | "ua" | "ug" | "um" | "us" | "uy" | "uz" | "va" | "vc" | "ve" | "vg" | "vi" | "vn" | "vu" | "wf" | "ws" | "ye" | "yt" | "za" | "zm" | "zw" | null; } | null; } | null; }, { input: { id: string; }; }, void>; + "query Query_event($input: QueryEventInput!) {\n event(input: $input) {\n id\n name\n description\n startAt\n endAt\n creator {\n id\n name\n }\n organization {\n id\n countryCode\n }\n }\n}": + TadaDocumentNode<{ event: { id: string; name: string | null; description: string | null; startAt: string | null; endAt: string | null; creator: { id: string; name: string | null; } | null; organization: { id: string; countryCode: "at" | "pg" | "ad" | "ae" | "af" | "ag" | "ai" | "al" | "am" | "ao" | "aq" | "ar" | "as" | "au" | "aw" | "ax" | "az" | "ba" | "bb" | "bd" | "be" | "bf" | "bg" | "bh" | "bi" | "bj" | "bl" | "bm" | "bn" | "bo" | "bq" | "br" | "bs" | "bt" | "bv" | "bw" | "by" | "bz" | "ca" | "cc" | "cd" | "cf" | "cg" | "ch" | "ci" | "ck" | "cl" | "cm" | "cn" | "co" | "cr" | "cu" | "cv" | "cw" | "cx" | "cy" | "cz" | "de" | "dj" | "dk" | "dm" | "do" | "dz" | "ec" | "ee" | "eg" | "eh" | "er" | "es" | "et" | "fi" | "fj" | "fk" | "fm" | "fo" | "fr" | "ga" | "gb" | "gd" | "ge" | "gf" | "gg" | "gh" | "gi" | "gl" | "gm" | "gn" | "gp" | "gq" | "gr" | "gs" | "gt" | "gu" | "gw" | "gy" | "hk" | "hm" | "hn" | "hr" | "ht" | "hu" | "id" | "ie" | "il" | "im" | "in" | "io" | "iq" | "ir" | "is" | "it" | "je" | "jm" | "jo" | "jp" | "ke" | "kg" | "kh" | "ki" | "km" | "kn" | "kp" | "kr" | "kw" | "ky" | "kz" | "la" | "lb" | "lc" | "li" | "lk" | "lr" | "ls" | "lt" | "lu" | "lv" | "ly" | "ma" | "mc" | "md" | "me" | "mf" | "mg" | "mh" | "mk" | "ml" | "mm" | "mn" | "mo" | "mp" | "mq" | "mr" | "ms" | "mt" | "mu" | "mv" | "mw" | "mx" | "my" | "mz" | "na" | "nc" | "ne" | "nf" | "ng" | "ni" | "nl" | "no" | "np" | "nr" | "nu" | "nz" | "om" | "pa" | "pe" | "pf" | "ph" | "pk" | "pl" | "pm" | "pn" | "pr" | "ps" | "pt" | "pw" | "py" | "qa" | "re" | "ro" | "rs" | "ru" | "rw" | "sa" | "sb" | "sc" | "sd" | "se" | "sg" | "sh" | "si" | "sj" | "sk" | "sl" | "sm" | "sn" | "so" | "sr" | "ss" | "st" | "sv" | "sx" | "sy" | "sz" | "tc" | "td" | "tf" | "tg" | "th" | "tj" | "tk" | "tl" | "tm" | "tn" | "to" | "tr" | "tt" | "tv" | "tw" | "tz" | "ua" | "ug" | "um" | "us" | "uy" | "uz" | "va" | "vc" | "ve" | "vg" | "vi" | "vn" | "vu" | "wf" | "ws" | "ye" | "yt" | "za" | "zm" | "zw" | null; } | null; } | null; }, { input: { id: string; }; }, void>; + "mutation Mutation_createEvent($input: MutationCreateEventInput!) {\n createEvent(input: $input) {\n id\n name\n description\n startAt\n endAt\n createdAt\n creator{\n id\n name\n }\n organization {\n id\n countryCode\n }\n }\n}": + TadaDocumentNode<{ createEvent: { id: string; name: string | null; description: string | null; startAt: string | null; endAt: string | null; createdAt: string | null; creator: { id: string; name: string | null; } | null; organization: { id: string; countryCode: "at" | "pg" | "ad" | "ae" | "af" | "ag" | "ai" | "al" | "am" | "ao" | "aq" | "ar" | "as" | "au" | "aw" | "ax" | "az" | "ba" | "bb" | "bd" | "be" | "bf" | "bg" | "bh" | "bi" | "bj" | "bl" | "bm" | "bn" | "bo" | "bq" | "br" | "bs" | "bt" | "bv" | "bw" | "by" | "bz" | "ca" | "cc" | "cd" | "cf" | "cg" | "ch" | "ci" | "ck" | "cl" | "cm" | "cn" | "co" | "cr" | "cu" | "cv" | "cw" | "cx" | "cy" | "cz" | "de" | "dj" | "dk" | "dm" | "do" | "dz" | "ec" | "ee" | "eg" | "eh" | "er" | "es" | "et" | "fi" | "fj" | "fk" | "fm" | "fo" | "fr" | "ga" | "gb" | "gd" | "ge" | "gf" | "gg" | "gh" | "gi" | "gl" | "gm" | "gn" | "gp" | "gq" | "gr" | "gs" | "gt" | "gu" | "gw" | "gy" | "hk" | "hm" | "hn" | "hr" | "ht" | "hu" | "id" | "ie" | "il" | "im" | "in" | "io" | "iq" | "ir" | "is" | "it" | "je" | "jm" | "jo" | "jp" | "ke" | "kg" | "kh" | "ki" | "km" | "kn" | "kp" | "kr" | "kw" | "ky" | "kz" | "la" | "lb" | "lc" | "li" | "lk" | "lr" | "ls" | "lt" | "lu" | "lv" | "ly" | "ma" | "mc" | "md" | "me" | "mf" | "mg" | "mh" | "mk" | "ml" | "mm" | "mn" | "mo" | "mp" | "mq" | "mr" | "ms" | "mt" | "mu" | "mv" | "mw" | "mx" | "my" | "mz" | "na" | "nc" | "ne" | "nf" | "ng" | "ni" | "nl" | "no" | "np" | "nr" | "nu" | "nz" | "om" | "pa" | "pe" | "pf" | "ph" | "pk" | "pl" | "pm" | "pn" | "pr" | "ps" | "pt" | "pw" | "py" | "qa" | "re" | "ro" | "rs" | "ru" | "rw" | "sa" | "sb" | "sc" | "sd" | "se" | "sg" | "sh" | "si" | "sj" | "sk" | "sl" | "sm" | "sn" | "so" | "sr" | "ss" | "st" | "sv" | "sx" | "sy" | "sz" | "tc" | "td" | "tf" | "tg" | "th" | "tj" | "tk" | "tl" | "tm" | "tn" | "to" | "tr" | "tt" | "tv" | "tw" | "tz" | "ua" | "ug" | "um" | "us" | "uy" | "uz" | "va" | "vc" | "ve" | "vg" | "vi" | "vn" | "vu" | "wf" | "ws" | "ye" | "yt" | "za" | "zm" | "zw" | null; } | null; } | null; }, { input: { startAt: string; organizationId: string; name: string; endAt: string; description?: string | null | undefined; attachments?: unknown[] | null | undefined; }; }, void>; + "mutation Mutation_deleteEvent($input: MutationDeleteEventInput!) {\n deleteEvent(input: $input) {\n id\n }\n}": + TadaDocumentNode<{ deleteEvent: { id: string; } | null; }, { input: { id: string; }; }, void>; + "mutation Mutation_updateEvent($input: MutationUpdateEventInput!) {\n updateEvent(input: $input) {\n id\n name\n description\n startAt\n endAt\n updatedAt\n organization {\n id\n countryCode\n }\n }\n}": + TadaDocumentNode<{ updateEvent: { id: string; name: string | null; description: string | null; startAt: string | null; endAt: string | null; updatedAt: string | null; organization: { id: string; countryCode: "at" | "pg" | "ad" | "ae" | "af" | "ag" | "ai" | "al" | "am" | "ao" | "aq" | "ar" | "as" | "au" | "aw" | "ax" | "az" | "ba" | "bb" | "bd" | "be" | "bf" | "bg" | "bh" | "bi" | "bj" | "bl" | "bm" | "bn" | "bo" | "bq" | "br" | "bs" | "bt" | "bv" | "bw" | "by" | "bz" | "ca" | "cc" | "cd" | "cf" | "cg" | "ch" | "ci" | "ck" | "cl" | "cm" | "cn" | "co" | "cr" | "cu" | "cv" | "cw" | "cx" | "cy" | "cz" | "de" | "dj" | "dk" | "dm" | "do" | "dz" | "ec" | "ee" | "eg" | "eh" | "er" | "es" | "et" | "fi" | "fj" | "fk" | "fm" | "fo" | "fr" | "ga" | "gb" | "gd" | "ge" | "gf" | "gg" | "gh" | "gi" | "gl" | "gm" | "gn" | "gp" | "gq" | "gr" | "gs" | "gt" | "gu" | "gw" | "gy" | "hk" | "hm" | "hn" | "hr" | "ht" | "hu" | "id" | "ie" | "il" | "im" | "in" | "io" | "iq" | "ir" | "is" | "it" | "je" | "jm" | "jo" | "jp" | "ke" | "kg" | "kh" | "ki" | "km" | "kn" | "kp" | "kr" | "kw" | "ky" | "kz" | "la" | "lb" | "lc" | "li" | "lk" | "lr" | "ls" | "lt" | "lu" | "lv" | "ly" | "ma" | "mc" | "md" | "me" | "mf" | "mg" | "mh" | "mk" | "ml" | "mm" | "mn" | "mo" | "mp" | "mq" | "mr" | "ms" | "mt" | "mu" | "mv" | "mw" | "mx" | "my" | "mz" | "na" | "nc" | "ne" | "nf" | "ng" | "ni" | "nl" | "no" | "np" | "nr" | "nu" | "nz" | "om" | "pa" | "pe" | "pf" | "ph" | "pk" | "pl" | "pm" | "pn" | "pr" | "ps" | "pt" | "pw" | "py" | "qa" | "re" | "ro" | "rs" | "ru" | "rw" | "sa" | "sb" | "sc" | "sd" | "se" | "sg" | "sh" | "si" | "sj" | "sk" | "sl" | "sm" | "sn" | "so" | "sr" | "ss" | "st" | "sv" | "sx" | "sy" | "sz" | "tc" | "td" | "tf" | "tg" | "th" | "tj" | "tk" | "tl" | "tm" | "tn" | "to" | "tr" | "tt" | "tv" | "tw" | "tz" | "ua" | "ug" | "um" | "us" | "uy" | "uz" | "va" | "vc" | "ve" | "vg" | "vi" | "vn" | "vu" | "wf" | "ws" | "ye" | "yt" | "za" | "zm" | "zw" | null; } | null; } | null; }, { input: { startAt?: string | null | undefined; name?: string | null | undefined; id: string; endAt?: string | null | undefined; description?: string | null | undefined; }; }, void>; "\n query tag($input:QueryTagInput!) {\n tag(input: $input) {\n id\n name\n organization {\n id\n }\n createdAt\n }\n}": TadaDocumentNode<{ tag: { id: string; name: string | null; organization: { id: string; } | null; createdAt: string | null; } | null; }, { input: { id: string; }; }, void>; "\n mutation CreateTag($input:MutationCreateTagInput!) {\n createTag(input: $input) {\n id\n name\n createdAt\n organization{\n id\n name\n createdAt\n\n }\n }\n }": TadaDocumentNode<{ createTag: { id: string; name: string | null; createdAt: string | null; organization: { id: string; name: string | null; createdAt: string | null; } | null; } | null; }, { input: { organizationId: string; name: string; folderId?: string | null | undefined; }; }, void>; "\n query Organization($input: QueryOrganizationInput!, $first: Int!) {\n organization(input: $input) {\n id\n name\n members(first: $first) {\n edges {\n node {\n id\n name\n }\n }\n }\n }\n }\n ": TadaDocumentNode<{ organization: { id: string; name: string | null; members: { edges: ({ node: { id: string; name: string | null; } | null; } | null)[] | null; } | null; } | null; }, { first: number; input: { id: string; }; }, void>; + "query Query_agendaItem($input: QueryAgendaItemInput!) {\n agendaItem(input: $input) {\n id\n name\n description\n duration\n key\n type\n }\n}": + TadaDocumentNode<{ agendaItem: { id: string; name: string | null; description: string | null; duration: string | null; key: string | null; type: "general" | "note" | "scripture" | "song" | null; } | null; }, { input: { id: string; }; }, void>; + "\n mutation Mutation_createAgendaFolder($input: MutationCreateAgendaFolderInput!) {\n createAgendaFolder(input: $input) {\n id\n name\n event {\n id\n }\n }\n }\n": + TadaDocumentNode<{ createAgendaFolder: { id: string; name: string | null; event: { id: string; } | null; } | null; }, { input: { parentFolderId?: string | null | undefined; name: string; isAgendaItemFolder: boolean; eventId: string; }; }, void>; + "\n mutation Mutation_createAgendaItem($input: MutationCreateAgendaItemInput!) {\n createAgendaItem(input: $input) {\n id\n name\n description\n duration\n type\n }\n }\n": + TadaDocumentNode<{ createAgendaItem: { id: string; name: string | null; description: string | null; duration: string | null; type: "general" | "note" | "scripture" | "song" | null; } | null; }, { input: { type: "general" | "note" | "scripture" | "song"; name: string; key?: string | null | undefined; folderId: string; duration?: string | null | undefined; description?: string | null | undefined; }; }, void>; + "\n mutation Mutation_deleteAgendaItem($input: MutationDeleteAgendaItemInput!) {\n deleteAgendaItem(input: $input) {\n id\n name\n }\n }\n": + TadaDocumentNode<{ deleteAgendaItem: { id: string; name: string | null; } | null; }, { input: { id: string; }; }, void>; } } diff --git a/test/routes/graphql/gql.tada.d.ts b/test/routes/graphql/gql.tada.d.ts index af91a29bdf1..e84b974289f 100644 --- a/test/routes/graphql/gql.tada.d.ts +++ b/test/routes/graphql/gql.tada.d.ts @@ -149,10 +149,12 @@ export type introspection_types = { 'PostUpVotersConnection': { kind: 'OBJECT'; name: 'PostUpVotersConnection'; fields: { 'edges': { name: 'edges'; type: { kind: 'LIST'; name: never; ofType: { kind: 'OBJECT'; name: 'PostUpVotersConnectionEdge'; ofType: null; }; } }; 'pageInfo': { name: 'pageInfo'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'OBJECT'; name: 'PageInfo'; ofType: null; }; } }; }; }; 'PostUpVotersConnectionEdge': { kind: 'OBJECT'; name: 'PostUpVotersConnectionEdge'; fields: { 'cursor': { name: 'cursor'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'String'; ofType: null; }; } }; 'node': { name: 'node'; type: { kind: 'OBJECT'; name: 'User'; ofType: null; } }; }; }; 'PostVoteType': { name: 'PostVoteType'; enumValues: 'down_vote' | 'up_vote'; }; - 'Query': { kind: 'OBJECT'; name: 'Query'; fields: { 'advertisement': { name: 'advertisement'; type: { kind: 'OBJECT'; name: 'Advertisement'; ofType: null; } }; 'agendaFolder': { name: 'agendaFolder'; type: { kind: 'OBJECT'; name: 'AgendaFolder'; ofType: null; } }; 'agendaItem': { name: 'agendaItem'; type: { kind: 'OBJECT'; name: 'AgendaItem'; ofType: null; } }; 'chat': { name: 'chat'; type: { kind: 'OBJECT'; name: 'Chat'; ofType: null; } }; 'chatMessage': { name: 'chatMessage'; type: { kind: 'OBJECT'; name: 'ChatMessage'; ofType: null; } }; 'comment': { name: 'comment'; type: { kind: 'OBJECT'; name: 'Comment'; ofType: null; } }; 'community': { name: 'community'; type: { kind: 'OBJECT'; name: 'Community'; ofType: null; } }; 'currentUser': { name: 'currentUser'; type: { kind: 'OBJECT'; name: 'User'; ofType: null; } }; 'event': { name: 'event'; type: { kind: 'OBJECT'; name: 'Event'; ofType: null; } }; 'fund': { name: 'fund'; type: { kind: 'OBJECT'; name: 'Fund'; ofType: null; } }; 'fundCampaign': { name: 'fundCampaign'; type: { kind: 'OBJECT'; name: 'FundCampaign'; ofType: null; } }; 'fundCampaignPledge': { name: 'fundCampaignPledge'; type: { kind: 'OBJECT'; name: 'FundCampaignPledge'; ofType: null; } }; 'organization': { name: 'organization'; type: { kind: 'OBJECT'; name: 'Organization'; ofType: null; } }; 'post': { name: 'post'; type: { kind: 'OBJECT'; name: 'Post'; ofType: null; } }; 'renewAuthenticationToken': { name: 'renewAuthenticationToken'; type: { kind: 'SCALAR'; name: 'String'; ofType: null; } }; 'signIn': { name: 'signIn'; type: { kind: 'OBJECT'; name: 'AuthenticationPayload'; ofType: null; } }; 'tag': { name: 'tag'; type: { kind: 'OBJECT'; name: 'Tag'; ofType: null; } }; 'tagFolder': { name: 'tagFolder'; type: { kind: 'OBJECT'; name: 'TagFolder'; ofType: null; } }; 'user': { name: 'user'; type: { kind: 'OBJECT'; name: 'User'; ofType: null; } }; 'venue': { name: 'venue'; type: { kind: 'OBJECT'; name: 'Venue'; ofType: null; } }; }; }; + 'Query': { kind: 'OBJECT'; name: 'Query'; fields: { 'advertisement': { name: 'advertisement'; type: { kind: 'OBJECT'; name: 'Advertisement'; ofType: null; } }; 'agendaFolder': { name: 'agendaFolder'; type: { kind: 'OBJECT'; name: 'AgendaFolder'; ofType: null; } }; 'agendaItem': { name: 'agendaItem'; type: { kind: 'OBJECT'; name: 'AgendaItem'; ofType: null; } }; 'allUsers': { name: 'allUsers'; type: { kind: 'OBJECT'; name: 'QueryAllUsersConnection'; ofType: null; } }; 'chat': { name: 'chat'; type: { kind: 'OBJECT'; name: 'Chat'; ofType: null; } }; 'chatMessage': { name: 'chatMessage'; type: { kind: 'OBJECT'; name: 'ChatMessage'; ofType: null; } }; 'comment': { name: 'comment'; type: { kind: 'OBJECT'; name: 'Comment'; ofType: null; } }; 'community': { name: 'community'; type: { kind: 'OBJECT'; name: 'Community'; ofType: null; } }; 'currentUser': { name: 'currentUser'; type: { kind: 'OBJECT'; name: 'User'; ofType: null; } }; 'event': { name: 'event'; type: { kind: 'OBJECT'; name: 'Event'; ofType: null; } }; 'fund': { name: 'fund'; type: { kind: 'OBJECT'; name: 'Fund'; ofType: null; } }; 'fundCampaign': { name: 'fundCampaign'; type: { kind: 'OBJECT'; name: 'FundCampaign'; ofType: null; } }; 'fundCampaignPledge': { name: 'fundCampaignPledge'; type: { kind: 'OBJECT'; name: 'FundCampaignPledge'; ofType: null; } }; 'organization': { name: 'organization'; type: { kind: 'OBJECT'; name: 'Organization'; ofType: null; } }; 'post': { name: 'post'; type: { kind: 'OBJECT'; name: 'Post'; ofType: null; } }; 'renewAuthenticationToken': { name: 'renewAuthenticationToken'; type: { kind: 'SCALAR'; name: 'String'; ofType: null; } }; 'signIn': { name: 'signIn'; type: { kind: 'OBJECT'; name: 'AuthenticationPayload'; ofType: null; } }; 'tag': { name: 'tag'; type: { kind: 'OBJECT'; name: 'Tag'; ofType: null; } }; 'tagFolder': { name: 'tagFolder'; type: { kind: 'OBJECT'; name: 'TagFolder'; ofType: null; } }; 'user': { name: 'user'; type: { kind: 'OBJECT'; name: 'User'; ofType: null; } }; 'venue': { name: 'venue'; type: { kind: 'OBJECT'; name: 'Venue'; ofType: null; } }; }; }; 'QueryAdvertisementInput': { kind: 'INPUT_OBJECT'; name: 'QueryAdvertisementInput'; isOneOf: false; inputFields: [{ name: 'id'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'String'; ofType: null; }; }; defaultValue: null }]; }; 'QueryAgendaFolderInput': { kind: 'INPUT_OBJECT'; name: 'QueryAgendaFolderInput'; isOneOf: false; inputFields: [{ name: 'id'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'String'; ofType: null; }; }; defaultValue: null }]; }; 'QueryAgendaItemInput': { kind: 'INPUT_OBJECT'; name: 'QueryAgendaItemInput'; isOneOf: false; inputFields: [{ name: 'id'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'String'; ofType: null; }; }; defaultValue: null }]; }; + 'QueryAllUsersConnection': { kind: 'OBJECT'; name: 'QueryAllUsersConnection'; fields: { 'edges': { name: 'edges'; type: { kind: 'LIST'; name: never; ofType: { kind: 'OBJECT'; name: 'QueryAllUsersConnectionEdge'; ofType: null; }; } }; 'pageInfo': { name: 'pageInfo'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'OBJECT'; name: 'PageInfo'; ofType: null; }; } }; }; }; + 'QueryAllUsersConnectionEdge': { kind: 'OBJECT'; name: 'QueryAllUsersConnectionEdge'; fields: { 'cursor': { name: 'cursor'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'String'; ofType: null; }; } }; 'node': { name: 'node'; type: { kind: 'OBJECT'; name: 'User'; ofType: null; } }; }; }; 'QueryChatInput': { kind: 'INPUT_OBJECT'; name: 'QueryChatInput'; isOneOf: false; inputFields: [{ name: 'id'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'String'; ofType: null; }; }; defaultValue: null }]; }; 'QueryChatMessageInput': { kind: 'INPUT_OBJECT'; name: 'QueryChatMessageInput'; isOneOf: false; inputFields: [{ name: 'id'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'String'; ofType: null; }; }; defaultValue: null }]; }; 'QueryCommentInput': { kind: 'INPUT_OBJECT'; name: 'QueryCommentInput'; isOneOf: false; inputFields: [{ name: 'id'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'String'; ofType: null; }; }; defaultValue: null }]; }; diff --git a/test/scripts/dbManagement/addSampleData.test.ts b/test/scripts/dbManagement/addSampleData.test.ts new file mode 100644 index 00000000000..bfbf2521bc3 --- /dev/null +++ b/test/scripts/dbManagement/addSampleData.test.ts @@ -0,0 +1,89 @@ +import * as mainModule from "scripts/dbManagement/addSampleData"; +import * as helpers from "scripts/dbManagement/helpers"; +import { beforeEach, expect, suite, test, vi } from "vitest"; + +suite("addSampleData main function tests", () => { + beforeEach(async () => { + vi.resetModules(); + }); + + test("should execute all operations successfully", async () => { + // Arrange: simulate successful operations. + const pingDBSpy = vi.spyOn(helpers, "pingDB").mockResolvedValue(true); + const ensureAdminSpy = vi + .spyOn(helpers, "ensureAdministratorExists") + .mockResolvedValue(true); + const insertCollectionsSpy = vi + .spyOn(helpers, "insertCollections") + .mockResolvedValue(true); + + // Spy on console.log to verify the output messages. + const consoleLogSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + + // Act: call main() + await expect(mainModule.main()).resolves.toBeUndefined(); + + // Assert: verify that each helper was called as expected. + expect(pingDBSpy).toHaveBeenCalled(); + expect(ensureAdminSpy).toHaveBeenCalled(); + expect(insertCollectionsSpy).toHaveBeenCalledWith([ + "users", + "organizations", + "organization_memberships", + "posts", + "post_votes", + "post_attachments", + "comments", + "comment_votes", + ]); + + // Verify that success messages are logged. + expect(consoleLogSpy).toHaveBeenCalledWith( + expect.stringContaining("Database connected successfully"), + ); + expect(consoleLogSpy).toHaveBeenCalledWith( + expect.stringContaining("Administrator setup complete"), + ); + expect(consoleLogSpy).toHaveBeenCalledWith( + expect.stringContaining("Sample Data added to the database"), + ); + }); + + test("should throw an error when pingDB fails", async () => { + // Arrange: simulate failure of pingDB. + const errorMsg = "pingDB error"; + vi.spyOn(helpers, "pingDB").mockRejectedValue(new Error(errorMsg)); + + // Act & Assert: main() should throw an error indicating DB connection failure. + await expect(mainModule.main()).rejects.toThrow( + `Database connection failed: Error: ${errorMsg}`, + ); + }); + + test("should throw an error when ensureAdministratorExists fails", async () => { + // Arrange: pingDB succeeds, but ensureAdministratorExists fails. + vi.spyOn(helpers, "pingDB").mockResolvedValue(true); + const errorMsg = "admin error"; + vi.spyOn(helpers, "ensureAdministratorExists").mockRejectedValue( + new Error(errorMsg), + ); + + // Act & Assert: main() should throw the specific error message. + await expect(mainModule.main()).rejects.toThrow( + "\n\x1b[31mAdministrator access may be lost, try reimporting sample DB to restore access\x1b[0m\n", + ); + }); + + test("should throw an error when insertCollections fails", async () => { + // Arrange: pingDB and ensureAdministratorExists succeed, but insertCollections fails. + vi.spyOn(helpers, "pingDB").mockResolvedValue(true); + vi.spyOn(helpers, "ensureAdministratorExists").mockResolvedValue(true); + const errorMsg = "insert error"; + vi.spyOn(helpers, "insertCollections").mockRejectedValue( + new Error(errorMsg), + ); + + // Act & Assert: main() should throw an error indicating sample data insertion failure. + await expect(mainModule.main()).rejects.toThrow("Error adding sample data"); + }); +}); diff --git a/test/scripts/dbManagement/helpers.test.ts b/test/scripts/dbManagement/helpers.test.ts new file mode 100644 index 00000000000..e69de29bb2d diff --git a/test/scripts/dbManagement/resetDB.test.ts b/test/scripts/dbManagement/resetDB.test.ts new file mode 100644 index 00000000000..4c4ca99e09a --- /dev/null +++ b/test/scripts/dbManagement/resetDB.test.ts @@ -0,0 +1,116 @@ +import * as helpers from "scripts/dbManagement/helpers"; +import * as resetDataModule from "scripts/dbManagement/resetData"; +// tests/dbManagement/resetData.test.ts +import { beforeEach, expect, suite, test, vi } from "vitest"; + +suite("resetData main function tests", () => { + beforeEach(() => { + // Reset all mocks before each test. + vi.resetAllMocks(); + }); + + test("should cancel operation if askUserToContinue returns false", async () => { + // Arrange: simulate the user declining the operation. + vi.spyOn(helpers, "askUserToContinue").mockResolvedValue(false); + const consoleLogSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + + // Act: call main() + await resetDataModule.main(); + + // Assert: verify that "Operation cancelled" is logged. + expect(consoleLogSpy).toHaveBeenCalledWith("Operation cancelled"); + }); + + test("should throw error when pingDB fails", async () => { + // Arrange: simulate user confirming and pingDB failure. + vi.spyOn(helpers, "askUserToContinue").mockResolvedValue(true); + vi.spyOn(helpers, "pingDB").mockRejectedValue(new Error("ping error")); + + // Act & Assert: main() should throw an error indicating DB connection failure. + await expect(resetDataModule.main()).rejects.toThrow( + "Database connection failed: Error: ping error", + ); + }); + + test("should log errors for failing formatDatabase, emptyMinioBucket, and ensureAdministratorExists, but not throw", async () => { + // Arrange: simulate user confirming and pingDB succeeding. + vi.spyOn(helpers, "askUserToContinue").mockResolvedValue(true); + vi.spyOn(helpers, "pingDB").mockResolvedValue(true); + // Simulate failures for subsequent operations. + vi.spyOn(helpers, "formatDatabase").mockRejectedValue( + new Error("format error"), + ); + vi.spyOn(helpers, "emptyMinioBucket").mockRejectedValue( + new Error("minio error"), + ); + vi.spyOn(helpers, "ensureAdministratorExists").mockRejectedValue( + new Error("admin error"), + ); + + const consoleErrorSpy = vi + .spyOn(console, "error") + .mockImplementation(() => {}); + const consoleLogSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + + // Act: call main(). In this case main() should resolve (it doesn't throw after pingDB), + // and the errors should be logged. + await expect(resetDataModule.main()).resolves.toBeUndefined(); + + // Assert: verify that pingDB success was logged. + expect(consoleLogSpy).toHaveBeenCalledWith( + expect.stringContaining("Database connected successfully"), + ); + // Verify errors were logged for formatting, bucket cleanup, and admin creation. + expect(consoleErrorSpy).toHaveBeenCalledWith( + expect.stringContaining("Error: Database formatting failed"), + expect.any(Error), + ); + expect(consoleErrorSpy).toHaveBeenCalledWith( + expect.stringContaining("Rolled back to previous state"), + ); + expect(consoleErrorSpy).toHaveBeenCalledWith( + expect.stringContaining("Preserving administrator access"), + ); + expect(consoleErrorSpy).toHaveBeenCalledWith( + expect.stringContaining("Error: Bucket formatting failed"), + expect.any(Error), + ); + expect(consoleErrorSpy).toHaveBeenCalledWith( + expect.stringContaining("Error: Administrator creation failed"), + expect.any(Error), + ); + expect(consoleErrorSpy).toHaveBeenCalledWith( + expect.stringContaining( + "Administrator access may be lost, try reformatting DB to restore access", + ), + ); + }); + + test("should log success messages when all operations succeed", async () => { + // Arrange: simulate user confirming and all operations succeeding. + vi.spyOn(helpers, "askUserToContinue").mockResolvedValue(true); + vi.spyOn(helpers, "pingDB").mockResolvedValue(true); + vi.spyOn(helpers, "formatDatabase").mockResolvedValue(true); + vi.spyOn(helpers, "emptyMinioBucket").mockResolvedValue(true); + vi.spyOn(helpers, "ensureAdministratorExists").mockResolvedValue(true); + + const consoleLogSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + + // Act: call main() + await expect(resetDataModule.main()).resolves.toBeUndefined(); + + // Assert: verify that all success messages are logged. + expect(consoleLogSpy).toHaveBeenCalledWith( + expect.stringContaining("Database connected successfully"), + ); + expect(consoleLogSpy).toHaveBeenCalledWith( + expect.stringContaining("Database formatted successfully"), + ); + expect(consoleLogSpy).toHaveBeenCalledWith( + expect.stringContaining("Bucket formatted successfully"), + ); + expect(consoleLogSpy).toHaveBeenCalledWith( + expect.stringContaining("Administrator access restored"), + ); + }); +}); diff --git a/test/utilities/defaultGraphQlConnection.test.ts b/test/utilities/defaultGraphQlConnection.test.ts new file mode 100644 index 00000000000..571323e01a3 --- /dev/null +++ b/test/utilities/defaultGraphQlConnection.test.ts @@ -0,0 +1,552 @@ +import { expect, suite, test, vi } from "vitest"; +import { + type ParsedDefaultGraphQLConnectionArguments, + defaultGraphQLConnectionArgumentsSchema, + transformDefaultGraphQLConnectionArguments, + transformToDefaultGraphQLConnection, +} from "../../src/utilities/defaultGraphQLConnection"; + +suite("defaultGraphQLConnection utilities", () => { + suite("defaultGraphQLConnectionArgumentsSchema", () => { + test("returns valid schema output for first/after pattern", () => { + const result = defaultGraphQLConnectionArgumentsSchema.safeParse({ + first: 10, + after: "someCursor", + before: null, + last: null, + }); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toEqual({ + first: 10, + after: "someCursor", + before: undefined, + last: undefined, + }); + } + }); + + test("returns valid schema output for last/before pattern", () => { + const result = defaultGraphQLConnectionArgumentsSchema.safeParse({ + last: 10, + before: "someCursor", + first: null, + after: null, + }); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toEqual({ + last: 10, + before: "someCursor", + first: undefined, + after: undefined, + }); + } + }); + + test("validates minimum value for first", () => { + const result = defaultGraphQLConnectionArgumentsSchema.safeParse({ + first: 0, + }); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues.length).toBeGreaterThan(0); + expect(result.error.issues[0]?.path).toContain("first"); + } + }); + + test("validates maximum value for first", () => { + const result = defaultGraphQLConnectionArgumentsSchema.safeParse({ + first: 33, + }); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0]?.path).toContain("first"); + } + }); + + test("validates minimum value for last", () => { + const result = defaultGraphQLConnectionArgumentsSchema.safeParse({ + last: 0, + }); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0]?.path).toContain("last"); + } + }); + + test("validates maximum value for last", () => { + const result = defaultGraphQLConnectionArgumentsSchema.safeParse({ + last: 33, + }); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0]?.path).toContain("last"); + } + }); + + test("converts null values to undefined", () => { + const result = defaultGraphQLConnectionArgumentsSchema.safeParse({ + first: null, + after: null, + before: null, + last: null, + }); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toEqual({ + first: undefined, + after: undefined, + before: undefined, + last: undefined, + }); + } + }); + }); + + suite("transformDefaultGraphQLConnectionArguments", () => { + test("transforms first/after arguments correctly", () => { + const ctx = { addIssue: vi.fn(), path: [] }; + const result = transformDefaultGraphQLConnectionArguments( + { + first: 10, + after: "someCursor", + before: undefined, + last: undefined, + }, + ctx, + ); + + expect(ctx.addIssue).not.toHaveBeenCalled(); + expect(result).toEqual({ + limit: 11, // first + 1 + isInversed: false, + cursor: "someCursor", + }); + }); + + test("transforms first without after arguments correctly", () => { + const ctx = { addIssue: vi.fn(), path: [] }; + const result = transformDefaultGraphQLConnectionArguments( + { + first: 10, + after: undefined, + before: undefined, + last: undefined, + }, + ctx, + ); + + expect(ctx.addIssue).not.toHaveBeenCalled(); + expect(result).toEqual({ + limit: 11, // first + 1 + isInversed: false, + cursor: undefined, + }); + }); + + test("transforms last/before arguments correctly", () => { + const ctx = { addIssue: vi.fn(), path: [] }; + const result = transformDefaultGraphQLConnectionArguments( + { + last: 10, + before: "someCursor", + first: undefined, + after: undefined, + }, + ctx, + ); + + expect(ctx.addIssue).not.toHaveBeenCalled(); + expect(result).toEqual({ + limit: 11, // last + 1 + isInversed: true, + cursor: "someCursor", + }); + }); + + test("transforms last without before arguments correctly", () => { + const ctx = { addIssue: vi.fn(), path: [] }; + const result = transformDefaultGraphQLConnectionArguments( + { + last: 10, + before: undefined, + first: undefined, + after: undefined, + }, + ctx, + ); + + expect(ctx.addIssue).not.toHaveBeenCalled(); + expect(result).toEqual({ + limit: 11, // last + 1 + isInversed: true, + cursor: undefined, + }); + }); + + test("errors when both first and last are provided", () => { + const ctx = { addIssue: vi.fn(), path: [] }; + const result = transformDefaultGraphQLConnectionArguments( + { + first: 10, + last: 5, + after: undefined, + before: undefined, + }, + ctx, + ); + + expect(ctx.addIssue).toHaveBeenCalledWith( + expect.objectContaining({ + path: ["last"], + }), + ); + expect(result.cursor).toBe(undefined); + }); + + test("errors when first is provided with before", () => { + const ctx = { addIssue: vi.fn(), path: [] }; + const result = transformDefaultGraphQLConnectionArguments( + { + first: 10, + before: "someCursor", + after: undefined, + last: undefined, + }, + ctx, + ); + + expect(ctx.addIssue).toHaveBeenCalledWith( + expect.objectContaining({ + path: ["before"], + }), + ); + expect(result.cursor).toBe(undefined); + }); + + test("errors when last is provided with after", () => { + const ctx = { addIssue: vi.fn(), path: [] }; + const result = transformDefaultGraphQLConnectionArguments( + { + last: 10, + after: "someCursor", + before: undefined, + first: undefined, + }, + ctx, + ); + + expect(ctx.addIssue).toHaveBeenCalledWith( + expect.objectContaining({ + path: ["after"], + }), + ); + expect(result.cursor).toBe(undefined); + }); + + test("errors when neither first nor last is provided", () => { + const ctx = { addIssue: vi.fn(), path: [] }; + const result = transformDefaultGraphQLConnectionArguments( + { + first: undefined, + last: undefined, + after: undefined, + before: undefined, + }, + ctx, + ); + + expect(ctx.addIssue).toHaveBeenCalledTimes(2); + expect(ctx.addIssue).toHaveBeenCalledWith( + expect.objectContaining({ + path: ["first"], + }), + ); + expect(ctx.addIssue).toHaveBeenCalledWith( + expect.objectContaining({ + path: ["last"], + }), + ); + expect(result.cursor).toBe(undefined); + }); + + test("preserves custom arguments", () => { + const ctx = { addIssue: vi.fn(), path: [] }; + const customArgs = { + first: 10, + after: undefined, + before: undefined, + last: undefined, + name: "test", + role: "admin", + }; + const result = transformDefaultGraphQLConnectionArguments( + customArgs, + ctx, + ); + + expect(ctx.addIssue).not.toHaveBeenCalled(); + expect(result).toEqual({ + limit: 11, + isInversed: false, + cursor: undefined, + name: "test", + role: "admin", + }); + }); + }); + + suite("transformToDefaultGraphQLConnection", () => { + // Mock data for testing + const mockUsers = [ + { id: "1", name: "User 1", createdAt: new Date("2025-01-01") }, + { id: "2", name: "User 2", createdAt: new Date("2025-01-02") }, + { id: "3", name: "User 3", createdAt: new Date("2025-01-03") }, + { id: "4", name: "User 4", createdAt: new Date("2025-01-04") }, + { id: "5", name: "User 5", createdAt: new Date("2025-01-05") }, + ]; + + test("transforms forward pagination with hasNextPage=true", () => { + const parsedArgs: ParsedDefaultGraphQLConnectionArguments = { + cursor: undefined, + isInversed: false, + limit: 5, // We provide 5 items, so hasNextPage should be true + }; + + const result = transformToDefaultGraphQLConnection({ + createCursor: (user) => user.id, + createNode: (user) => user, + parsedArgs, + rawNodes: [...mockUsers], + }); + + expect(result.edges).toHaveLength(4); // Should drop the extra node + expect(result.edges[0]?.node.id).toBe("1"); + expect(result.edges[3]?.node.id).toBe("4"); + expect(result.pageInfo.hasNextPage).toBe(true); + expect(result.pageInfo.hasPreviousPage).toBe(false); + expect(result.pageInfo.startCursor).toBe("4"); + expect(result.pageInfo.endCursor).toBe("1"); + }); + + test("transforms forward pagination with hasNextPage=false", () => { + const parsedArgs: ParsedDefaultGraphQLConnectionArguments = { + cursor: undefined, + isInversed: false, + limit: 6, // We provide 5 items, so hasNextPage should be false + }; + + const result = transformToDefaultGraphQLConnection({ + createCursor: (user) => user.id, + createNode: (user) => user, + parsedArgs, + rawNodes: [...mockUsers], + }); + + expect(result.edges).toHaveLength(5); + expect(result.pageInfo.hasNextPage).toBe(false); + expect(result.pageInfo.hasPreviousPage).toBe(false); + expect(result.pageInfo.startCursor).toBe("5"); + expect(result.pageInfo.endCursor).toBe("1"); + }); + + test("transforms forward pagination with cursor (hasPreviousPage=true)", () => { + const parsedArgs: ParsedDefaultGraphQLConnectionArguments = { + cursor: "2", + isInversed: false, + limit: 4, + }; + + // Simulate DB query result after a cursor + const afterCursorUsers = mockUsers.slice(2); + + const result = transformToDefaultGraphQLConnection({ + createCursor: (user) => user.id, + createNode: (user) => user, + parsedArgs, + rawNodes: afterCursorUsers, + }); + + expect(result.edges).toHaveLength(3); + expect(result.pageInfo.hasNextPage).toBe(false); + expect(result.pageInfo.hasPreviousPage).toBe(true); + expect(result.pageInfo.startCursor).toBe("5"); + expect(result.pageInfo.endCursor).toBe("3"); + }); + + test("transforms backward pagination with hasPreviousPage=true", () => { + const parsedArgs: ParsedDefaultGraphQLConnectionArguments = { + cursor: undefined, + isInversed: true, + limit: 5, // We provide 5 items, so hasPreviousPage should be true + }; + + const result = transformToDefaultGraphQLConnection({ + createCursor: (user) => user.id, + createNode: (user) => user, + parsedArgs, + rawNodes: [...mockUsers], + }); + + expect(result.edges).toHaveLength(4); // Should drop the extra node + expect(result.edges[0]?.node.id).toBe("4"); // Backward, so reversed order + expect(result.edges[3]?.node.id).toBe("1"); + expect(result.pageInfo.hasNextPage).toBe(false); + expect(result.pageInfo.hasPreviousPage).toBe(true); + expect(result.pageInfo.startCursor).toBe("1"); + expect(result.pageInfo.endCursor).toBe("4"); + }); + + test("transforms backward pagination with hasPreviousPage=false", () => { + const parsedArgs: ParsedDefaultGraphQLConnectionArguments = { + cursor: undefined, + isInversed: true, + limit: 6, // We provide 5 items, so hasPreviousPage should be false + }; + + const result = transformToDefaultGraphQLConnection({ + createCursor: (user) => user.id, + createNode: (user) => user, + parsedArgs, + rawNodes: [...mockUsers], + }); + + expect(result.edges).toHaveLength(5); + expect(result.pageInfo.hasNextPage).toBe(false); + expect(result.pageInfo.hasPreviousPage).toBe(false); + expect(result.pageInfo.startCursor).toBe("1"); + expect(result.pageInfo.endCursor).toBe("5"); + }); + + test("transforms backward pagination with cursor (hasNextPage=true)", () => { + const parsedArgs: ParsedDefaultGraphQLConnectionArguments = { + cursor: "4", + isInversed: true, + limit: 3, + }; + + // Simulate DB query result before a cursor + const beforeCursorUsers = mockUsers.slice(0, 3); + + const result = transformToDefaultGraphQLConnection({ + createCursor: (user) => user.id, + createNode: (user) => user, + parsedArgs, + rawNodes: beforeCursorUsers, + }); + + expect(result.edges).toHaveLength(2); + expect(result.edges[0]?.node.id).toBe("2"); + expect(result.edges[1]?.node.id).toBe("1"); + expect(result.pageInfo.hasNextPage).toBe(true); + expect(result.pageInfo.hasPreviousPage).toBe(true); + expect(result.pageInfo.startCursor).toBe("1"); + expect(result.pageInfo.endCursor).toBe("2"); + }); + + test("handles empty results", () => { + const parsedArgs: ParsedDefaultGraphQLConnectionArguments = { + cursor: "nonexistent", + isInversed: false, + limit: 10, + }; + + const result = transformToDefaultGraphQLConnection({ + createCursor: (user: { id: string }) => user.id, + createNode: (user) => user, + parsedArgs, + rawNodes: [], + }); + + expect(result.edges).toHaveLength(0); + expect(result.pageInfo.hasNextPage).toBe(false); + expect(result.pageInfo.hasPreviousPage).toBe(true); // Has previous since cursor was provided + expect(result.pageInfo.startCursor).toBe(null); + expect(result.pageInfo.endCursor).toBe(null); + }); + + test("handles empty results with backward pagination", () => { + const parsedArgs: ParsedDefaultGraphQLConnectionArguments = { + cursor: "nonexistent", + isInversed: true, + limit: 10, + }; + + const result = transformToDefaultGraphQLConnection({ + createCursor: (user: { id: string }) => user.id, + createNode: (user) => user, + parsedArgs, + rawNodes: [], + }); + + expect(result.edges).toHaveLength(0); + expect(result.pageInfo.hasNextPage).toBe(true); // Has next since cursor was provided + expect(result.pageInfo.hasPreviousPage).toBe(false); + expect(result.pageInfo.startCursor).toBe(null); + expect(result.pageInfo.endCursor).toBe(null); + }); + + test("supports node transformation", () => { + const parsedArgs: ParsedDefaultGraphQLConnectionArguments = { + cursor: undefined, + isInversed: false, + limit: 3, + }; + + const result = transformToDefaultGraphQLConnection({ + createCursor: (user) => user.id, + // Transform node to include only name + createNode: (user) => ({ name: user.name }), + parsedArgs, + rawNodes: mockUsers.slice(0, 2), + }); + + expect(result.edges).toHaveLength(2); + expect(result.edges[0]?.node).toEqual({ name: "User 1" }); + expect(result.edges[1]?.node).toEqual({ name: "User 2" }); + }); + + test("supports custom cursor generation", () => { + const parsedArgs: ParsedDefaultGraphQLConnectionArguments = { + cursor: undefined, + isInversed: false, + limit: 3, + }; + + const result = transformToDefaultGraphQLConnection({ + // Create base64 cursor from combined id and createdAt + createCursor: (user) => + Buffer.from( + JSON.stringify({ + id: user.id, + createdAt: user.createdAt.toISOString(), + }), + ).toString("base64url"), + createNode: (user) => user, + parsedArgs, + rawNodes: mockUsers.slice(0, 2), + }); + + expect(result.edges).toHaveLength(2); + // Verify cursor is base64 encoded + expect(result.edges[0]?.cursor).toMatch(/^[A-Za-z0-9_-]+$/); + + // Verify we can decode the cursor back + const decodedCursor = JSON.parse( + Buffer.from(result.edges[0]?.cursor || "", "base64url").toString( + "utf-8", + ), + ); + expect(decodedCursor).toHaveProperty("id", "1"); + expect(decodedCursor).toHaveProperty("createdAt"); + }); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index 106064717a2..ce06bef9b21 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -19,5 +19,8 @@ export default defineConfig({ // // https://vitest.dev/config/#teardowntimeout, // teardownTimeout: 10000 + + hookTimeout: 30000, // 30 seconds for hooks + pool: "threads", // for faster test execution and to avoid postgres max-limit error }, });