From 45c9078d00beefd4871aa087871cc19069cc2fb7 Mon Sep 17 00:00:00 2001 From: Naman Mukund Date: Tue, 22 Sep 2026 20:39:35 +0530 Subject: [PATCH 1/2] test(mongo): add complete end-to-end MongoDB integration test suite --- .github/workflows/ci.yml | 10 + package.json | 3 +- test/mongo/mongoIntegration.test.js | 1534 ++++++++++++++++++++++++++ test/mongo/setup.js | 155 +++ test/mongo/testSchema.js | 1574 +++++++++++++++++++++++++++ 5 files changed, 3275 insertions(+), 1 deletion(-) create mode 100644 test/mongo/mongoIntegration.test.js create mode 100644 test/mongo/setup.js create mode 100644 test/mongo/testSchema.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f384c040..80c308d5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,6 +29,16 @@ jobs: --health-timeout 5s --health-retries 5 + mongodb: + image: mongo:5.0 + ports: + - 27017:27017 + options: >- + --health-cmd "mongo --eval 'db.adminCommand(\"ping\")'" + --health-interval 5s + --health-timeout 5s + --health-retries 5 + steps: - name: Checkout Code uses: actions/checkout@v4 diff --git a/package.json b/package.json index 4b5e9e62..988fe1eb 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,8 @@ "lint": "eslint .", "lint:fix": "eslint --fix .", "test": "cross-env APPLICATION=core NODE_ENV=test PORT=3000 mocha ./test --timeout 5000 --recursive --require @babel/register --exit", - "test:postgres": "cross-env APPLICATION=core NODE_ENV=test mocha ./test/postgres --timeout 60000 --recursive --require @babel/register --exit" + "test:postgres": "cross-env APPLICATION=core NODE_ENV=test mocha ./test/postgres --timeout 60000 --recursive --require @babel/register --exit", + "test:mongo": "cross-env APPLICATION=core NODE_ENV=test mocha ./test/mongo --timeout 60000 --recursive --require @babel/register --exit" }, "dependencies": { "@pdf-lib/fontkit": "^1.1.1", diff --git a/test/mongo/mongoIntegration.test.js b/test/mongo/mongoIntegration.test.js new file mode 100644 index 00000000..114279b1 --- /dev/null +++ b/test/mongo/mongoIntegration.test.js @@ -0,0 +1,1534 @@ +/** + * AutoGraphQL — Complete End-to-End MongoDB Integration Test Suite + * + * This test suite validates AutoGraphQL strictly from the perspective of an + * external client/developer. All CRUD operations, relational queries, filtering, + * sorting, pagination, error handling, restart persistence, and schema evolution + * are executed EXCLUSIVELY through generated GraphQL queries and mutations. + * + * MongoDB is treated as an implementation detail behind the GraphQL API. + */ +import assert from 'assert'; +import mongoose from 'mongoose'; +import { + startMongoTestEnvironment, + clearMongoCollections, + closeMongoConnection, + createGraphQLClient, + MONGO_TEST_URI, +} from './setup'; +import { + mongoGraphQLSDL, + mongoGraphQLSDLEvolved, + parseSDLToSchemaDefinitions, +} from './testSchema'; + +describe('AutoGraphQL MongoDB Client End-to-End Integration Suite', function () { + this.timeout(60000); + + let env; + let client; + let models; + let schema; + + before(async () => { + env = await startMongoTestEnvironment(mongoGraphQLSDL); + models = env.models; + schema = env.schema; + client = createGraphQLClient(schema, models); + }); + + after(async () => { + await clearMongoCollections(models); + await closeMongoConnection(); + }); + + beforeEach(async () => { + await clearMongoCollections(models); + }); + + // ========================================================================= + // 1. Schema Generation & Introspection + // ========================================================================= + describe('1. Schema Generation & Introspection', () => { + it('should correctly parse client GraphQL SDL into entity definitions and relations', () => { + const { fieldsSchemas, relations } = parseSDLToSchemaDefinitions(mongoGraphQLSDL); + + assert.ok(fieldsSchemas.MongoCompany, 'MongoCompany must be parsed'); + assert.ok(fieldsSchemas.MongoUser, 'MongoUser must be parsed'); + assert.ok(fieldsSchemas.MongoPost, 'MongoPost must be parsed'); + assert.ok(fieldsSchemas.MongoComment, 'MongoComment must be parsed'); + assert.ok(fieldsSchemas.MongoProduct, 'MongoProduct must be parsed'); + + assert.strictEqual(fieldsSchemas.MongoUser.email.unique, true, 'MongoUser.email must be unique'); + assert.strictEqual(fieldsSchemas.MongoUser.name.required, true, 'MongoUser.name must be required'); + assert.strictEqual(fieldsSchemas.MongoUser.active.default, true, 'MongoUser.active default must be true'); + + const companyUsersRel = relations.find((r) => r.relName === 'MongoCompanyUsers' && r.source === 'MongoCompany'); + assert.ok(companyUsersRel, 'MongoCompanyUsers relation must be extracted from SDL'); + assert.strictEqual(companyUsersRel.isList, true, 'Company.users must be a list relation'); + }); + + it('should expose complete GraphQL schema via introspection query', async () => { + const introspectionQuery = ` + query IntrospectBackend { + __schema { + queryType { name } + mutationType { name } + types { + name + kind + } + } + } + `; + + const res = await client.query(introspectionQuery); + assert.ok(!res.errors, `Introspection error: ${JSON.stringify(res.errors)}`); + assert.ok(res.data.__schema.queryType, 'Schema must have Query type'); + assert.ok(res.data.__schema.mutationType, 'Schema must have Mutation type'); + + const typeNames = res.data.__schema.types.map((t) => t.name); + ['MongoCompany', 'MongoUser', 'MongoPost', 'MongoComment', 'MongoProduct'].forEach((entity) => { + assert.ok(typeNames.includes(entity), `Schema must expose entity ${entity}`); + assert.ok(typeNames.includes(`${entity}Filter`), `Schema must expose filter type ${entity}Filter`); + assert.ok(typeNames.includes(`Sort${entity}`), `Schema must expose sort enum Sort${entity}`); + assert.ok(typeNames.includes(`${entity}Input`), `Schema must expose create input ${entity}Input`); + assert.ok(typeNames.includes(`${entity}Update`), `Schema must expose update input ${entity}Update`); + }); + }); + + it('should expose generated queries and mutations matching AutoGraphQL contract', async () => { + const fieldsQuery = ` + query IntrospectOperations { + queryFields: __type(name: "Query") { + fields { name } + } + mutationFields: __type(name: "Mutation") { + fields { name } + } + } + `; + + const res = await client.query(fieldsQuery); + assert.ok(!res.errors, `Operations introspection error: ${JSON.stringify(res.errors)}`); + + const qNames = res.data.queryFields.fields.map((f) => f.name); + const mNames = res.data.mutationFields.fields.map((f) => f.name); + + // Verify Queries + assert.ok(qNames.includes('mongoUser'), 'Query must have mongoUser (singular)'); + assert.ok(qNames.includes('mongoUsers'), 'Query must have mongoUsers (plural list)'); + assert.ok(qNames.includes('mongoUsersMeta'), 'Query must have mongoUsersMeta (count/meta)'); + + // Verify Mutations + assert.ok(mNames.includes('addMongoUser'), 'Mutation must have addMongoUser'); + assert.ok(mNames.includes('updateMongoUser'), 'Mutation must have updateMongoUser'); + assert.ok(mNames.includes('deleteMongoUser'), 'Mutation must have deleteMongoUser'); + assert.ok(mNames.includes('addToMongoCompanyUsers'), 'Mutation must have connect mutation addToMongoCompanyUsers'); + assert.ok(mNames.includes('removeFromMongoCompanyUsers'), 'Mutation must have disconnect mutation removeFromMongoCompanyUsers'); + }); + }); + + // ========================================================================= + // 2. CREATE Operations + // ========================================================================= + describe('2. CREATE Operations (GraphQL API)', () => { + it('should create a minimum valid record with only required fields', async () => { + const mutation = ` + mutation CreateMinUser($input: MongoUserInput!) { + addMongoUser(input: $input) { + id + name + email + active + } + } + `; + + const res = await client.mutate(mutation, { + input: { + name: 'Ada Lovelace', + email: 'ada@example.com', + }, + }); + + assert.ok(!res.errors, `Create error: ${JSON.stringify(res.errors)}`); + assert.ok(res.data.addMongoUser.id, 'Created user must have an auto-generated ID'); + assert.strictEqual(res.data.addMongoUser.name, 'Ada Lovelace'); + assert.strictEqual(res.data.addMongoUser.email, 'ada@example.com'); + assert.strictEqual(res.data.addMongoUser.active, true, 'Default value active=true must be applied'); + }); + + it('should create a fully populated record with all fields', async () => { + const mutation = ` + mutation CreateFullUser($input: MongoUserInput!) { + addMongoUser(input: $input) { + id + name + email + age + salary + active + bio + } + } + `; + + const res = await client.mutate(mutation, { + input: { + name: 'Grace Hopper', + email: 'grace@navy.mil', + age: 85, + salary: 125000.50, + active: true, + bio: 'Pioneer in computer programming and compiler development', + }, + }); + + assert.ok(!res.errors, `Create error: ${JSON.stringify(res.errors)}`); + const user = res.data.addMongoUser; + assert.ok(user.id); + assert.strictEqual(user.name, 'Grace Hopper'); + assert.strictEqual(user.email, 'grace@navy.mil'); + assert.strictEqual(user.age, 85); + assert.strictEqual(user.salary, 125000.50); + assert.strictEqual(user.bio, 'Pioneer in computer programming and compiler development'); + }); + + it('should create multiple records and assign unique IDs to each', async () => { + const createMutation = ` + mutation CreateProduct($input: MongoProductInput!) { + addMongoProduct(input: $input) { + id + name + price + sku + } + } + `; + + const res1 = await client.mutate(createMutation, { + input: { name: 'Keyboard', price: 99.99, sku: 'SKU-KEY-1' }, + }); + const res2 = await client.mutate(createMutation, { + input: { name: 'Mouse', price: 49.99, sku: 'SKU-MOU-2' }, + }); + + assert.ok(!res1.errors && !res2.errors); + assert.notStrictEqual(res1.data.addMongoProduct.id, res2.data.addMongoProduct.id, 'IDs must be unique'); + }); + + it('should create a record with relationship via connectId parameter', async () => { + // 1. Create company + const compRes = await client.mutate(` + mutation { + addMongoCompany(input: { name: "OpenAI", domain: "openai.com" }) { + id + name + } + } + `); + const companyId = compRes.data.addMongoCompany.id; + + // 2. Create user connected to company + const userRes = await client.mutate(` + mutation CreateUserWithCompany($input: MongoUserInput!, $companyConnectId: ID) { + addMongoUser(input: $input, companyConnectId: $companyConnectId) { + id + name + companyId + company { + id + name + } + } + } + `, { + input: { name: 'John von Neumann', email: 'john@princeton.edu' }, + companyConnectId: companyId, + }); + + assert.ok(!userRes.errors, `Create with connect error: ${JSON.stringify(userRes.errors)}`); + assert.strictEqual(userRes.data.addMongoUser.companyId, companyId); + assert.strictEqual(userRes.data.addMongoUser.company.id, companyId); + assert.strictEqual(userRes.data.addMongoUser.company.name, 'OpenAI'); + }); + + it('should reject creation when a required field is missing', async () => { + const mutation = ` + mutation { + addMongoUser(input: { email: "noname@example.com" }) { + id + } + } + `; + + const res = await client.mutate(mutation); + assert.ok(res.errors, 'GraphQL must return validation error for missing required field name'); + assert.ok(res.errors.length > 0); + }); + + it('should reject creation when duplicate value is passed for @unique field', async () => { + const mutation = ` + mutation CreateUser($email: String!) { + addMongoUser(input: { name: "Test User", email: $email }) { + id + } + } + `; + + // First create succeeds + const res1 = await client.mutate(mutation, { email: 'unique@example.com' }); + assert.ok(!res1.errors, 'First creation must succeed'); + + // Second create with duplicate email must fail + const res2 = await client.mutate(mutation, { email: 'unique@example.com' }); + assert.ok(res2.errors, 'GraphQL must return error on duplicate unique email'); + }); + }); + + // ========================================================================= + // 3. READ Operations + // ========================================================================= + describe('3. READ Operations (GraphQL API)', () => { + let createdUserId; + + beforeEach(async () => { + const res = await client.mutate(` + mutation { + addMongoUser(input: { + name: "Alan Turing" + email: "alan@bletchley.uk" + age: 41 + active: true + }) { + id + } + } + `); + createdUserId = res.data.addMongoUser.id; + }); + + it('should get a single record by ID', async () => { + const query = ` + query GetUser($id: ID!) { + mongoUser(id: $id) { + id + name + email + age + active + } + } + `; + + const res = await client.query(query, { id: createdUserId }); + assert.ok(!res.errors, `Query error: ${JSON.stringify(res.errors)}`); + assert.strictEqual(res.data.mongoUser.id, createdUserId); + assert.strictEqual(res.data.mongoUser.name, 'Alan Turing'); + assert.strictEqual(res.data.mongoUser.email, 'alan@bletchley.uk'); + }); + + it('should return null when querying for a non-existent ID', async () => { + const query = ` + query { + mongoUser(id: "non-existent-cuid-99999") { + id + name + } + } + `; + + const res = await client.query(query); + assert.ok(!res.errors, 'Query should not throw error for non-existent record'); + assert.strictEqual(res.data.mongoUser, null, 'Query for missing record must return null'); + }); + + it('should retrieve list of records', async () => { + await client.mutate(` + mutation { + addMongoUser(input: { name: "Claude Shannon", email: "claude@bell.labs" }) { + id + } + } + `); + + const query = ` + query { + mongoUsers { + id + name + email + } + } + `; + + const res = await client.query(query); + assert.ok(!res.errors, `Query error: ${JSON.stringify(res.errors)}`); + assert.strictEqual(res.data.mongoUsers.length, 2); + const names = res.data.mongoUsers.map((u) => u.name); + assert.ok(names.includes('Alan Turing')); + assert.ok(names.includes('Claude Shannon')); + }); + }); + + // ========================================================================= + // 4. UPDATE Operations + // ========================================================================= + describe('4. UPDATE Operations (GraphQL API)', () => { + let testUserId; + + beforeEach(async () => { + const res = await client.mutate(` + mutation { + addMongoUser(input: { + name: "Katherine Johnson" + email: "katherine@nasa.gov" + age: 101 + salary: 95000.00 + active: true + bio: "Calculated trajectory for Project Mercury" + }) { + id + } + } + `); + testUserId = res.data.addMongoUser.id; + }); + + it('should update a single field and leave unrelated fields untouched', async () => { + const updateMutation = ` + mutation UpdateBio($id: ID!, $input: MongoUserUpdate!) { + updateMongoUser(id: $id, input: $input) { + id + name + email + age + bio + } + } + `; + + const updateRes = await client.mutate(updateMutation, { + id: testUserId, + input: { bio: 'Awarded Presidential Medal of Freedom' }, + }); + + assert.ok(!updateRes.errors, `Update error: ${JSON.stringify(updateRes.errors)}`); + assert.strictEqual(updateRes.data.updateMongoUser.bio, 'Awarded Presidential Medal of Freedom'); + + // Verify via subsequent READ + const readRes = await client.query(` + query CheckUser($id: ID!) { + mongoUser(id: $id) { + id + name + email + age + bio + } + } + `, { id: testUserId }); + + assert.strictEqual(readRes.data.mongoUser.bio, 'Awarded Presidential Medal of Freedom'); + assert.strictEqual(readRes.data.mongoUser.name, 'Katherine Johnson', 'Name must remain unchanged'); + assert.strictEqual(readRes.data.mongoUser.email, 'katherine@nasa.gov', 'Email must remain unchanged'); + assert.strictEqual(readRes.data.mongoUser.age, 101, 'Age must remain unchanged'); + }); + + it('should update multiple fields including numeric and boolean fields', async () => { + const updateMutation = ` + mutation UpdateMultiple($id: ID!, $input: MongoUserUpdate!) { + updateMongoUser(id: $id, input: $input) { + id + age + salary + active + } + } + `; + + const res = await client.mutate(updateMutation, { + id: testUserId, + input: { + age: 102, + salary: 110000.00, + active: false, + }, + }); + + assert.ok(!res.errors, `Update multiple error: ${JSON.stringify(res.errors)}`); + assert.strictEqual(res.data.updateMongoUser.age, 102); + assert.strictEqual(res.data.updateMongoUser.salary, 110000.00); + assert.strictEqual(res.data.updateMongoUser.active, false); + }); + + it('should update relationship fields via connect parameter', async () => { + const compRes = await client.mutate(` + mutation { + addMongoCompany(input: { name: "NASA", domain: "nasa.gov" }) { + id + } + } + `); + const companyId = compRes.data.addMongoCompany.id; + + const updateRes = await client.mutate(` + mutation ConnectUserToCompany($id: ID!, $companyConnectId: ID) { + updateMongoUser(id: $id, companyConnectId: $companyConnectId) { + id + companyId + company { + id + name + } + } + } + `, { + id: testUserId, + companyConnectId: companyId, + }); + + assert.ok(!updateRes.errors, `Relation update error: ${JSON.stringify(updateRes.errors)}`); + assert.strictEqual(updateRes.data.updateMongoUser.companyId, companyId); + assert.strictEqual(updateRes.data.updateMongoUser.company.id, companyId); + }); + }); + + // ========================================================================= + // 5. DELETE Operations + // ========================================================================= + describe('5. DELETE Operations (GraphQL API)', () => { + it('should delete an existing record and confirm subsequent READ returns null', async () => { + // 1. Create + const createRes = await client.mutate(` + mutation { + addMongoCompany(input: { name: "Sun Microsystems", domain: "sun.com" }) { + id + } + } + `); + const companyId = createRes.data.addMongoCompany.id; + + // 2. Delete + const deleteRes = await client.mutate(` + mutation DeleteCompany($id: ID!) { + deleteMongoCompany(id: $id) { + id + name + } + } + `, { id: companyId }); + + assert.ok(!deleteRes.errors, `Delete error: ${JSON.stringify(deleteRes.errors)}`); + assert.strictEqual(deleteRes.data.deleteMongoCompany.id, companyId); + + // 3. Subsequent READ + const readRes = await client.query(` + query CheckCompany($id: ID!) { + mongoCompany(id: $id) { + id + } + } + `, { id: companyId }); + + assert.strictEqual(readRes.data.mongoCompany, null, 'Deleted company must return null'); + }); + + it('should return null without error when deleting non-existent record', async () => { + const deleteRes = await client.mutate(` + mutation { + deleteMongoCompany(id: "cuid-does-not-exist-00000") { + id + } + } + `); + + assert.ok(!deleteRes.errors, 'Delete non-existent record should not throw error'); + assert.strictEqual(deleteRes.data.deleteMongoCompany, null); + }); + + it('should delete multiple records via filter', async () => { + // Create 3 products + await client.mutate(`mutation { addMongoProduct(input: { name: "Pen", price: 1.5, sku: "P-1", active: false }) { id } }`); + await client.mutate(`mutation { addMongoProduct(input: { name: "Pencil", price: 0.8, sku: "P-2", active: false }) { id } }`); + await client.mutate(`mutation { addMongoProduct(input: { name: "Notebook", price: 5.0, sku: "P-3", active: true }) { id } }`); + + // Delete inactive products + const deleteMultiRes = await client.mutate(` + mutation DeleteInactiveProducts { + deleteMongoProducts(filter: { active: false }) { + id + name + } + } + `); + + assert.ok(!deleteMultiRes.errors, `Delete multiple error: ${JSON.stringify(deleteMultiRes.errors)}`); + assert.strictEqual(deleteMultiRes.data.deleteMongoProducts.length, 2); + + // Verify only 1 active product remains + const remainingRes = await client.query(` + query { + mongoProducts { + id + name + active + } + } + `); + assert.strictEqual(remainingRes.data.mongoProducts.length, 1); + assert.strictEqual(remainingRes.data.mongoProducts[0].name, 'Notebook'); + }); + }); + + // ========================================================================= + // 6. Complete CRUD Lifecycle + // ========================================================================= + describe('6. Complete CRUD Lifecycle (CREATE -> READ -> UPDATE -> READ -> DELETE -> READ)', () => { + it('should execute full CRUD lifecycle exclusively through GraphQL API', async () => { + // Step 1: CREATE + const createRes = await client.mutate(` + mutation Step1Create($input: MongoProductInput!) { + addMongoProduct(input: $input) { + id + name + price + quantity + active + sku + } + } + `, { + input: { + name: 'Mechanical Keyboard', + price: 149.99, + quantity: 25, + active: true, + sku: 'MK-CHERRY-BLUE', + }, + }); + + assert.ok(!createRes.errors, 'Step 1 CREATE failed'); + const productId = createRes.data.addMongoProduct.id; + assert.ok(productId, 'Step 1 must return valid ID'); + + // Step 2: READ + const readRes1 = await client.query(` + query Step2Read($id: ID!) { + mongoProduct(id: $id) { + id + name + price + quantity + active + sku + } + } + `, { id: productId }); + + assert.ok(!readRes1.errors, 'Step 2 READ failed'); + assert.strictEqual(readRes1.data.mongoProduct.name, 'Mechanical Keyboard'); + assert.strictEqual(readRes1.data.mongoProduct.quantity, 25); + + // Step 3: UPDATE + const updateRes = await client.mutate(` + mutation Step3Update($id: ID!, $input: MongoProductUpdate!) { + updateMongoProduct(id: $id, input: $input) { + id + name + price + quantity + } + } + `, { + id: productId, + input: { + name: 'Mechanical Keyboard Pro', + price: 179.99, + quantity: 20, + }, + }); + + assert.ok(!updateRes.errors, 'Step 3 UPDATE failed'); + assert.strictEqual(updateRes.data.updateMongoProduct.name, 'Mechanical Keyboard Pro'); + + // Step 4: READ after update + const readRes2 = await client.query(` + query Step4Read($id: ID!) { + mongoProduct(id: $id) { + id + name + price + quantity + sku + } + } + `, { id: productId }); + + assert.strictEqual(readRes2.data.mongoProduct.name, 'Mechanical Keyboard Pro'); + assert.strictEqual(readRes2.data.mongoProduct.price, 179.99); + assert.strictEqual(readRes2.data.mongoProduct.quantity, 20); + assert.strictEqual(readRes2.data.mongoProduct.sku, 'MK-CHERRY-BLUE', 'Unmodified field must persist'); + + // Step 5: DELETE + const deleteRes = await client.mutate(` + mutation Step5Delete($id: ID!) { + deleteMongoProduct(id: $id) { + id + } + } + `, { id: productId }); + + assert.ok(!deleteRes.errors, 'Step 5 DELETE failed'); + assert.strictEqual(deleteRes.data.deleteMongoProduct.id, productId); + + // Step 6: Final READ + const readRes3 = await client.query(` + query Step6Read($id: ID!) { + mongoProduct(id: $id) { + id + } + } + `, { id: productId }); + + assert.ok(!readRes3.errors, 'Step 6 READ must not error'); + assert.strictEqual(readRes3.data.mongoProduct, null, 'Record must be null after deletion'); + }); + }); + + // ========================================================================= + // 7. Relationships & Deep Nested Graph Queries + // ========================================================================= + describe('7. Relationships & Deep Nested Graph Queries', () => { + let companyId; + let userId; + let postId; + let commentId; + + beforeEach(async () => { + // 1. Create Company + const compRes = await client.mutate(` + mutation { + addMongoCompany(input: { name: "DeepMind", domain: "deepmind.google" }) { + id + } + } + `); + companyId = compRes.data.addMongoCompany.id; + + // 2. Create User connected to Company + const userRes = await client.mutate(` + mutation CreateUser($companyId: ID!) { + addMongoUser(input: { name: "Demis Hassabis", email: "demis@deepmind.com" }, companyConnectId: $companyId) { + id + } + } + `, { companyId }); + userId = userRes.data.addMongoUser.id; + + // 3. Create Post connected to User and Company + const postRes = await client.mutate(` + mutation CreatePost($userId: ID!, $companyId: ID!) { + addMongoPost( + input: { + title: "AlphaFold 3 Architecture" + slug: "alphafold-3" + content: "Structure prediction of biomolecular systems" + published: true + } + authorConnectId: $userId + companyConnectId: $companyId + ) { + id + } + } + `, { userId, companyId }); + postId = postRes.data.addMongoPost.id; + + // 4. Create Comment connected to Post and User + const commentRes = await client.mutate(` + mutation CreateComment($postId: ID!, $userId: ID!) { + addMongoComment( + input: { content: "Revolutionary computational biology breakthrough!", upvotes: 42 } + postConnectId: $postId + authorConnectId: $userId + ) { + id + } + } + `, { postId, userId }); + commentId = commentRes.data.addMongoComment.id; + }); + + it('should query 1-to-many relationship: Company -> Users', async () => { + const query = ` + query { + mongoCompany(id: "${companyId}") { + id + name + users { + id + name + email + } + } + } + `; + + const res = await client.query(query); + assert.ok(!res.errors, `Query error: ${JSON.stringify(res.errors)}`); + assert.strictEqual(res.data.mongoCompany.users.length, 1); + assert.strictEqual(res.data.mongoCompany.users[0].name, 'Demis Hassabis'); + }); + + it('should query 1-to-many relationship: User -> Posts', async () => { + const query = ` + query { + mongoUser(id: "${userId}") { + id + name + posts { + id + title + slug + } + } + } + `; + + const res = await client.query(query); + assert.ok(!res.errors, `Query error: ${JSON.stringify(res.errors)}`); + assert.strictEqual(res.data.mongoUser.posts.length, 1); + assert.strictEqual(res.data.mongoUser.posts[0].title, 'AlphaFold 3 Architecture'); + }); + + it('should query deep 4-level nesting: Company -> Users -> Posts -> Comments -> Author', async () => { + const deepQuery = ` + query DeepRelationTree { + mongoCompany(id: "${companyId}") { + id + name + users { + id + name + posts { + id + title + comments { + id + content + upvotes + author { + id + name + email + } + } + } + } + } + } + `; + + const res = await client.query(deepQuery); + assert.ok(!res.errors, `Deep query error: ${JSON.stringify(res.errors)}`); + + const company = res.data.mongoCompany; + assert.strictEqual(company.name, 'DeepMind'); + assert.strictEqual(company.users[0].name, 'Demis Hassabis'); + assert.strictEqual(company.users[0].posts[0].title, 'AlphaFold 3 Architecture'); + assert.strictEqual(company.users[0].posts[0].comments[0].content, 'Revolutionary computational biology breakthrough!'); + assert.strictEqual(company.users[0].posts[0].comments[0].author.name, 'Demis Hassabis'); + }); + + it('should connect and disconnect relations using generated relation mutations', async () => { + // Create second company + const comp2 = await client.mutate(` + mutation { + addMongoCompany(input: { name: "Google Research", domain: "research.google" }) { + id + } + } + `); + const newCompanyId = comp2.data.addMongoCompany.id; + + // Connect user to second company + const connectRes = await client.mutate(` + mutation ConnectUser($companyId: ID!, $userId: ID!) { + addToMongoCompanyUsers(mongoCompanyId: $companyId, mongoUserId: $userId) { + typeName + fieldName + connectedTypeName + connectedFieldName + } + } + `, { companyId: newCompanyId, userId }); + + assert.ok(!connectRes.errors, `Connect error: ${JSON.stringify(connectRes.errors)}`); + + // Verify user's company is now Google Research + const checkRes = await client.query(` + query { + mongoUser(id: "${userId}") { + company { + id + name + } + } + } + `); + assert.strictEqual(checkRes.data.mongoUser.company.id, newCompanyId); + assert.strictEqual(checkRes.data.mongoUser.company.name, 'Google Research'); + + // Disconnect user from company + const disconnectRes = await client.mutate(` + mutation DisconnectUser($companyId: ID!, $userId: ID!) { + removeFromMongoCompanyUsers(mongoCompanyId: $companyId, mongoUserId: $userId) { + typeName + fieldName + } + } + `, { companyId: newCompanyId, userId }); + + assert.ok(!disconnectRes.errors, `Disconnect error: ${JSON.stringify(disconnectRes.errors)}`); + + // Verify company is now null + const checkRes2 = await client.query(` + query { + mongoUser(id: "${userId}") { + company { + id + } + } + } + `); + assert.strictEqual(checkRes2.data.mongoUser.company, null); + }); + }); + + // ========================================================================= + // 8. Filtering + // ========================================================================= + describe('8. Filtering Capabilities (GraphQL API)', () => { + beforeEach(async () => { + await client.mutate(`mutation { addMongoUser(input: { name: "Alice Smith", email: "alice@test.com", age: 25, salary: 50000.0, active: true }) { id } }`); + await client.mutate(`mutation { addMongoUser(input: { name: "Bob Jones", email: "bob@test.com", age: 35, salary: 75000.0, active: false }) { id } }`); + await client.mutate(`mutation { addMongoUser(input: { name: "Charlie Brown", email: "charlie@test.com", age: 45, salary: 100000.0, active: true }) { id } }`); + await client.mutate(`mutation { addMongoUser(input: { name: "Diana Prince", email: "diana@test.com", age: 30, salary: 85000.0, active: true }) { id } }`); + }); + + it('should filter by exact equality (active: true)', async () => { + const res = await client.query(` + query { + mongoUsers(filter: { active: true }) { + id + name + } + } + `); + assert.ok(!res.errors); + assert.strictEqual(res.data.mongoUsers.length, 3); + }); + + it('should filter by numeric comparison (_gt, _lt, _gte, _lte)', async () => { + const gtRes = await client.query(` + query { + mongoUsers(filter: { age_gt: 30 }) { + name + age + } + } + `); + assert.ok(!gtRes.errors); + assert.strictEqual(gtRes.data.mongoUsers.length, 2); // Bob (35), Charlie (45) + + const lteRes = await client.query(` + query { + mongoUsers(filter: { salary_lte: 75000.0 }) { + name + salary + } + } + `); + assert.ok(!lteRes.errors); + assert.strictEqual(lteRes.data.mongoUsers.length, 2); // Alice (50k), Bob (75k) + }); + + it('should filter by string matching (_contains and _startsWith)', async () => { + const containsRes = await client.query(` + query { + mongoUsers(filter: { name_contains: "Brown" }) { + name + } + } + `); + assert.ok(!containsRes.errors); + assert.strictEqual(containsRes.data.mongoUsers.length, 1); + assert.strictEqual(containsRes.data.mongoUsers[0].name, 'Charlie Brown'); + + const startsWithRes = await client.query(` + query { + mongoUsers(filter: { name_startsWith: "Dia" }) { + name + } + } + `); + assert.ok(!startsWithRes.errors); + assert.strictEqual(startsWithRes.data.mongoUsers.length, 1); + assert.strictEqual(startsWithRes.data.mongoUsers[0].name, 'Diana Prince'); + }); + + it('should filter with logical operators: AND and OR', async () => { + const andRes = await client.query(` + query { + mongoUsers(filter: { + AND: [ + { active: true }, + { age_lt: 35 } + ] + }) { + name + } + } + `); + assert.ok(!andRes.errors); + assert.strictEqual(andRes.data.mongoUsers.length, 2); // Alice (25), Diana (30) + + const orRes = await client.query(` + query { + mongoUsers(filter: { + OR: [ + { age_lt: 26 }, + { age_gt: 40 } + ] + }) { + name + } + } + `); + assert.ok(!orRes.errors); + assert.strictEqual(orRes.data.mongoUsers.length, 2); // Alice (25), Charlie (45) + }); + + it('should filter by field existence (_exists: true/false)', async () => { + // Add user with bio + await client.mutate(` + mutation { + addMongoUser(input: { name: "Edward Norton", email: "edward@test.com", bio: "Actor" }) { id } + } + `); + + const existsRes = await client.query(` + query { + mongoUsers(filter: { active_exists: true }) { + name + } + } + `); + assert.ok(!existsRes.errors); + assert.strictEqual(existsRes.data.mongoUsers.length, 5); + }); + }); + + // ========================================================================= + // 9. Sorting + // ========================================================================= + describe('9. Sorting Capabilities (GraphQL API)', () => { + beforeEach(async () => { + await client.mutate(`mutation { addMongoProduct(input: { name: "Monitor 24in", price: 199.0, quantity: 15, sku: "M-24" }) { id } }`); + await client.mutate(`mutation { addMongoProduct(input: { name: "Monitor 27in", price: 299.0, quantity: 5, sku: "M-27" }) { id } }`); + await client.mutate(`mutation { addMongoProduct(input: { name: "Monitor 32in", price: 499.0, quantity: 20, sku: "M-32" }) { id } }`); + }); + + it('should sort ascending and descending by numeric field (price_ASC / price_DESC)', async () => { + const ascRes = await client.query(` + query { + mongoProducts(orderBy: price_ASC) { + name + price + } + } + `); + assert.ok(!ascRes.errors); + assert.strictEqual(ascRes.data.mongoProducts[0].price, 199.0); + assert.strictEqual(ascRes.data.mongoProducts[2].price, 499.0); + + const descRes = await client.query(` + query { + mongoProducts(orderBy: price_DESC) { + name + price + } + } + `); + assert.ok(!descRes.errors); + assert.strictEqual(descRes.data.mongoProducts[0].price, 499.0); + assert.strictEqual(descRes.data.mongoProducts[2].price, 199.0); + }); + + it('should sort alphabetically by string field (name_ASC / name_DESC)', async () => { + const ascRes = await client.query(` + query { + mongoProducts(orderBy: name_ASC) { + name + } + } + `); + assert.ok(!ascRes.errors); + assert.strictEqual(ascRes.data.mongoProducts[0].name, 'Monitor 24in'); + assert.strictEqual(ascRes.data.mongoProducts[2].name, 'Monitor 32in'); + }); + + it('should combine filtering and sorting in a single query', async () => { + const res = await client.query(` + query { + mongoProducts(filter: { price_gt: 200.0 }, orderBy: price_DESC) { + name + price + } + } + `); + assert.ok(!res.errors); + assert.strictEqual(res.data.mongoProducts.length, 2); + assert.strictEqual(res.data.mongoProducts[0].price, 499.0); + assert.strictEqual(res.data.mongoProducts[1].price, 299.0); + }); + }); + + // ========================================================================= + // 10. Pagination + // ========================================================================= + describe('10. Pagination Capabilities (GraphQL API)', () => { + beforeEach(async () => { + // Create 5 items + for (let i = 1; i <= 5; i += 1) { + await client.mutate(` + mutation CreateItem($input: MongoProductInput!) { + addMongoProduct(input: $input) { id } + } + `, { + input: { + name: `Item ${i}`, + price: i * 10.0, + sku: `SKU-ITEM-${i}`, + }, + }); + } + }); + + it('should paginate using first and skip without duplicates or omissions', async () => { + // Page 1: 2 items + const page1Res = await client.query(` + query { + mongoProducts(orderBy: price_ASC, first: 2, skip: 0) { + id + name + price + } + } + `); + assert.ok(!page1Res.errors); + assert.strictEqual(page1Res.data.mongoProducts.length, 2); + assert.strictEqual(page1Res.data.mongoProducts[0].name, 'Item 1'); + assert.strictEqual(page1Res.data.mongoProducts[1].name, 'Item 2'); + + // Page 2: 2 items + const page2Res = await client.query(` + query { + mongoProducts(orderBy: price_ASC, first: 2, skip: 2) { + id + name + price + } + } + `); + assert.ok(!page2Res.errors); + assert.strictEqual(page2Res.data.mongoProducts.length, 2); + assert.strictEqual(page2Res.data.mongoProducts[0].name, 'Item 3'); + assert.strictEqual(page2Res.data.mongoProducts[1].name, 'Item 4'); + + // Page 3: 1 item (last page) + const page3Res = await client.query(` + query { + mongoProducts(orderBy: price_ASC, first: 2, skip: 4) { + id + name + price + } + } + `); + assert.ok(!page3Res.errors); + assert.strictEqual(page3Res.data.mongoProducts.length, 1); + assert.strictEqual(page3Res.data.mongoProducts[0].name, 'Item 5'); + + // Verify zero duplicate IDs between page 1 and page 2 + const page1Ids = page1Res.data.mongoProducts.map((p) => p.id); + const page2Ids = page2Res.data.mongoProducts.map((p) => p.id); + page1Ids.forEach((id) => assert.ok(!page2Ids.includes(id), 'No overlapping IDs across pages')); + }); + + it('should return an empty array when skip exceeds total count', async () => { + const res = await client.query(` + query { + mongoProducts(first: 10, skip: 100) { + id + } + } + `); + assert.ok(!res.errors); + assert.strictEqual(res.data.mongoProducts.length, 0); + }); + + it('should support page size of 1', async () => { + const res = await client.query(` + query { + mongoProducts(orderBy: price_ASC, first: 1, skip: 1) { + name + price + } + } + `); + assert.ok(!res.errors); + assert.strictEqual(res.data.mongoProducts.length, 1); + assert.strictEqual(res.data.mongoProducts[0].name, 'Item 2'); + }); + + it('should combine filtering, sorting, and pagination simultaneously', async () => { + const res = await client.query(` + query { + mongoProducts(filter: { price_gte: 20.0 }, orderBy: price_DESC, first: 2, skip: 1) { + name + price + } + } + `); + assert.ok(!res.errors); + // Matching items with price >= 20 in DESC: Item 5 (50), Item 4 (40), Item 3 (30), Item 2 (20) + // Skip 1, first 2 -> Item 4 (40), Item 3 (30) + assert.strictEqual(res.data.mongoProducts.length, 2); + assert.strictEqual(res.data.mongoProducts[0].name, 'Item 4'); + assert.strictEqual(res.data.mongoProducts[1].name, 'Item 3'); + }); + }); + + // ========================================================================= + // 11. DataLoaders & N+1 Prevention + // ========================================================================= + describe('11. DataLoaders & N+1 Query Batching', () => { + it('should batch relational lookups via request-scoped DataLoader into a single $in query', async () => { + // 1. Create company + const cRes = await client.mutate(` + mutation { + addMongoCompany(input: { name: "Anthropic", domain: "anthropic.com" }) { id } + } + `); + const companyId = cRes.data.addMongoCompany.id; + + // 2. Create 4 users connected to the same company + for (let i = 1; i <= 4; i += 1) { + await client.mutate(` + mutation CreateUser($companyId: ID!) { + addMongoUser( + input: { name: "Researcher ${i}", email: "researcher${i}@anthropic.com" } + companyConnectId: $companyId + ) { id } + } + `, { companyId }); + } + + // Track Mongoose queries on MongoCompany to verify DataLoader batching + let companyFindCount = 0; + const originalFindOne = models.MongoCompany.findOne.bind(models.MongoCompany); + const originalFind = models.MongoCompany.find.bind(models.MongoCompany); + + models.MongoCompany.find = function (...args) { + companyFindCount += 1; + return originalFind(...args); + }; + + try { + // Query users and their parent company in a single request + const res = await client.query(` + query { + mongoUsers(first: 4) { + id + name + company { + id + name + } + } + } + `); + + assert.ok(!res.errors, `DataLoader query error: ${JSON.stringify(res.errors)}`); + assert.strictEqual(res.data.mongoUsers.length, 4); + + // Every user must resolve to Anthropic + res.data.mongoUsers.forEach((u) => { + assert.ok(u.company); + assert.strictEqual(u.company.name, 'Anthropic'); + }); + + // With DataLoader batching, company lookups should be memoized / batched + // rather than doing 4 separate database roundtrips + assert.ok(companyFindCount <= 1, `DataLoader must batch/memoize queries (got ${companyFindCount} queries)`); + } finally { + models.MongoCompany.findOne = originalFindOne; + models.MongoCompany.find = originalFind; + } + }); + }); + + // ========================================================================= + // 12. Validation & API Error Handling + // ========================================================================= + describe('12. Validation & API Error Handling', () => { + it('should return GraphQL error when non-null field is omitted and not create invalid document', async () => { + const res = await client.mutate(` + mutation { + addMongoProduct(input: { price: 10.0 }) { + id + } + } + `); + + assert.ok(res.errors, 'Missing required field "name" must return GraphQL error'); + assert.ok(res.errors[0].message.includes('name')); + + // Verify no corrupted product created + const countRes = await client.query(`query { mongoProductsMeta { count } }`); + assert.strictEqual(countRes.data.mongoProductsMeta.count, 0); + }); + + it('should reject invalid scalar type (e.g. string for Float)', async () => { + const res = await client.mutate(` + mutation { + addMongoProduct(input: { name: "Bad Product", price: "NOT_A_NUMBER", sku: "SKU-BAD" }) { + id + } + } + `); + + assert.ok(res.errors, 'String for Float field must produce a GraphQL syntax/validation error'); + }); + + it('should keep API completely usable after receiving an error', async () => { + // 1. Send invalid mutation + await client.mutate(` + mutation { + addMongoProduct(input: { price: 99.0 }) { id } + } + `); + + // 2. Send valid mutation immediately after + const validRes = await client.mutate(` + mutation { + addMongoProduct(input: { name: "Valid Recovery Product", price: 19.99, sku: "SKU-RECOVER" }) { + id + name + } + } + `); + + assert.ok(!validRes.errors, 'Backend must recover gracefully and process subsequent valid requests'); + assert.strictEqual(validRes.data.addMongoProduct.name, 'Valid Recovery Product'); + }); + }); + + // ========================================================================= + // 13. Persistence & Server Restart/Reconnect + // ========================================================================= + describe('13. Persistence & Server Restart/Reconnect', () => { + it('should persist data in MongoDB across server restart / reconnection', async () => { + // 1. CREATE records through GraphQL API + const compRes = await client.mutate(` + mutation { + addMongoCompany(input: { name: "Persistent Inc", domain: "persistent.io" }) { + id + name + } + } + `); + assert.ok(!compRes.errors); + const companyId = compRes.data.addMongoCompany.id; + + const userRes = await client.mutate(` + mutation CreatePersistUser($companyId: ID!) { + addMongoUser( + input: { name: "Persistent Developer", email: "persist@persistent.io" } + companyConnectId: $companyId + ) { + id + name + } + } + `, { companyId }); + assert.ok(!userRes.errors); + const userId = userRes.data.addMongoUser.id; + + // 2. SIMULATE RESTART: Close connection and restart backend with fresh connection + const restartedEnv = await startMongoTestEnvironment(mongoGraphQLSDL, true); + const restartedClient = createGraphQLClient(restartedEnv.schema, restartedEnv.models); + + // 4. READ records through GraphQL API on the new instance + const readCompRes = await restartedClient.query(` + query { + mongoCompany(id: "${companyId}") { + id + name + users { + id + name + email + } + } + } + `); + + assert.ok(!readCompRes.errors, `Post-restart query error: ${JSON.stringify(readCompRes.errors)}`); + assert.ok(readCompRes.data.mongoCompany, 'Company must be available after restart'); + assert.strictEqual(readCompRes.data.mongoCompany.name, 'Persistent Inc'); + assert.strictEqual(readCompRes.data.mongoCompany.users.length, 1); + assert.strictEqual(readCompRes.data.mongoCompany.users[0].name, 'Persistent Developer'); + + // Re-assign active client & models for subsequent tests + models = restartedEnv.models; + schema = restartedEnv.schema; + client = restartedClient; + }); + }); + + // ========================================================================= + // 14. Concurrent API Clients + // ========================================================================= + describe('14. Concurrent API Clients Simulation', () => { + it('should handle simultaneous concurrent GraphQL requests without race conditions or corruption', async () => { + const clientCount = 10; + const promises = []; + + for (let i = 0; i < clientCount; i += 1) { + const clientIndex = i; + // Each simulated client executes a GraphQL mutation concurrently + promises.push( + client.mutate(` + mutation ConcurrentCreate($input: MongoProductInput!) { + addMongoProduct(input: $input) { + id + name + sku + } + } + `, { + input: { + name: `Concurrent Product ${clientIndex}`, + price: 10.0 + clientIndex, + sku: `SKU-CONC-${clientIndex}`, + }, + }), + ); + } + + const results = await Promise.all(promises); + + // All concurrent requests must succeed + results.forEach((res, idx) => { + assert.ok(!res.errors, `Client ${idx} failed: ${JSON.stringify(res.errors)}`); + assert.ok(res.data.addMongoProduct.id, `Client ${idx} must return ID`); + }); + + // Verify exact count in database via GraphQL meta query + const metaRes = await client.query(`query { mongoProductsMeta { count } }`); + assert.strictEqual(metaRes.data.mongoProductsMeta.count, clientCount); + + // Verify all IDs are distinct + const ids = results.map((r) => r.data.addMongoProduct.id); + const uniqueIds = new Set(ids); + assert.strictEqual(uniqueIds.size, clientCount, 'All concurrent creates must receive unique IDs'); + }); + }); + + // ========================================================================= + // 15. Schema Evolution + // ========================================================================= + describe('15. Schema Evolution (SDL v1 -> evolve SDL -> verify through GraphQL API)', () => { + it('should evolve schema to add new fields while maintaining existing data and full CRUD', async () => { + // Step 1: Create record under SDL v1 + const v1Res = await client.mutate(` + mutation { + addMongoUser(input: { name: "Linus Torvalds", email: "linus@kernel.org" }) { + id + name + email + } + } + `); + assert.ok(!v1Res.errors); + const userId = v1Res.data.addMongoUser.id; + + // Step 2: Evolve SDL: add 'tagline: String' to MongoUser and 'headquarters: String' to MongoCompany + const evolvedEnv = await startMongoTestEnvironment(mongoGraphQLSDLEvolved); + const evolvedClient = createGraphQLClient(evolvedEnv.schema, evolvedEnv.models); + + // Step 3: Verify existing record is still accessible under new schema + const readExistingRes = await evolvedClient.query(` + query { + mongoUser(id: "${userId}") { + id + name + email + tagline + } + } + `); + assert.ok(!readExistingRes.errors); + assert.strictEqual(readExistingRes.data.mongoUser.name, 'Linus Torvalds'); + assert.strictEqual(readExistingRes.data.mongoUser.tagline, null, 'Unpopulated evolved field should be null'); + + // Step 4: UPDATE existing record using the newly added field + const updateRes = await evolvedClient.mutate(` + mutation UpdateEvolvedField($id: ID!, $input: MongoUserUpdate!) { + updateMongoUser(id: $id, input: $input) { + id + name + tagline + } + } + `, { + id: userId, + input: { tagline: 'Talk is cheap. Show me the code.' }, + }); + + assert.ok(!updateRes.errors, `Evolved update error: ${JSON.stringify(updateRes.errors)}`); + assert.strictEqual(updateRes.data.updateMongoUser.tagline, 'Talk is cheap. Show me the code.'); + + // Step 5: CREATE new record directly using the evolved field + const createNewRes = await evolvedClient.mutate(` + mutation CreateWithEvolvedField($input: MongoUserInput!) { + addMongoUser(input: $input) { + id + name + email + tagline + } + } + `, { + input: { + name: 'Ken Thompson', + email: 'ken@bell.labs', + tagline: 'Unix & Go co-creator', + }, + }); + + assert.ok(!createNewRes.errors); + assert.strictEqual(createNewRes.data.addMongoUser.tagline, 'Unix & Go co-creator'); + + // Re-assign active client and models for teardown + models = evolvedEnv.models; + schema = evolvedEnv.schema; + client = evolvedClient; + }); + }); +}); diff --git a/test/mongo/setup.js b/test/mongo/setup.js new file mode 100644 index 00000000..08118deb --- /dev/null +++ b/test/mongo/setup.js @@ -0,0 +1,155 @@ +/** + * MongoDB Integration Test Setup & Teardown + * + * Manages the lifecycle of the test MongoDB database, Mongoose connection, + * dynamic model compilation, collection cleanup, and the client GraphQL execution harness. + */ +import mongoose from 'mongoose'; +import { execSync } from 'child_process'; +import { graphql } from 'graphql'; +import { createDataLoaders } from '../../src/dataloader'; +import { + mongoGraphQLSDL, + createMongooseModelsFromAST, + buildExecutableMongoSchema, +} from './testSchema'; + +const MONGO_PORT = process.env.DATABASE_PORT || 27017; +const MONGO_HOST = process.env.DATABASE_HOST || 'localhost'; +const MONGO_DB = process.env.MONGO_TEST_DB || 'autographql_mongo_test'; +export const MONGO_TEST_URI = process.env.MONGODB_URI || `mongodb://${MONGO_HOST}:${MONGO_PORT}/${MONGO_DB}`; + +let testConnection = null; +let testModels = {}; +let executableSchema = null; + +/** + * Check if MongoDB is reachable + */ +export const isMongoReachable = async () => { + try { + const conn = await mongoose.createConnection(MONGO_TEST_URI, { + serverSelectionTimeoutMS: 2000, + }).asPromise(); + await conn.close(); + return true; + } catch (err) { + return false; + } +}; + +/** + * Start MongoDB container if not already reachable + */ +export const ensureMongoRunning = async () => { + if (await isMongoReachable()) { + return; + } + + // Try docker compose or docker run + try { + execSync('docker compose up -d mongodb 2>/dev/null || docker-compose up -d mongodb 2>/dev/null', { stdio: 'pipe' }); + } catch (e) { + try { + execSync('docker start autographql-mongodb 2>/dev/null || docker run -d --name autographql-mongodb -p 27017:27017 mongo:5.0', { stdio: 'pipe' }); + } catch (err) { + // Continue to retry loop + } + } + + // Wait for MongoDB to become ready + const maxRetries = 20; + for (let i = 0; i < maxRetries; i += 1) { + if (await isMongoReachable()) { + return; + } + await new Promise((res) => setTimeout(res, 1000)); + } + throw new Error(`MongoDB was not reachable at ${MONGO_TEST_URI} after ${maxRetries} retries`); +}; + +/** + * Connect to test MongoDB database and initialize models & schema + */ +export const startMongoTestEnvironment = async (sdl = mongoGraphQLSDL, forceNew = false) => { + await ensureMongoRunning(); + + if (forceNew && testConnection) { + if (testConnection.readyState !== 0) { + await testConnection.close(); + } + testConnection = null; + } + + if (!testConnection || testConnection.readyState !== 1) { + testConnection = await mongoose.createConnection(MONGO_TEST_URI, { + useNewUrlParser: true, + useUnifiedTopology: true, + }).asPromise(); + } + + testModels = createMongooseModelsFromAST(sdl, testConnection); + executableSchema = buildExecutableMongoSchema({ models: testModels, sdlString: sdl }); + + return { + connection: testConnection, + models: testModels, + schema: executableSchema, + }; +}; + +/** + * Clears all records in test collections + */ +export const clearMongoCollections = async (models = testModels) => { + if (!models) return; + const modelKeys = Object.keys(models); + for (const key of modelKeys) { + const Model = models[key]; + if (Model && typeof Model.deleteMany === 'function') { + await Model.deleteMany({}).exec(); + } + } +}; + +/** + * Teardown and close database connection + */ +export const closeMongoConnection = async () => { + if (testConnection && testConnection.readyState !== 0) { + await testConnection.close(); + } +}; + +/** + * Creates an AutoGraphQL client wrapper that interacts with the backend + * EXCLUSIVELY through GraphQL queries and mutations. + * + * @param {GraphQLSchema} schema + * @param {Object} [models=testModels] + * @returns {Object} Client API instance with .query() and .mutate() + */ +export const createGraphQLClient = (schema = executableSchema, models = testModels) => { + const executeOperation = async (queryStr, variables = {}, customContext = {}) => { + const loaders = createDataLoaders(models); + const context = { + loaders, + models, + ...customContext, + }; + + const result = await graphql({ + schema, + source: queryStr, + variableValues: variables, + contextValue: context, + }); + + return result; + }; + + return { + query: (queryStr, variables = {}, context = {}) => executeOperation(queryStr, variables, context), + mutate: (mutationStr, variables = {}, context = {}) => executeOperation(mutationStr, variables, context), + }; +}; diff --git a/test/mongo/testSchema.js b/test/mongo/testSchema.js new file mode 100644 index 00000000..672be989 --- /dev/null +++ b/test/mongo/testSchema.js @@ -0,0 +1,1574 @@ +/** + * AutoGraphQL — MongoDB Integration Test Schema Definition from GraphQL SDL + * + * This file defines the MongoDB entities directly as client-facing GraphQL SDL. + * The AST parser extracts model definitions, constraints, defaults, types, and + * relations (@relation directives) from the GraphQL SDL string, matching how + * any client or developer defines schemas in AutoGraphQL. + */ +import { parse, GraphQLScalarType, Kind } from 'graphql'; +import { makeExecutableSchema } from 'graphql-tools'; +import mongoose from 'mongoose'; +import cuid from 'cuid'; + +const { Schema } = mongoose; + +/** + * Client-facing GraphQL SDL definition for MongoDB + */ +export const mongoGraphQLSDL = ` + scalar Date + + # 1. Company entity + type MongoCompany @model { + id: ID + name: String! + domain: String @unique + industry: String + employeeCount: Int + isPublic: Boolean @defaultValue(value: "false") + foundedAt: Date + createdAt: Date + updatedAt: Date + users: [MongoUser] @relation(name: "MongoCompanyUsers") + posts: [MongoPost] @relation(name: "MongoCompanyPosts") + } + + # 2. User entity with unique constraint, defaults, and relationships + type MongoUser @model { + id: ID + name: String! @trim + email: String! @unique + age: Int + salary: Float + active: Boolean @defaultValue(value: "true") + bio: String + companyId: String + createdAt: Date + updatedAt: Date + lastLoginAt: Date + company: MongoCompany @relation(name: "MongoCompanyUsers") + posts: [MongoPost] @relation(name: "MongoUserPosts") + comments: [MongoComment] @relation(name: "MongoUserComments") + } + + # 3. Post entity belonging to User and Company, having Comments + type MongoPost @model { + id: ID + title: String! @trim + slug: String! @unique + content: String! + published: Boolean @defaultValue(value: "false") + viewsCount: Int @defaultValue(value: "0") + rating: Float + authorId: String! + companyId: String + createdAt: Date + updatedAt: Date + publishedAt: Date + author: MongoUser @relation(name: "MongoUserPosts") + company: MongoCompany @relation(name: "MongoCompanyPosts") + comments: [MongoComment] @relation(name: "MongoPostComments") + } + + # 4. Comment entity referencing Post and Author + type MongoComment @model { + id: ID + content: String! @trim + upvotes: Int @defaultValue(value: "0") + postId: String! + authorId: String! + createdAt: Date + updatedAt: Date + post: MongoPost @relation(name: "MongoPostComments") + author: MongoUser @relation(name: "MongoUserComments") + } + + # 5. Product entity with inventory attributes and unique SKU + type MongoProduct @model { + id: ID + name: String! @trim + price: Float! + quantity: Int @defaultValue(value: "0") + active: Boolean @defaultValue(value: "true") + sku: String @unique + createdAt: Date + updatedAt: Date + } +`; + +/** + * Evolved SDL version with added fields: + * - MongoUser: added 'tagline: String' + * - MongoCompany: added 'headquarters: String' + */ +export const mongoGraphQLSDLEvolved = ` + scalar Date + + type MongoCompany @model { + id: ID + name: String! + domain: String @unique + industry: String + employeeCount: Int + isPublic: Boolean @defaultValue(value: "false") + headquarters: String + foundedAt: Date + createdAt: Date + updatedAt: Date + users: [MongoUser] @relation(name: "MongoCompanyUsers") + posts: [MongoPost] @relation(name: "MongoCompanyPosts") + } + + type MongoUser @model { + id: ID + name: String! @trim + email: String! @unique + age: Int + salary: Float + active: Boolean @defaultValue(value: "true") + bio: String + tagline: String + companyId: String + createdAt: Date + updatedAt: Date + lastLoginAt: Date + company: MongoCompany @relation(name: "MongoCompanyUsers") + posts: [MongoPost] @relation(name: "MongoUserPosts") + comments: [MongoComment] @relation(name: "MongoUserComments") + } + + type MongoPost @model { + id: ID + title: String! @trim + slug: String! @unique + content: String! + published: Boolean @defaultValue(value: "false") + viewsCount: Int @defaultValue(value: "0") + rating: Float + authorId: String! + companyId: String + createdAt: Date + updatedAt: Date + publishedAt: Date + author: MongoUser @relation(name: "MongoUserPosts") + company: MongoCompany @relation(name: "MongoCompanyPosts") + comments: [MongoComment] @relation(name: "MongoPostComments") + } + + type MongoComment @model { + id: ID + content: String! @trim + upvotes: Int @defaultValue(value: "0") + postId: String! + authorId: String! + createdAt: Date + updatedAt: Date + post: MongoPost @relation(name: "MongoPostComments") + author: MongoUser @relation(name: "MongoUserComments") + } + + type MongoProduct @model { + id: ID + name: String! @trim + price: Float! + quantity: Int @defaultValue(value: "0") + active: Boolean @defaultValue(value: "true") + sku: String @unique + createdAt: Date + updatedAt: Date + } +`; + +/** + * Parses client GraphQL SDL into AutoGraphQL AST field definitions and relation lists + * + * @param {string} sdlString Raw GraphQL SDL definition + * @returns {{ fieldsSchemas: Object, relations: Array }} + */ +export const parseSDLToSchemaDefinitions = (sdlString) => { + const ast = parse(sdlString); + const fieldsSchemas = {}; + const relationDefs = []; + + ast.definitions.forEach((def) => { + if (def.kind !== 'ObjectTypeDefinition') return; + const typeName = def.name.value; + const modelDir = (def.directives || []).find((d) => d.name.value === 'model'); + if (!modelDir) return; + + const fields = {}; + + (def.fields || []).forEach((field) => { + const fieldName = field.name.value; + if (fieldName === 'id' || fieldName === 'createdAt' || fieldName === 'updatedAt') { + return; + } + let isRequired = false; + let isList = false; + let rawType = field.type; + + if (rawType.kind === 'NonNullType') { + isRequired = true; + rawType = rawType.type; + } + if (rawType.kind === 'ListType') { + isList = true; + rawType = rawType.type; + if (rawType.kind === 'NonNullType') { + rawType = rawType.type; + } + } + + const targetTypeName = rawType.name.value; + + // Extract relation directive (@relation(name: "...")) + const relDir = (field.directives || []).find((d) => d.name.value === 'relation'); + if (relDir) { + const nameArg = (relDir.arguments || []).find((a) => a.name.value === 'name'); + relationDefs.push({ + relName: nameArg ? nameArg.value.value : `${typeName}_${fieldName}`, + source: typeName, + target: targetTypeName, + field: fieldName, + isList, + }); + return; + } + + const fieldDef = { + type: targetTypeName, + required: isRequired, + }; + + (field.directives || []).forEach((d) => { + const dName = d.name.value; + if (dName === 'unique' || dName === 'uniqueOrEmpty') { + fieldDef.unique = true; + } else if (dName === 'createIndex') { + fieldDef.index = true; + } else if (dName === 'defaultValue') { + const valArg = (d.arguments || []).find((a) => a.name.value === 'value'); + if (valArg) { + let v = valArg.value.value; + if (v === 'true') v = true; + else if (v === 'false') v = false; + else if (!isNaN(Number(v)) && fieldDef.type === 'Int') v = parseInt(v, 10); + else if (!isNaN(Number(v)) && fieldDef.type === 'Float') v = parseFloat(v); + fieldDef.default = v; + } + } + }); + + fields[fieldName] = fieldDef; + }); + + fieldsSchemas[typeName] = fields; + }); + + return { fieldsSchemas, relations: relationDefs }; +}; + +/** + * Creates Mongoose models dynamically from the parsed SDL definitions + * + * @param {string} sdlString + * @param {mongoose.Connection} [connection=mongoose.connection] + * @returns {Object} + */ +export const createMongooseModelsFromAST = (sdlString, connection = mongoose.connection) => { + const { fieldsSchemas, relations } = parseSDLToSchemaDefinitions(sdlString); + const models = {}; + + Object.keys(fieldsSchemas).forEach((typeName) => { + // If model already compiled on connection, delete to avoid OverwriteModelError + if (connection.models && connection.models[typeName]) { + delete connection.models[typeName]; + } + if (mongoose.models && mongoose.models[typeName]) { + delete mongoose.models[typeName]; + } + + const fields = fieldsSchemas[typeName]; + const schemaDef = { + id: { type: String, required: true, unique: true, index: true }, + }; + + // Convert fields to Mongoose schema types + Object.keys(fields).forEach((fName) => { + const fDef = fields[fName]; + let mType; + switch (fDef.type) { + case 'Int': + case 'Float': + mType = Number; + break; + case 'Boolean': + mType = Boolean; + break; + case 'Date': + mType = Date; + break; + case 'String': + case 'ID': + default: + mType = String; + break; + } + + const propDef = { type: mType }; + if (fDef.required) propDef.required = true; + if (fDef.unique) propDef.unique = true; + if (fDef.default !== undefined) propDef.default = fDef.default; + + schemaDef[fName] = propDef; + }); + + // Add relation fields representation in Mongoose schema + relations + .filter((r) => r.source === typeName) + .forEach((r) => { + if (r.isList) { + schemaDef[r.field] = [{ + typeId: { type: String }, + type: { type: String, default: r.target }, + }]; + } else { + schemaDef[r.field] = { + typeId: { type: String }, + type: { type: String, default: r.target }, + }; + } + }); + + const mSchema = new Schema(schemaDef, { + collection: typeName, + timestamps: true, + usePushEach: true, + autoIndex: false, + toJSON: { + transform: (doc, ret) => { + delete ret._id; + delete ret.__v; + return ret; + }, + }, + }); + + models[typeName] = connection.model(typeName, mSchema); + }); + + return models; +}; + +/** + * Builds a MongoDB filter query object from GraphQL filter input + * + * @param {Object} filterInput + * @returns {Object} MongoDB query filter object + */ +export const buildMongoFilterQuery = (filterInput) => { + if (!filterInput || Object.keys(filterInput).length === 0) return {}; + + const query = {}; + + // Handle AND / and + const andArray = filterInput.AND || filterInput.and; + if (Array.isArray(andArray) && andArray.length > 0) { + query.$and = andArray.map((f) => buildMongoFilterQuery(f)); + } + + // Handle OR / or + const orArray = filterInput.OR || filterInput.or; + if (Array.isArray(orArray) && orArray.length > 0) { + query.$or = orArray.map((f) => buildMongoFilterQuery(f)); + } + + Object.keys(filterInput).forEach((key) => { + if (['AND', 'OR', 'and', 'or'].includes(key)) return; + + const value = filterInput[key]; + + // id equality + if (key === 'id') { + query.id = value; + return; + } + + // field_in: [values] + if (key.endsWith('_in')) { + const field = key.slice(0, -3); + query[field] = { $in: value }; + return; + } + + // field_notIn: [values] + if (key.endsWith('_notIn')) { + const field = key.slice(0, -6); + query[field] = { $nin: value }; + return; + } + + // field_not: value + if (key.endsWith('_not')) { + const field = key.slice(0, -4); + query[field] = { $ne: value }; + return; + } + + // field_gt: value + if (key.endsWith('_gt')) { + const field = key.slice(0, -3); + query[field] = { ...query[field], $gt: value }; + return; + } + + // field_gte: value + if (key.endsWith('_gte')) { + const field = key.slice(0, -4); + query[field] = { ...query[field], $gte: value }; + return; + } + + // field_lt: value + if (key.endsWith('_lt')) { + const field = key.slice(0, -3); + query[field] = { ...query[field], $lt: value }; + return; + } + + // field_lte: value + if (key.endsWith('_lte')) { + const field = key.slice(0, -4); + query[field] = { ...query[field], $lte: value }; + return; + } + + // field_contains: value + if (key.endsWith('_contains')) { + const field = key.slice(0, -9); + query[field] = { $regex: value, $options: 'i' }; + return; + } + + // field_startsWith: value + if (key.endsWith('_startsWith')) { + const field = key.slice(0, -11); + query[field] = { $regex: `^${value}`, $options: 'i' }; + return; + } + + // field_exists: Boolean + if (key.endsWith('_exists')) { + const field = key.slice(0, -7); + query[field] = { $exists: Boolean(value) }; + return; + } + + // Exact equality: field: value + query[key] = value; + }); + + return query; +}; + +/** + * Builds MongoDB sort object from Sort enum string (e.g. 'name_ASC', 'createdAt_DESC') + * + * @param {string} sortString + * @returns {Object} MongoDB sort object (e.g. { name: 1 }) + */ +export const buildMongoSortOrder = (sortString) => { + if (!sortString || typeof sortString !== 'string') return {}; + + if (sortString.endsWith('_ASC')) { + const field = sortString.slice(0, -4); + return { [field]: 1 }; + } + if (sortString.endsWith('_DESC')) { + const field = sortString.slice(0, -5); + return { [field]: -1 }; + } + return {}; +}; + +/** + * Custom Date Scalar + */ +const DateScalar = new GraphQLScalarType({ + name: 'Date', + description: 'Date custom scalar type', + parseValue(value) { + return new Date(value); + }, + serialize(value) { + return value instanceof Date ? value.toISOString() : new Date(value).toISOString(); + }, + parseLiteral(ast) { + if (ast.kind === Kind.STRING || ast.kind === Kind.INT) { + return new Date(ast.value); + } + return null; + }, +}); + +/** + * Builds a complete executable GraphQL schema for the MongoDB test entities + * + * Exposes: + * - Queries: single (mongoUser), plural list (mongoUsers), count (mongoUsersMeta) + * - Mutations: addMongoUser, updateMongoUser, deleteMongoUser, deleteMongoProducts, addToMongoCompanyUsers, removeFromMongoCompanyUsers, etc. + * - Relational resolvers using request-scoped DataLoaders + * - Sorting enums and filter inputs matching AutoGraphQL's contract + * + * @param {Object} options + * @param {Object} options.models + * @param {string} [options.sdlString=mongoGraphQLSDL] + * @returns {GraphQLSchema} + */ +export const buildExecutableMongoSchema = ({ models, sdlString = mongoGraphQLSDL }) => { + const isEvolved = sdlString.includes('tagline: String'); + + const typeDefs = ` + scalar Date + + input MongoCompanyFilter { + AND: [MongoCompanyFilter] + OR: [MongoCompanyFilter] + and: [MongoCompanyFilter] + or: [MongoCompanyFilter] + id: ID + id_in: [ID] + id_not: ID + name: String + name_contains: String + name_startsWith: String + domain: String + industry: String + employeeCount: Int + employeeCount_gt: Int + employeeCount_gte: Int + employeeCount_lt: Int + employeeCount_lte: Int + isPublic: Boolean + isPublic_not: Boolean + isPublic_exists: Boolean + foundedAt: Date + foundedAt_gt: Date + foundedAt_lt: Date + } + + input MongoUserFilter { + AND: [MongoUserFilter] + OR: [MongoUserFilter] + and: [MongoUserFilter] + or: [MongoUserFilter] + id: ID + id_in: [ID] + id_not: ID + name: String + name_contains: String + name_startsWith: String + email: String + email_contains: String + email_not: String + age: Int + age_gt: Int + age_gte: Int + age_lt: Int + age_lte: Int + salary: Float + salary_gt: Float + salary_gte: Float + salary_lt: Float + salary_lte: Float + active: Boolean + active_not: Boolean + active_exists: Boolean + companyId: String + companyId_in: [String] + companyId_exists: Boolean + createdAt: Date + createdAt_gt: Date + createdAt_lt: Date + ${isEvolved ? 'tagline: String' : ''} + } + + input MongoPostFilter { + AND: [MongoPostFilter] + OR: [MongoPostFilter] + and: [MongoPostFilter] + or: [MongoPostFilter] + id: ID + id_in: [ID] + id_not: ID + title: String + title_contains: String + slug: String + content: String + content_contains: String + published: Boolean + published_not: Boolean + published_exists: Boolean + viewsCount: Int + viewsCount_gt: Int + viewsCount_gte: Int + viewsCount_lt: Int + viewsCount_lte: Int + rating: Float + rating_gt: Float + rating_lt: Float + authorId: String + companyId: String + createdAt: Date + } + + input MongoCommentFilter { + AND: [MongoCommentFilter] + OR: [MongoCommentFilter] + and: [MongoCommentFilter] + or: [MongoCommentFilter] + id: ID + id_in: [ID] + content: String + content_contains: String + upvotes: Int + upvotes_gt: Int + upvotes_lt: Int + postId: String + authorId: String + } + + input MongoProductFilter { + AND: [MongoProductFilter] + OR: [MongoProductFilter] + and: [MongoProductFilter] + or: [MongoProductFilter] + id: ID + id_in: [ID] + name: String + name_contains: String + price: Float + price_gt: Float + price_gte: Float + price_lt: Float + price_lte: Float + quantity: Int + quantity_gt: Int + quantity_lt: Int + active: Boolean + sku: String + } + + enum SortMongoCompany { + name_ASC + name_DESC + employeeCount_ASC + employeeCount_DESC + foundedAt_ASC + foundedAt_DESC + createdAt_ASC + createdAt_DESC + } + + enum SortMongoUser { + name_ASC + name_DESC + email_ASC + email_DESC + age_ASC + age_DESC + salary_ASC + salary_DESC + createdAt_ASC + createdAt_DESC + } + + enum SortMongoPost { + title_ASC + title_DESC + viewsCount_ASC + viewsCount_DESC + rating_ASC + rating_DESC + createdAt_ASC + createdAt_DESC + } + + enum SortMongoComment { + upvotes_ASC + upvotes_DESC + createdAt_ASC + createdAt_DESC + } + + enum SortMongoProduct { + name_ASC + name_DESC + price_ASC + price_DESC + quantity_ASC + quantity_DESC + createdAt_ASC + createdAt_DESC + } + + type AggregationResult { + count: Int + } + + input MongoCompanyInput { + name: String! + domain: String + industry: String + employeeCount: Int + isPublic: Boolean + foundedAt: Date + ${isEvolved ? 'headquarters: String' : ''} + } + + input MongoCompanyUpdate { + name: String + domain: String + industry: String + employeeCount: Int + isPublic: Boolean + foundedAt: Date + ${isEvolved ? 'headquarters: String' : ''} + } + + input MongoUserInput { + name: String! + email: String! + age: Int + salary: Float + active: Boolean + bio: String + companyId: String + lastLoginAt: Date + ${isEvolved ? 'tagline: String' : ''} + } + + input MongoUserUpdate { + name: String + email: String + age: Int + salary: Float + active: Boolean + bio: String + companyId: String + lastLoginAt: Date + ${isEvolved ? 'tagline: String' : ''} + } + + input MongoPostInput { + title: String! + slug: String! + content: String! + published: Boolean + viewsCount: Int + rating: Float + authorId: String + companyId: String + publishedAt: Date + } + + input MongoPostUpdate { + title: String + slug: String + content: String + published: Boolean + viewsCount: Int + rating: Float + authorId: String + companyId: String + publishedAt: Date + } + + input MongoCommentInput { + content: String! + upvotes: Int + postId: String + authorId: String + } + + input MongoCommentUpdate { + content: String + upvotes: Int + postId: String + authorId: String + } + + input MongoProductInput { + name: String! + price: Float! + quantity: Int + active: Boolean + sku: String + } + + input MongoProductUpdate { + name: String + price: Float + quantity: Int + active: Boolean + sku: String + } + + type MongoCompanyRelationPayload { + typeName: String + fieldName: String + connectedTypeName: String + connectedFieldName: String + } + + type MongoUserRelationPayload { + typeName: String + fieldName: String + connectedTypeName: String + connectedFieldName: String + } + + type MongoPostRelationPayload { + typeName: String + fieldName: String + connectedTypeName: String + connectedFieldName: String + } + + # Entity types + type MongoCompany { + id: ID + name: String! + domain: String + industry: String + employeeCount: Int + isPublic: Boolean + foundedAt: Date + createdAt: Date + updatedAt: Date + users: [MongoUser] + posts: [MongoPost] + ${isEvolved ? 'headquarters: String' : ''} + } + + type MongoUser { + id: ID + name: String! + email: String! + age: Int + salary: Float + active: Boolean + bio: String + companyId: String + createdAt: Date + updatedAt: Date + lastLoginAt: Date + company: MongoCompany + posts: [MongoPost] + comments: [MongoComment] + ${isEvolved ? 'tagline: String' : ''} + } + + type MongoPost { + id: ID + title: String! + slug: String! + content: String! + published: Boolean + viewsCount: Int + rating: Float + authorId: String! + companyId: String + createdAt: Date + updatedAt: Date + publishedAt: Date + author: MongoUser + company: MongoCompany + comments: [MongoComment] + } + + type MongoComment { + id: ID + content: String! + upvotes: Int + postId: String! + authorId: String! + createdAt: Date + updatedAt: Date + post: MongoPost + author: MongoUser + } + + type MongoProduct { + id: ID + name: String! + price: Float! + quantity: Int + active: Boolean + sku: String + createdAt: Date + updatedAt: Date + } + + type Query { + mongoCompany(id: ID, domain: String): MongoCompany + mongoCompanies(filter: MongoCompanyFilter, orderBy: SortMongoCompany, first: Int, skip: Int): [MongoCompany] + mongoCompaniesMeta(filter: MongoCompanyFilter): AggregationResult + + mongoUser(id: ID, email: String): MongoUser + mongoUsers(filter: MongoUserFilter, orderBy: SortMongoUser, first: Int, skip: Int): [MongoUser] + mongoUsersMeta(filter: MongoUserFilter): AggregationResult + + mongoPost(id: ID, slug: String): MongoPost + mongoPosts(filter: MongoPostFilter, orderBy: SortMongoPost, first: Int, skip: Int): [MongoPost] + mongoPostsMeta(filter: MongoPostFilter): AggregationResult + + mongoComment(id: ID): MongoComment + mongoComments(filter: MongoCommentFilter, orderBy: SortMongoComment, first: Int, skip: Int): [MongoComment] + mongoCommentsMeta(filter: MongoCommentFilter): AggregationResult + + mongoProduct(id: ID, sku: String): MongoProduct + mongoProducts(filter: MongoProductFilter, orderBy: SortMongoProduct, first: Int, skip: Int): [MongoProduct] + mongoProductsMeta(filter: MongoProductFilter): AggregationResult + } + + type Mutation { + # CREATE + addMongoCompany(input: MongoCompanyInput!): MongoCompany + addMongoUser(input: MongoUserInput!, companyConnectId: ID): MongoUser + addMongoPost(input: MongoPostInput!, authorConnectId: ID, companyConnectId: ID): MongoPost + addMongoComment(input: MongoCommentInput!, postConnectId: ID, authorConnectId: ID): MongoComment + addMongoProduct(input: MongoProductInput!): MongoProduct + + # UPDATE + updateMongoCompany(id: ID!, input: MongoCompanyUpdate): MongoCompany + updateMongoUser(id: ID!, input: MongoUserUpdate, companyConnectId: ID): MongoUser + updateMongoPost(id: ID!, input: MongoPostUpdate, authorConnectId: ID, companyConnectId: ID): MongoPost + updateMongoComment(id: ID!, input: MongoCommentUpdate, postConnectId: ID, authorConnectId: ID): MongoComment + updateMongoProduct(id: ID!, input: MongoProductUpdate): MongoProduct + + # DELETE + deleteMongoCompany(id: ID!): MongoCompany + deleteMongoUser(id: ID!): MongoUser + deleteMongoPost(id: ID!): MongoPost + deleteMongoComment(id: ID!): MongoComment + deleteMongoProduct(id: ID!): MongoProduct + deleteMongoProducts(filter: MongoProductFilter!): [MongoProduct] + + # RELATIONS (Connect / Disconnect) + addToMongoCompanyUsers(mongoCompanyId: ID!, mongoUserId: ID!): MongoCompanyRelationPayload + removeFromMongoCompanyUsers(mongoCompanyId: ID!, mongoUserId: ID!): MongoCompanyRelationPayload + addToMongoCompanyPosts(mongoCompanyId: ID!, mongoPostId: ID!): MongoCompanyRelationPayload + removeFromMongoCompanyPosts(mongoCompanyId: ID!, mongoPostId: ID!): MongoCompanyRelationPayload + addToMongoUserPosts(mongoUserId: ID!, mongoPostId: ID!): MongoUserRelationPayload + removeFromMongoUserPosts(mongoUserId: ID!, mongoPostId: ID!): MongoUserRelationPayload + addToMongoUserComments(mongoUserId: ID!, mongoCommentId: ID!): MongoUserRelationPayload + removeFromMongoUserComments(mongoUserId: ID!, mongoCommentId: ID!): MongoUserRelationPayload + addToMongoPostComments(mongoPostId: ID!, mongoCommentId: ID!): MongoPostRelationPayload + removeFromMongoPostComments(mongoPostId: ID!, mongoCommentId: ID!): MongoPostRelationPayload + } + `; + + // Resolvers + const resolvers = { + Date: DateScalar, + + Query: { + // Company + mongoCompany: async (_, { id, domain }, context) => { + if (id && context && context.loaders) { + const loader = context.loaders.getLoader('MongoCompany'); + if (loader) return loader.load(id); + } + const query = id ? { id } : { domain }; + return models.MongoCompany.findOne(query).lean().exec(); + }, + mongoCompanies: async (_, { filter, orderBy, first, skip }) => { + const query = buildMongoFilterQuery(filter); + const sort = buildMongoSortOrder(orderBy); + let q = models.MongoCompany.find(query); + if (Object.keys(sort).length > 0) q = q.sort(sort); + if (skip) q = q.skip(skip); + if (first) q = q.limit(first); + return q.lean().exec(); + }, + mongoCompaniesMeta: async (_, { filter }) => { + const query = buildMongoFilterQuery(filter); + const count = await models.MongoCompany.countDocuments(query).exec(); + return { count }; + }, + + // User + mongoUser: async (_, { id, email }, context) => { + if (id && context && context.loaders) { + const loader = context.loaders.getLoader('MongoUser'); + if (loader) return loader.load(id); + } + const query = id ? { id } : { email }; + return models.MongoUser.findOne(query).lean().exec(); + }, + mongoUsers: async (_, { filter, orderBy, first, skip }) => { + const query = buildMongoFilterQuery(filter); + const sort = buildMongoSortOrder(orderBy); + let q = models.MongoUser.find(query); + if (Object.keys(sort).length > 0) q = q.sort(sort); + if (skip) q = q.skip(skip); + if (first) q = q.limit(first); + return q.lean().exec(); + }, + mongoUsersMeta: async (_, { filter }) => { + const query = buildMongoFilterQuery(filter); + const count = await models.MongoUser.countDocuments(query).exec(); + return { count }; + }, + + // Post + mongoPost: async (_, { id, slug }, context) => { + if (id && context && context.loaders) { + const loader = context.loaders.getLoader('MongoPost'); + if (loader) return loader.load(id); + } + const query = id ? { id } : { slug }; + return models.MongoPost.findOne(query).lean().exec(); + }, + mongoPosts: async (_, { filter, orderBy, first, skip }) => { + const query = buildMongoFilterQuery(filter); + const sort = buildMongoSortOrder(orderBy); + let q = models.MongoPost.find(query); + if (Object.keys(sort).length > 0) q = q.sort(sort); + if (skip) q = q.skip(skip); + if (first) q = q.limit(first); + return q.lean().exec(); + }, + mongoPostsMeta: async (_, { filter }) => { + const query = buildMongoFilterQuery(filter); + const count = await models.MongoPost.countDocuments(query).exec(); + return { count }; + }, + + // Comment + mongoComment: async (_, { id }, context) => { + if (id && context && context.loaders) { + const loader = context.loaders.getLoader('MongoComment'); + if (loader) return loader.load(id); + } + return models.MongoComment.findOne({ id }).lean().exec(); + }, + mongoComments: async (_, { filter, orderBy, first, skip }) => { + const query = buildMongoFilterQuery(filter); + const sort = buildMongoSortOrder(orderBy); + let q = models.MongoComment.find(query); + if (Object.keys(sort).length > 0) q = q.sort(sort); + if (skip) q = q.skip(skip); + if (first) q = q.limit(first); + return q.lean().exec(); + }, + mongoCommentsMeta: async (_, { filter }) => { + const query = buildMongoFilterQuery(filter); + const count = await models.MongoComment.countDocuments(query).exec(); + return { count }; + }, + + // Product + mongoProduct: async (_, { id, sku }, context) => { + if (id && context && context.loaders) { + const loader = context.loaders.getLoader('MongoProduct'); + if (loader) return loader.load(id); + } + const query = id ? { id } : { sku }; + return models.MongoProduct.findOne(query).lean().exec(); + }, + mongoProducts: async (_, { filter, orderBy, first, skip }) => { + const query = buildMongoFilterQuery(filter); + const sort = buildMongoSortOrder(orderBy); + let q = models.MongoProduct.find(query); + if (Object.keys(sort).length > 0) q = q.sort(sort); + if (skip) q = q.skip(skip); + if (first) q = q.limit(first); + return q.lean().exec(); + }, + mongoProductsMeta: async (_, { filter }) => { + const query = buildMongoFilterQuery(filter); + const count = await models.MongoProduct.countDocuments(query).exec(); + return { count }; + }, + }, + + Mutation: { + // Company CREATE + addMongoCompany: async (_, { input }) => { + const recordId = cuid(); + const doc = new models.MongoCompany({ + id: recordId, + ...input, + }); + const saved = await doc.save(); + return saved.toJSON(); + }, + updateMongoCompany: async (_, { id, input }) => { + const updated = await models.MongoCompany.findOneAndUpdate( + { id }, + { $set: input }, + { new: true, runValidators: true }, + ).lean().exec(); + return updated; + }, + deleteMongoCompany: async (_, { id }) => { + const deleted = await models.MongoCompany.findOneAndDelete({ id }).lean().exec(); + return deleted; + }, + + // User CREATE + addMongoUser: async (_, { input, companyConnectId }) => { + const recordId = cuid(); + const docData = { + id: recordId, + ...input, + }; + if (companyConnectId) { + docData.companyId = companyConnectId; + docData.company = { typeId: companyConnectId, type: 'MongoCompany' }; + } + const doc = new models.MongoUser(docData); + const saved = await doc.save(); + + // Update company reverse relation if companyConnectId + if (companyConnectId) { + await models.MongoCompany.findOneAndUpdate( + { id: companyConnectId }, + { $addToSet: { users: { typeId: recordId, type: 'MongoUser' } } }, + ).exec(); + } + + return saved.toJSON(); + }, + updateMongoUser: async (_, { id, input, companyConnectId }) => { + const updateData = { ...(input || {}) }; + if (companyConnectId) { + updateData.companyId = companyConnectId; + updateData.company = { typeId: companyConnectId, type: 'MongoCompany' }; + } + const updated = await models.MongoUser.findOneAndUpdate( + { id }, + { $set: updateData }, + { new: true, runValidators: true }, + ).lean().exec(); + return updated; + }, + deleteMongoUser: async (_, { id }) => { + const deleted = await models.MongoUser.findOneAndDelete({ id }).lean().exec(); + return deleted; + }, + + // Post CREATE + addMongoPost: async (_, { input, authorConnectId, companyConnectId }) => { + const recordId = cuid(); + const docData = { + id: recordId, + ...input, + }; + if (authorConnectId) { + docData.authorId = authorConnectId; + docData.author = { typeId: authorConnectId, type: 'MongoUser' }; + } + if (companyConnectId) { + docData.companyId = companyConnectId; + docData.company = { typeId: companyConnectId, type: 'MongoCompany' }; + } + const doc = new models.MongoPost(docData); + const saved = await doc.save(); + + // Reverse relations update + if (authorConnectId) { + await models.MongoUser.findOneAndUpdate( + { id: authorConnectId }, + { $addToSet: { posts: { typeId: recordId, type: 'MongoPost' } } }, + ).exec(); + } + if (companyConnectId) { + await models.MongoCompany.findOneAndUpdate( + { id: companyConnectId }, + { $addToSet: { posts: { typeId: recordId, type: 'MongoPost' } } }, + ).exec(); + } + + return saved.toJSON(); + }, + updateMongoPost: async (_, { id, input, authorConnectId, companyConnectId }) => { + const updateData = { ...(input || {}) }; + if (authorConnectId) { + updateData.authorId = authorConnectId; + updateData.author = { typeId: authorConnectId, type: 'MongoUser' }; + } + if (companyConnectId) { + updateData.companyId = companyConnectId; + updateData.company = { typeId: companyConnectId, type: 'MongoCompany' }; + } + const updated = await models.MongoPost.findOneAndUpdate( + { id }, + { $set: updateData }, + { new: true, runValidators: true }, + ).lean().exec(); + return updated; + }, + deleteMongoPost: async (_, { id }) => { + const deleted = await models.MongoPost.findOneAndDelete({ id }).lean().exec(); + return deleted; + }, + + // Comment CREATE + addMongoComment: async (_, { input, postConnectId, authorConnectId }) => { + const recordId = cuid(); + const docData = { + id: recordId, + ...input, + }; + if (postConnectId) { + docData.postId = postConnectId; + docData.post = { typeId: postConnectId, type: 'MongoPost' }; + } + if (authorConnectId) { + docData.authorId = authorConnectId; + docData.author = { typeId: authorConnectId, type: 'MongoUser' }; + } + const doc = new models.MongoComment(docData); + const saved = await doc.save(); + + if (postConnectId) { + await models.MongoPost.findOneAndUpdate( + { id: postConnectId }, + { $addToSet: { comments: { typeId: recordId, type: 'MongoComment' } } }, + ).exec(); + } + if (authorConnectId) { + await models.MongoUser.findOneAndUpdate( + { id: authorConnectId }, + { $addToSet: { comments: { typeId: recordId, type: 'MongoComment' } } }, + ).exec(); + } + + return saved.toJSON(); + }, + updateMongoComment: async (_, { id, input, postConnectId, authorConnectId }) => { + const updateData = { ...(input || {}) }; + if (postConnectId) { + updateData.postId = postConnectId; + updateData.post = { typeId: postConnectId, type: 'MongoPost' }; + } + if (authorConnectId) { + updateData.authorId = authorConnectId; + updateData.author = { typeId: authorConnectId, type: 'MongoUser' }; + } + const updated = await models.MongoComment.findOneAndUpdate( + { id }, + { $set: updateData }, + { new: true, runValidators: true }, + ).lean().exec(); + return updated; + }, + deleteMongoComment: async (_, { id }) => { + const deleted = await models.MongoComment.findOneAndDelete({ id }).lean().exec(); + return deleted; + }, + + // Product CREATE + addMongoProduct: async (_, { input }) => { + const recordId = cuid(); + const doc = new models.MongoProduct({ + id: recordId, + ...input, + }); + const saved = await doc.save(); + return saved.toJSON(); + }, + updateMongoProduct: async (_, { id, input }) => { + const updated = await models.MongoProduct.findOneAndUpdate( + { id }, + { $set: input }, + { new: true, runValidators: true }, + ).lean().exec(); + return updated; + }, + deleteMongoProduct: async (_, { id }) => { + const deleted = await models.MongoProduct.findOneAndDelete({ id }).lean().exec(); + return deleted; + }, + deleteMongoProducts: async (_, { filter }) => { + const query = buildMongoFilterQuery(filter); + const toDelete = await models.MongoProduct.find(query).lean().exec(); + await models.MongoProduct.deleteMany(query).exec(); + return toDelete; + }, + + // Connect Mutations + addToMongoCompanyUsers: async (_, { mongoCompanyId, mongoUserId }) => { + await models.MongoUser.findOneAndUpdate( + { id: mongoUserId }, + { $set: { companyId: mongoCompanyId, company: { typeId: mongoCompanyId, type: 'MongoCompany' } } }, + ).exec(); + await models.MongoCompany.findOneAndUpdate( + { id: mongoCompanyId }, + { $addToSet: { users: { typeId: mongoUserId, type: 'MongoUser' } } }, + ).exec(); + return { + typeName: 'MongoCompany', + fieldName: 'users', + connectedTypeName: 'MongoUser', + connectedFieldName: 'company', + }; + }, + removeFromMongoCompanyUsers: async (_, { mongoCompanyId, mongoUserId }) => { + await models.MongoUser.findOneAndUpdate( + { id: mongoUserId }, + { $unset: { companyId: '', company: '' } }, + ).exec(); + await models.MongoCompany.findOneAndUpdate( + { id: mongoCompanyId }, + { $pull: { users: { typeId: mongoUserId } } }, + ).exec(); + return { + typeName: 'MongoCompany', + fieldName: 'users', + connectedTypeName: 'MongoUser', + connectedFieldName: 'company', + }; + }, + + addToMongoCompanyPosts: async (_, { mongoCompanyId, mongoPostId }) => { + await models.MongoPost.findOneAndUpdate( + { id: mongoPostId }, + { $set: { companyId: mongoCompanyId, company: { typeId: mongoCompanyId, type: 'MongoCompany' } } }, + ).exec(); + await models.MongoCompany.findOneAndUpdate( + { id: mongoCompanyId }, + { $addToSet: { posts: { typeId: mongoPostId, type: 'MongoPost' } } }, + ).exec(); + return { + typeName: 'MongoCompany', + fieldName: 'posts', + connectedTypeName: 'MongoPost', + connectedFieldName: 'company', + }; + }, + removeFromMongoCompanyPosts: async (_, { mongoCompanyId, mongoPostId }) => { + await models.MongoPost.findOneAndUpdate( + { id: mongoPostId }, + { $unset: { companyId: '', company: '' } }, + ).exec(); + await models.MongoCompany.findOneAndUpdate( + { id: mongoCompanyId }, + { $pull: { posts: { typeId: mongoPostId } } }, + ).exec(); + return { + typeName: 'MongoCompany', + fieldName: 'posts', + connectedTypeName: 'MongoPost', + connectedFieldName: 'company', + }; + }, + + addToMongoUserPosts: async (_, { mongoUserId, mongoPostId }) => { + await models.MongoPost.findOneAndUpdate( + { id: mongoPostId }, + { $set: { authorId: mongoUserId, author: { typeId: mongoUserId, type: 'MongoUser' } } }, + ).exec(); + await models.MongoUser.findOneAndUpdate( + { id: mongoUserId }, + { $addToSet: { posts: { typeId: mongoPostId, type: 'MongoPost' } } }, + ).exec(); + return { + typeName: 'MongoUser', + fieldName: 'posts', + connectedTypeName: 'MongoPost', + connectedFieldName: 'author', + }; + }, + removeFromMongoUserPosts: async (_, { mongoUserId, mongoPostId }) => { + await models.MongoPost.findOneAndUpdate( + { id: mongoPostId }, + { $unset: { authorId: '', author: '' } }, + ).exec(); + await models.MongoUser.findOneAndUpdate( + { id: mongoUserId }, + { $pull: { posts: { typeId: mongoPostId } } }, + ).exec(); + return { + typeName: 'MongoUser', + fieldName: 'posts', + connectedTypeName: 'MongoPost', + connectedFieldName: 'author', + }; + }, + + addToMongoUserComments: async (_, { mongoUserId, mongoCommentId }) => { + await models.MongoComment.findOneAndUpdate( + { id: mongoCommentId }, + { $set: { authorId: mongoUserId, author: { typeId: mongoUserId, type: 'MongoUser' } } }, + ).exec(); + await models.MongoUser.findOneAndUpdate( + { id: mongoUserId }, + { $addToSet: { comments: { typeId: mongoCommentId, type: 'MongoComment' } } }, + ).exec(); + return { + typeName: 'MongoUser', + fieldName: 'comments', + connectedTypeName: 'MongoComment', + connectedFieldName: 'author', + }; + }, + removeFromMongoUserComments: async (_, { mongoUserId, mongoCommentId }) => { + await models.MongoComment.findOneAndUpdate( + { id: mongoCommentId }, + { $unset: { authorId: '', author: '' } }, + ).exec(); + await models.MongoUser.findOneAndUpdate( + { id: mongoUserId }, + { $pull: { comments: { typeId: mongoCommentId } } }, + ).exec(); + return { + typeName: 'MongoUser', + fieldName: 'comments', + connectedTypeName: 'MongoComment', + connectedFieldName: 'author', + }; + }, + + addToMongoPostComments: async (_, { mongoPostId, mongoCommentId }) => { + await models.MongoComment.findOneAndUpdate( + { id: mongoCommentId }, + { $set: { postId: mongoPostId, post: { typeId: mongoPostId, type: 'MongoPost' } } }, + ).exec(); + await models.MongoPost.findOneAndUpdate( + { id: mongoPostId }, + { $addToSet: { comments: { typeId: mongoCommentId, type: 'MongoComment' } } }, + ).exec(); + return { + typeName: 'MongoPost', + fieldName: 'comments', + connectedTypeName: 'MongoComment', + connectedFieldName: 'post', + }; + }, + removeFromMongoPostComments: async (_, { mongoPostId, mongoCommentId }) => { + await models.MongoComment.findOneAndUpdate( + { id: mongoCommentId }, + { $unset: { postId: '', post: '' } }, + ).exec(); + await models.MongoPost.findOneAndUpdate( + { id: mongoPostId }, + { $pull: { comments: { typeId: mongoCommentId } } }, + ).exec(); + return { + typeName: 'MongoPost', + fieldName: 'comments', + connectedTypeName: 'MongoComment', + connectedFieldName: 'post', + }; + }, + }, + + // Relational Field Resolvers + MongoCompany: { + users: async (company) => { + return models.MongoUser.find({ + $or: [ + { companyId: company.id }, + { 'company.typeId': company.id }, + ], + }).lean().exec(); + }, + posts: async (company) => { + return models.MongoPost.find({ + $or: [ + { companyId: company.id }, + { 'company.typeId': company.id }, + ], + }).lean().exec(); + }, + }, + + MongoUser: { + company: async (user, _, context) => { + const cId = user.companyId || (user.company && user.company.typeId); + if (!cId) return null; + if (context && context.loaders) { + const loader = context.loaders.getLoader('MongoCompany'); + if (loader) return loader.load(cId); + } + return models.MongoCompany.findOne({ id: cId }).lean().exec(); + }, + posts: async (user) => { + return models.MongoPost.find({ + $or: [ + { authorId: user.id }, + { 'author.typeId': user.id }, + ], + }).lean().exec(); + }, + comments: async (user) => { + return models.MongoComment.find({ + $or: [ + { authorId: user.id }, + { 'author.typeId': user.id }, + ], + }).lean().exec(); + }, + }, + + MongoPost: { + author: async (post, _, context) => { + const uId = post.authorId || (post.author && post.author.typeId); + if (!uId) return null; + if (context && context.loaders) { + const loader = context.loaders.getLoader('MongoUser'); + if (loader) return loader.load(uId); + } + return models.MongoUser.findOne({ id: uId }).lean().exec(); + }, + company: async (post, _, context) => { + const cId = post.companyId || (post.company && post.company.typeId); + if (!cId) return null; + if (context && context.loaders) { + const loader = context.loaders.getLoader('MongoCompany'); + if (loader) return loader.load(cId); + } + return models.MongoCompany.findOne({ id: cId }).lean().exec(); + }, + comments: async (post) => { + return models.MongoComment.find({ + $or: [ + { postId: post.id }, + { 'post.typeId': post.id }, + ], + }).lean().exec(); + }, + }, + + MongoComment: { + post: async (comment, _, context) => { + const pId = comment.postId || (comment.post && comment.post.typeId); + if (!pId) return null; + if (context && context.loaders) { + const loader = context.loaders.getLoader('MongoPost'); + if (loader) return loader.load(pId); + } + return models.MongoPost.findOne({ id: pId }).lean().exec(); + }, + author: async (comment, _, context) => { + const uId = comment.authorId || (comment.author && comment.author.typeId); + if (!uId) return null; + if (context && context.loaders) { + const loader = context.loaders.getLoader('MongoUser'); + if (loader) return loader.load(uId); + } + return models.MongoUser.findOne({ id: uId }).lean().exec(); + }, + }, + }; + + return makeExecutableSchema({ + typeDefs, + resolvers, + }); +}; From 0d361185cc1cf171d46fcd5faadc0c9ce5ba6722 Mon Sep 17 00:00:00 2001 From: Naman Mukund Date: Tue, 22 Sep 2026 20:45:40 +0530 Subject: [PATCH 2/2] fix(mongo): ensure unique constraints and index initialization across clean CI environments --- test/mongo/setup.js | 7 ++++++ test/mongo/testSchema.js | 48 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+) diff --git a/test/mongo/setup.js b/test/mongo/setup.js index 08118deb..e7c56036 100644 --- a/test/mongo/setup.js +++ b/test/mongo/setup.js @@ -89,6 +89,13 @@ export const startMongoTestEnvironment = async (sdl = mongoGraphQLSDL, forceNew } testModels = createMongooseModelsFromAST(sdl, testConnection); + // Ensure indexes (such as unique constraints) are initialized + for (const m of Object.values(testModels)) { + if (m && typeof m.init === 'function') { + await m.init().catch(() => {}); + } + } + executableSchema = buildExecutableMongoSchema({ models: testModels, sdlString: sdl }); return { diff --git a/test/mongo/testSchema.js b/test/mongo/testSchema.js index 672be989..f9bece29 100644 --- a/test/mongo/testSchema.js +++ b/test/mongo/testSchema.js @@ -1098,6 +1098,12 @@ export const buildExecutableMongoSchema = ({ models, sdlString = mongoGraphQLSDL Mutation: { // Company CREATE addMongoCompany: async (_, { input }) => { + if (input.domain) { + const existing = await models.MongoCompany.findOne({ domain: input.domain }).lean().exec(); + if (existing) { + throw new Error(`Duplicate value for unique field domain: ${input.domain}`); + } + } const recordId = cuid(); const doc = new models.MongoCompany({ id: recordId, @@ -1107,6 +1113,12 @@ export const buildExecutableMongoSchema = ({ models, sdlString = mongoGraphQLSDL return saved.toJSON(); }, updateMongoCompany: async (_, { id, input }) => { + if (input && input.domain) { + const existing = await models.MongoCompany.findOne({ domain: input.domain, id: { $ne: id } }).lean().exec(); + if (existing) { + throw new Error(`Duplicate value for unique field domain: ${input.domain}`); + } + } const updated = await models.MongoCompany.findOneAndUpdate( { id }, { $set: input }, @@ -1121,6 +1133,12 @@ export const buildExecutableMongoSchema = ({ models, sdlString = mongoGraphQLSDL // User CREATE addMongoUser: async (_, { input, companyConnectId }) => { + if (input.email) { + const existing = await models.MongoUser.findOne({ email: input.email }).lean().exec(); + if (existing) { + throw new Error(`Duplicate value for unique field email: ${input.email}`); + } + } const recordId = cuid(); const docData = { id: recordId, @@ -1144,6 +1162,12 @@ export const buildExecutableMongoSchema = ({ models, sdlString = mongoGraphQLSDL return saved.toJSON(); }, updateMongoUser: async (_, { id, input, companyConnectId }) => { + if (input && input.email) { + const existing = await models.MongoUser.findOne({ email: input.email, id: { $ne: id } }).lean().exec(); + if (existing) { + throw new Error(`Duplicate value for unique field email: ${input.email}`); + } + } const updateData = { ...(input || {}) }; if (companyConnectId) { updateData.companyId = companyConnectId; @@ -1163,6 +1187,12 @@ export const buildExecutableMongoSchema = ({ models, sdlString = mongoGraphQLSDL // Post CREATE addMongoPost: async (_, { input, authorConnectId, companyConnectId }) => { + if (input.slug) { + const existing = await models.MongoPost.findOne({ slug: input.slug }).lean().exec(); + if (existing) { + throw new Error(`Duplicate value for unique field slug: ${input.slug}`); + } + } const recordId = cuid(); const docData = { id: recordId, @@ -1196,6 +1226,12 @@ export const buildExecutableMongoSchema = ({ models, sdlString = mongoGraphQLSDL return saved.toJSON(); }, updateMongoPost: async (_, { id, input, authorConnectId, companyConnectId }) => { + if (input && input.slug) { + const existing = await models.MongoPost.findOne({ slug: input.slug, id: { $ne: id } }).lean().exec(); + if (existing) { + throw new Error(`Duplicate value for unique field slug: ${input.slug}`); + } + } const updateData = { ...(input || {}) }; if (authorConnectId) { updateData.authorId = authorConnectId; @@ -1274,6 +1310,12 @@ export const buildExecutableMongoSchema = ({ models, sdlString = mongoGraphQLSDL // Product CREATE addMongoProduct: async (_, { input }) => { + if (input.sku) { + const existing = await models.MongoProduct.findOne({ sku: input.sku }).lean().exec(); + if (existing) { + throw new Error(`Duplicate value for unique field sku: ${input.sku}`); + } + } const recordId = cuid(); const doc = new models.MongoProduct({ id: recordId, @@ -1283,6 +1325,12 @@ export const buildExecutableMongoSchema = ({ models, sdlString = mongoGraphQLSDL return saved.toJSON(); }, updateMongoProduct: async (_, { id, input }) => { + if (input && input.sku) { + const existing = await models.MongoProduct.findOne({ sku: input.sku, id: { $ne: id } }).lean().exec(); + if (existing) { + throw new Error(`Duplicate value for unique field sku: ${input.sku}`); + } + } const updated = await models.MongoProduct.findOneAndUpdate( { id }, { $set: input },