diff --git a/client/components/CandidateReview/TestPlans/index.jsx b/client/components/CandidateReview/TestPlans/index.jsx index d6760e1f9..fea18e87b 100644 --- a/client/components/CandidateReview/TestPlans/index.jsx +++ b/client/components/CandidateReview/TestPlans/index.jsx @@ -21,6 +21,7 @@ import ClippedProgressBar from '@components/common/ClippedProgressBar'; import { dates } from 'shared'; import './TestPlans.css'; import { calculations } from 'shared'; +import { UserPropType } from '../../common/proptypes'; const FullHeightContainer = styled(Container)` min-height: calc(100vh - 64px); @@ -177,7 +178,7 @@ const None = styled.span` } `; -const TestPlans = ({ testPlanVersions }) => { +const TestPlans = ({ testPlanVersions, me }) => { const [atExpandTableItems, setAtExpandTableItems] = useState({ 1: true, 2: true, @@ -503,20 +504,39 @@ const TestPlans = ({ testPlanVersions }) => { .filter(t => t.isCandidateReview === true) .filter(t => uniqueFilter(t, uniqueLinks, 'link')); + const canReview = + me.roles.includes('ADMIN') || + (me.roles.includes('VENDOR') && + me.company.ats.some(at => at.id === atId)); + + const getTitleEl = () => { + if (canReview) { + return ( + + {getTestPlanVersionTitle(testPlanVersion, { + includeVersionString: true + })}{' '} + ({testsCount} Test + {testsCount === 0 || testsCount > 1 ? `s` : ''}) + + ); + } + return ( + <> + {getTestPlanVersionTitle(testPlanVersion, { + includeVersionString: true + })}{' '} + ({testsCount} Test + {testsCount === 0 || testsCount > 1 ? `s` : ''}) + + ); + }; return ( dataExists && ( - - - {getTestPlanVersionTitle(testPlanVersion, { - includeVersionString: true - })}{' '} - ({testsCount} Test - {testsCount === 0 || testsCount > 1 ? `s` : ''}) - - + {getTitleEl()} {dates.convertDateToString( @@ -778,6 +798,7 @@ TestPlans.propTypes = { ) }) ).isRequired, + me: UserPropType.isRequired, triggerPageUpdate: PropTypes.func }; diff --git a/client/components/CandidateReview/index.jsx b/client/components/CandidateReview/index.jsx index ba0ded88f..db9d2aad9 100644 --- a/client/components/CandidateReview/index.jsx +++ b/client/components/CandidateReview/index.jsx @@ -3,12 +3,16 @@ import { useQuery } from '@apollo/client'; import PageStatus from '../common/PageStatus'; import TestPlans from './TestPlans'; import { CANDIDATE_REVIEW_PAGE_QUERY } from './queries'; +import { ME_QUERY } from '../App/queries'; const CandidateReview = () => { const { loading, data, error } = useQuery(CANDIDATE_REVIEW_PAGE_QUERY, { fetchPolicy: 'cache-and-network' }); + const { data: meData } = useQuery(ME_QUERY); + const { me } = meData; + if (error) { return ( { const testPlanVersions = data.testPlanVersions; - return ; + return ; }; export default CandidateReview; diff --git a/client/components/ConfirmAuth/index.jsx b/client/components/ConfirmAuth/index.jsx index c39bd0104..484bde1e7 100644 --- a/client/components/ConfirmAuth/index.jsx +++ b/client/components/ConfirmAuth/index.jsx @@ -1,15 +1,17 @@ import React from 'react'; -import { Navigate } from 'react-router-dom'; +import { Navigate, useParams } from 'react-router-dom'; import PropTypes from 'prop-types'; import { useQuery } from '@apollo/client'; import { ME_QUERY } from '../App/queries'; import { evaluateAuth } from '../../utils/evaluateAuth'; -const ConfirmAuth = ({ children, requiredPermission }) => { +const ConfirmAuth = ({ children, requiredPermission, requireVendorForAt }) => { const { data } = useQuery(ME_QUERY); + const { atId } = useParams(); const auth = evaluateAuth(data && data.me ? data.me : {}); const { roles, username, isAdmin, isSignedIn } = auth; + const company = data && data.me ? data.me.company : null; if (!username) return ; @@ -21,6 +23,14 @@ const ConfirmAuth = ({ children, requiredPermission }) => { return ; } + // Check if the user's company is the vendor for the associated At + if (requireVendorForAt && !isAdmin) { + const isVendorForAt = company && company.ats.some(at => at.id === atId); + if (!isVendorForAt) { + return ; + } + } + return children; }; @@ -29,7 +39,8 @@ ConfirmAuth.propTypes = { PropTypes.arrayOf(PropTypes.node), PropTypes.node ]).isRequired, - requiredPermission: PropTypes.string + requiredPermission: PropTypes.string, + requireVendorForAt: PropTypes.bool }; export default ConfirmAuth; diff --git a/client/components/common/fragments/Me.js b/client/components/common/fragments/Me.js index 128e7072d..dcdf9adeb 100644 --- a/client/components/common/fragments/Me.js +++ b/client/components/common/fragments/Me.js @@ -6,6 +6,12 @@ const ME_FIELDS = gql` id username roles + company { + id + ats { + id + } + } } `; diff --git a/client/index.js b/client/index.js index 5d19d83a4..50c4ece39 100644 --- a/client/index.js +++ b/client/index.js @@ -51,7 +51,20 @@ window.signMeInAsTester = username => { }; window.signMeInAsVendor = username => { - return signMeInCommon({ username, roles: [{ name: 'VENDOR' }] }); + return signMeInCommon({ + username, + roles: [{ name: 'VENDOR' }], + company: { + id: '1', + name: 'vispero', + ats: [ + { + id: '1', + name: 'JAWS' + } + ] + } + }); }; window.startTestTransaction = async () => { diff --git a/client/routes/index.js b/client/routes/index.js index 89a1c600b..2f28f7afa 100644 --- a/client/routes/index.js +++ b/client/routes/index.js @@ -33,7 +33,7 @@ export default () => ( exact path="/candidate-test-plan/:testPlanVersionId/:atId" element={ - + } diff --git a/client/tests/__mocks__/GraphQLMocks/TestQueuePageAdminNotPopulatedMock.js b/client/tests/__mocks__/GraphQLMocks/TestQueuePageAdminNotPopulatedMock.js index 32c8de0cf..423268e4d 100644 --- a/client/tests/__mocks__/GraphQLMocks/TestQueuePageAdminNotPopulatedMock.js +++ b/client/tests/__mocks__/GraphQLMocks/TestQueuePageAdminNotPopulatedMock.js @@ -9,7 +9,12 @@ export default testQueuePageQuery => [ __typename: 'User', id: '1', username: 'foo-bar', - roles: ['ADMIN', 'TESTER'] + roles: ['ADMIN', 'TESTER'], + company: { + id: '1', + name: 'Company', + ats: [] + } }, users: [ { @@ -18,7 +23,12 @@ export default testQueuePageQuery => [ username: 'foo-bar', roles: ['ADMIN', 'TESTER'], isBot: false, - ats: [] + ats: [], + company: { + id: '1', + name: 'Company', + ats: [] + } }, { __typename: 'User', @@ -26,7 +36,12 @@ export default testQueuePageQuery => [ username: 'bar-foo', roles: ['TESTER'], isBot: false, - ats: [] + ats: [], + company: { + id: '1', + name: 'Company', + ats: [] + } }, { __typename: 'User', @@ -34,7 +49,12 @@ export default testQueuePageQuery => [ username: 'boo-far', roles: ['TESTER'], isBot: false, - ats: [] + ats: [], + company: { + id: '1', + name: 'Company', + ats: [] + } } ], ats: [], diff --git a/client/tests/__mocks__/GraphQLMocks/TestQueuePageTesterNotPopulatedMock.js b/client/tests/__mocks__/GraphQLMocks/TestQueuePageTesterNotPopulatedMock.js index 186f6ac4f..403698f61 100644 --- a/client/tests/__mocks__/GraphQLMocks/TestQueuePageTesterNotPopulatedMock.js +++ b/client/tests/__mocks__/GraphQLMocks/TestQueuePageTesterNotPopulatedMock.js @@ -9,7 +9,12 @@ export default testQueuePageQuery => [ __typename: 'User', id: '4', username: 'bar-foo', - roles: ['TESTER'] + roles: ['TESTER'], + company: { + id: '1', + name: 'Company', + ats: [] + } }, users: [ { @@ -18,7 +23,12 @@ export default testQueuePageQuery => [ username: 'foo-bar', roles: ['ADMIN', 'TESTER'], isBot: false, - ats: [] + ats: [], + company: { + id: '1', + name: 'Company', + ats: [] + } }, { __typename: 'User', @@ -26,7 +36,12 @@ export default testQueuePageQuery => [ username: 'bar-foo', roles: ['TESTER'], isBot: false, - ats: [] + ats: [], + company: { + id: '1', + name: 'Company', + ats: [] + } }, { __typename: 'User', @@ -34,7 +49,12 @@ export default testQueuePageQuery => [ username: 'boo-far', roles: ['TESTER'], isBot: false, - ats: [] + ats: [], + company: { + id: '1', + name: 'Company', + ats: [] + } } ], ats: [], diff --git a/client/tests/e2e/snapshots/saved/_candidate-review.html b/client/tests/e2e/snapshots/saved/_candidate-review.html index 4fb2ede26..068c908d6 100644 --- a/client/tests/e2e/snapshots/saved/_candidate-review.html +++ b/client/tests/e2e/snapshots/saved/_candidate-review.html @@ -127,7 +127,23 @@

Jul 6, 2022 Jan 2, 2023 - + + Changes requested for 1 test +

0 Open - Issues1 Open + Issue diff --git a/server/controllers/AuthController.js b/server/controllers/AuthController.js index 18f36508a..52e46d32b 100644 --- a/server/controllers/AuthController.js +++ b/server/controllers/AuthController.js @@ -1,7 +1,15 @@ const { User } = require('../models'); -const { getOrCreateUser } = require('../models/services/UserService'); +const { + getOrCreateUser, + addUserVendor, + getUserById +} = require('../models/services/UserService'); const { GithubService } = require('../services'); const getUsersFromFile = require('../util/getUsersFromFile'); +const { + findVendorByName, + getOrCreateVendor +} = require('../models/services/VendorService'); const APP_SERVER = process.env.APP_SERVER; @@ -70,6 +78,40 @@ const oauthRedirectFromGithubController = async (req, res) => { transaction: req.transaction }); + if (roles.some(role => role.name === User.VENDOR)) { + const vendorEntry = vendors.find( + vendor => vendor.split('|')[0] === githubUsername + ); + if (vendorEntry) { + const [, companyName] = vendorEntry.split('|').trim(); + const vendor = await findVendorByName({ + name: companyName, + transaction: req.transaction + }); + if (vendor) { + await addUserVendor(user.id, vendor.id, { + transaction: req.transaction + }); + } else { + const vendor = await getOrCreateVendor({ + where: { name: companyName }, + transaction: req.transaction + }); + await addUserVendor(user.id, vendor.id, { + transaction: req.transaction + }); + } + // Fetch the user again with vendor and AT information + user = await getUserById({ + id: user.id, + vendorAttributes: ['id', 'name'], + atAttributes: ['id', 'name'], + includeVendorAts: true, + transaction: req.transaction + }); + } + } + req.session.user = user; return loginSucceeded(); diff --git a/server/controllers/FakeUserController.js b/server/controllers/FakeUserController.js index ab870ff03..ca64c337c 100644 --- a/server/controllers/FakeUserController.js +++ b/server/controllers/FakeUserController.js @@ -1,4 +1,12 @@ -const { getOrCreateUser } = require('../models/services/UserService'); +const { + getOrCreateUser, + addUserVendor, + getUserById +} = require('../models/services/UserService'); +const { + getOrCreateVendor, + updateVendorById +} = require('../models/services/VendorService'); const ALLOW_FAKE_USER = process.env.ALLOW_FAKE_USER === 'true'; @@ -25,6 +33,29 @@ const setFakeUserController = async (req, res) => { transaction: req.transaction }); + if (userToCreate.company) { + const [vendor] = await getOrCreateVendor({ + where: { name: userToCreate.company.name }, + transaction: req.transaction + }); + + await addUserVendor(user.id, vendor.id, { transaction: req.transaction }); + await updateVendorById({ + id: vendor.id, + ats: userToCreate.company.ats, + transaction: req.transaction + }); + + // Refresh user to include updated associations + user = await getUserById({ + id: user.id, + vendorAttributes: ['id', 'name'], + atAttributes: ['id', 'name'], + includeVendorAts: true, + transaction: req.transaction + }); + } + req.session.user = user; res.status(200).send(''); diff --git a/server/graphql-schema.js b/server/graphql-schema.js index dd70988cc..4ae520528 100644 --- a/server/graphql-schema.js +++ b/server/graphql-schema.js @@ -59,6 +59,10 @@ const graphqlSchema = gql` The ATs the user has indicated they are able to test. """ ats: [At]! + """ + The vendor the user is associated with. + """ + company: Vendor } """ @@ -1256,6 +1260,18 @@ const graphqlSchema = gql` directory: String ): [TestPlanVersion]! """ + Get a vendor by ID. + """ + vendor(id: ID!): Vendor + """ + Get a vendor by name. + """ + vendorByName(name: String!): Vendor + """ + Get all vendors. + """ + vendors: [Vendor]! + """ Get a particular TestPlanVersion by ID. """ testPlanVersion(id: ID): TestPlanVersion @@ -1478,6 +1494,28 @@ const graphqlSchema = gql` retryCanceledCollections: CollectionJob! } + """ + Vendor company that makes an AT + """ + type Vendor { + """ + Postgres-provided numeric ID. + """ + id: ID! + """ + The name of the vendor company. + """ + name: String! + """ + The ATs associated with this vendor. + """ + ats: [At]! + """ + The users associated with this vendor. + """ + users: [User]! + } + type Mutation { """ Get the available mutations for the given AT. diff --git a/server/migrations/20240903164204-add-vendor-table.js b/server/migrations/20240903164204-add-vendor-table.js new file mode 100644 index 000000000..1eaaad688 --- /dev/null +++ b/server/migrations/20240903164204-add-vendor-table.js @@ -0,0 +1,55 @@ +'use strict'; + +module.exports = { + up: async (queryInterface, Sequelize) => { + await queryInterface.createTable('Vendor', { + id: { + type: Sequelize.INTEGER, + allowNull: false, + primaryKey: true, + autoIncrement: true + }, + name: { + type: Sequelize.TEXT, + allowNull: false, + unique: true + }, + createdAt: { + type: Sequelize.DATE, + allowNull: false + }, + updatedAt: { + type: Sequelize.DATE, + allowNull: false + } + }); + + await queryInterface.addColumn('User', 'vendorId', { + type: Sequelize.INTEGER, + allowNull: true, + references: { + model: 'Vendor', + key: 'id' + }, + onUpdate: 'CASCADE', + onDelete: 'SET NULL' + }); + + await queryInterface.addColumn('At', 'vendorId', { + type: Sequelize.INTEGER, + allowNull: true, + references: { + model: 'Vendor', + key: 'id' + }, + onUpdate: 'CASCADE', + onDelete: 'SET NULL' + }); + }, + + down: async queryInterface => { + await queryInterface.removeColumn('At', 'vendorId'); + await queryInterface.removeColumn('User', 'vendorId'); + await queryInterface.dropTable('Vendor'); + } +}; diff --git a/server/models/User.js b/server/models/User.js index 0ab297316..ae5d84972 100644 --- a/server/models/User.js +++ b/server/models/User.js @@ -37,6 +37,8 @@ module.exports = function (sequelize, DataTypes) { Model.ATS_ASSOCIATION = { through: 'UserAts', as: 'ats' }; Model.TEST_PLAN_RUN_ASSOCIATION = { as: 'testPlanRuns' }; + Model.VENDOR_ASSOCIATION = { as: 'company' }; + Model.associate = function (models) { Model.belongsToMany(models.Role, { ...Model.ROLE_ASSOCIATION, @@ -55,6 +57,11 @@ module.exports = function (sequelize, DataTypes) { foreignKey: 'testerUserId', sourceKey: 'id' }); + + Model.belongsTo(models.Vendor, { + ...Model.VENDOR_ASSOCIATION, + foreignKey: 'vendorId' + }); }; return Model; diff --git a/server/models/Vendor.js b/server/models/Vendor.js new file mode 100644 index 000000000..7507ecf47 --- /dev/null +++ b/server/models/Vendor.js @@ -0,0 +1,43 @@ +const MODEL_NAME = 'Vendor'; + +module.exports = function (sequelize, DataTypes) { + const Model = sequelize.define( + MODEL_NAME, + { + id: { + type: DataTypes.INTEGER, + allowNull: false, + primaryKey: true, + autoIncrement: true + }, + name: { + type: DataTypes.TEXT, + allowNull: false, + unique: true + } + }, + { + timestamps: true, + tableName: MODEL_NAME + } + ); + + Model.AT_ASSOCIATION = { as: 'ats' }; + Model.USER_ASSOCIATION = { as: 'users' }; + + Model.associate = function (models) { + Model.hasMany(models.At, { + as: 'ats', + foreignKey: 'vendorId', + sourceKey: 'id' + }); + + Model.hasMany(models.User, { + ...Model.USER_ASSOCIATION, + foreignKey: 'vendorId', + sourceKey: 'id' + }); + }; + + return Model; +}; diff --git a/server/models/services/UserService.js b/server/models/services/UserService.js index a07019104..68a3f2e7b 100644 --- a/server/models/services/UserService.js +++ b/server/models/services/UserService.js @@ -41,6 +41,15 @@ const testPlanRunAssociation = testPlanRunAttributes => ({ attributes: testPlanRunAttributes }); +/** + * @param vendorAttributes - Vendor attributes + * @returns {{association: string, attributes: string[]}} + */ +const vendorAssociation = vendorAttributes => ({ + association: 'company', + attributes: vendorAttributes +}); + /** * You can pass any of the attribute arrays as '[]' to exclude that related association * @param {object} options @@ -58,16 +67,33 @@ const getUserById = async ({ roleAttributes = ROLE_ATTRIBUTES, atAttributes = AT_ATTRIBUTES, testPlanRunAttributes = TEST_PLAN_RUN_ATTRIBUTES, + vendorAttributes = [], + includeVendorAts = false, transaction }) => { + const include = [ + roleAssociation(roleAttributes), + atAssociation(atAttributes), + testPlanRunAssociation(testPlanRunAttributes) + ]; + + if (vendorAttributes.length > 0) { + const vendorInclude = vendorAssociation(vendorAttributes); + if (includeVendorAts) { + vendorInclude.include = [ + { + association: 'ats', + attributes: atAttributes + } + ]; + } + include.push(vendorInclude); + } + return ModelService.getById(User, { id, attributes: userAttributes, - include: [ - roleAssociation(roleAttributes), - atAssociation(atAttributes), - testPlanRunAssociation(testPlanRunAttributes) - ], + include, transaction }); }; @@ -89,6 +115,7 @@ const getUserByUsername = async ({ roleAttributes = ROLE_ATTRIBUTES, atAttributes = AT_ATTRIBUTES, testPlanRunAttributes = TEST_PLAN_RUN_ATTRIBUTES, + vendorAttributes = [], transaction }) => { return ModelService.getByQuery(User, { @@ -97,7 +124,8 @@ const getUserByUsername = async ({ include: [ roleAssociation(roleAttributes), atAssociation(atAttributes), - testPlanRunAssociation(testPlanRunAttributes) + testPlanRunAssociation(testPlanRunAttributes), + vendorAssociation(vendorAttributes) ], transaction }); @@ -126,6 +154,7 @@ const getUsers = async ({ roleAttributes = ROLE_ATTRIBUTES, atAttributes = AT_ATTRIBUTES, testPlanRunAttributes = TEST_PLAN_RUN_ATTRIBUTES, + vendorAttributes = [], pagination = {}, transaction }) => { @@ -141,7 +170,8 @@ const getUsers = async ({ include: [ roleAssociation(roleAttributes), atAssociation(atAttributes), - testPlanRunAssociation(testPlanRunAttributes) + testPlanRunAssociation(testPlanRunAttributes), + vendorAssociation(vendorAttributes) ], pagination, transaction @@ -247,6 +277,7 @@ const createUser = async ({ roleAttributes = ROLE_ATTRIBUTES, atAttributes = AT_ATTRIBUTES, testPlanRunAttributes = TEST_PLAN_RUN_ATTRIBUTES, + vendorAttributes = [], transaction }) => { const userResult = await ModelService.create(User, { @@ -262,7 +293,8 @@ const createUser = async ({ include: [ roleAssociation(roleAttributes), atAssociation(atAttributes), - testPlanRunAssociation(testPlanRunAttributes) + testPlanRunAssociation(testPlanRunAttributes), + vendorAssociation(vendorAttributes) ], transaction }); @@ -286,6 +318,7 @@ const updateUserById = async ({ roleAttributes = ROLE_ATTRIBUTES, atAttributes = AT_ATTRIBUTES, testPlanRunAttributes = TEST_PLAN_RUN_ATTRIBUTES, + vendorAttributes = [], transaction }) => { await ModelService.update(User, { @@ -300,7 +333,8 @@ const updateUserById = async ({ include: [ roleAssociation(roleAttributes), atAssociation(atAttributes), - testPlanRunAssociation(testPlanRunAttributes) + testPlanRunAssociation(testPlanRunAttributes), + vendorAssociation(vendorAttributes) ], transaction }); @@ -412,6 +446,7 @@ const getOrCreateUser = async ({ roleAttributes = ROLE_ATTRIBUTES, atAttributes = AT_ATTRIBUTES, testPlanRunAttributes = TEST_PLAN_RUN_ATTRIBUTES, + vendorAttributes = [], transaction }) => { const accumulatedResults = await ModelService.nestedGetOrCreate({ @@ -443,6 +478,7 @@ const getOrCreateUser = async ({ roleAttributes, atAttributes, testPlanRunAttributes, + vendorAttributes, transaction }); @@ -480,6 +516,22 @@ const removeUserRole = async (userId, roleName, { transaction }) => { }); }; +/** + * @param {number} userId - id of the User that the vendor will be added to + * @param {number} vendorId - id of the Vendor that the User will be associated with + * @param {object} options + * @param {*} options.transaction - Sequelize transaction + * @returns {Promise<*>} + */ +const addUserVendor = async (userId, vendorId, { transaction }) => { + // Set the company Vendor association for the user + return ModelService.update(User, { + where: { id: userId }, + values: { vendorId }, + transaction + }); +}; + module.exports = { // Basic CRUD getUserById, @@ -497,5 +549,6 @@ module.exports = { // Custom Functions addUserRole, - removeUserRole + removeUserRole, + addUserVendor }; diff --git a/server/models/services/VendorService.js b/server/models/services/VendorService.js new file mode 100644 index 000000000..3997545aa --- /dev/null +++ b/server/models/services/VendorService.js @@ -0,0 +1,195 @@ +const ModelService = require('./ModelService'); +const { + VENDOR_ATTRIBUTES, + AT_ATTRIBUTES, + USER_ATTRIBUTES +} = require('./helpers'); +const { Vendor } = require('../'); +const { Op } = require('sequelize'); + +// Association helpers + +const atAssociation = atAttributes => ({ + association: 'ats', + attributes: atAttributes +}); + +const userAssociation = userAttributes => ({ + association: 'users', + attributes: userAttributes +}); + +/** + * @param {object} options + * @param {number} options.id - id of Vendor to be retrieved + * @param {string[]} options.vendorAttributes - Vendor attributes to be returned in the result + * @param {string[]} options.atAttributes - AT attributes to be returned in the result + * @param {string[]} options.userAttributes - User attributes to be returned in the result + * @param {*} options.transaction - Sequelize transaction + * @returns {Promise<*>} + */ +const findVendorById = async ({ + id, + vendorAttributes = VENDOR_ATTRIBUTES, + atAttributes = AT_ATTRIBUTES, + userAttributes = USER_ATTRIBUTES, + transaction +}) => { + return ModelService.getById(Vendor, { + id, + attributes: vendorAttributes, + include: [atAssociation(atAttributes), userAssociation(userAttributes)], + transaction + }); +}; + +/** + * @param {object} options + * @param {string} options.name - name of Vendor to be retrieved + * @param {string[]} options.vendorAttributes - Vendor attributes to be returned in the result + * @param {string[]} options.atAttributes - AT attributes to be returned in the result + * @param {string[]} options.userAttributes - User attributes to be returned in the result + * @param {*} options.transaction - Sequelize transaction + * @returns {Promise<*>} + */ +const findVendorByName = async ({ + name, + vendorAttributes = VENDOR_ATTRIBUTES, + atAttributes = AT_ATTRIBUTES, + userAttributes = USER_ATTRIBUTES, + transaction +}) => { + return ModelService.getByQuery(Vendor, { + where: { name }, + attributes: vendorAttributes, + include: [atAssociation(atAttributes), userAssociation(userAttributes)], + transaction + }); +}; + +/** + * @param {object} options + * @param {string|any} options.search - use this to combine with {@param filter} to be passed to Sequelize's where clause + * @param {object} options.where - use this define conditions to be passed to Sequelize's where clause + * @param {string[]} options.vendorAttributes - Vendor attributes to be returned in the result + * @param {string[]} options.atAttributes - AT attributes to be returned in the result + * @param {string[]} options.userAttributes - User attributes to be returned in the result + * @param {object} options.pagination - pagination options for query + * @param {*} options.transaction - Sequelize transaction + * @returns {Promise<*>} + */ +const findAllVendors = async ({ + search, + where = {}, + vendorAttributes = VENDOR_ATTRIBUTES, + atAttributes = AT_ATTRIBUTES, + userAttributes = USER_ATTRIBUTES, + pagination = {}, + transaction +}) => { + const searchQuery = search ? `%${search}%` : ''; + if (searchQuery) { + where = { ...where, name: { [Op.iLike]: searchQuery } }; + } + + return ModelService.get(Vendor, { + where, + attributes: vendorAttributes, + include: [atAssociation(atAttributes), userAssociation(userAttributes)], + pagination, + transaction + }); +}; + +/** + * @param {object} options + * @param {object} options.where - conditions to find or create the Vendor + * @param {string[]} options.vendorAttributes - Vendor attributes to be returned in the result + * @param {*} options.transaction - Sequelize transaction + * @returns {Promise<[*, boolean]>} + */ +const getOrCreateVendor = async ({ + where, + vendorAttributes = VENDOR_ATTRIBUTES, + transaction +}) => { + const [vendor, created] = await Vendor.findOrCreate({ + where, + attributes: vendorAttributes, + transaction + }); + return [vendor, created]; +}; + +/** + * @param {object} options + * @param {object} options.values - values to be used to create the Vendor + * @param {string[]} options.vendorAttributes - Vendor attributes to be returned in the result + * @param {*} options.transaction - Sequelize transaction + * @returns {Promise<*>} + */ +const createVendor = async ({ + values, + vendorAttributes = VENDOR_ATTRIBUTES, + transaction +}) => { + const vendorResult = await ModelService.create(Vendor, { + values, + transaction + }); + const { id } = vendorResult; + + return ModelService.getById(Vendor, { + id, + attributes: vendorAttributes, + transaction + }); +}; + +/** + * @param {object} options + * @param {number} options.id - id of the Vendor record to be updated + * @param {object} options.values - values to be used to update columns for the record + * @param {string[]} options.vendorAttributes - Vendor attributes to be returned in the result + * @param {*} options.transaction - Sequelize transaction + * @returns {Promise<*>} + */ +const updateVendorById = async ({ + id, + values, + vendorAttributes = VENDOR_ATTRIBUTES, + transaction +}) => { + await ModelService.update(Vendor, { + where: { id }, + values, + transaction + }); + + return ModelService.getById(Vendor, { + id, + attributes: vendorAttributes, + transaction + }); +}; + +/** + * @param {object} options + * @param {number} options.id - id of the Vendor record to be removed + * @param {boolean} options.truncate - Sequelize specific deletion options that could be passed + * @param {*} options.transaction - Sequelize transaction + * @returns {Promise} + */ +const removeVendorById = async ({ id, truncate = false, transaction }) => { + return ModelService.removeById(Vendor, { id, truncate, transaction }); +}; + +module.exports = { + findVendorById, + findVendorByName, + findAllVendors, + getOrCreateVendor, + createVendor, + updateVendorById, + removeVendorById +}; diff --git a/server/models/services/helpers.js b/server/models/services/helpers.js index 402787495..54a0930ca 100644 --- a/server/models/services/helpers.js +++ b/server/models/services/helpers.js @@ -12,7 +12,8 @@ const { UserRoles, UserAts, CollectionJob, - CollectionJobTestStatus + CollectionJobTestStatus, + Vendor } = require('../index'); /** @@ -43,5 +44,6 @@ module.exports = { COLLECTION_JOB_ATTRIBUTES: getSequelizeModelAttributes(CollectionJob), COLLECTION_JOB_TEST_STATUS_ATTRIBUTES: getSequelizeModelAttributes( CollectionJobTestStatus - ) + ), + VENDOR_ATTRIBUTES: getSequelizeModelAttributes(Vendor) }; diff --git a/server/resolvers/index.js b/server/resolvers/index.js index 58bfd1d80..7642ad198 100644 --- a/server/resolvers/index.js +++ b/server/resolvers/index.js @@ -10,6 +10,9 @@ const testPlanVersion = require('./testPlanVersionResolver'); const testPlanVersions = require('./testPlanVersionsResolver'); const testPlanRun = require('./testPlanRunResolver'); const testPlanRuns = require('./testPlanRunsResolver'); +const vendors = require('./vendorsResolver'); +const vendor = require('./vendorResolver'); +const vendorByName = require('./vendorByNameResolver'); const createTestPlanReport = require('./createTestPlanReportResolver'); const addViewer = require('./addViewerResolver'); const mutateAt = require('./mutateAtResolver'); @@ -64,7 +67,10 @@ const resolvers = { populateData, collectionJob, collectionJobs, - collectionJobByTestPlanRunId + collectionJobByTestPlanRunId, + vendors, + vendor, + vendorByName }, Mutation: { at: mutateAt, diff --git a/server/resolvers/vendorByNameResolver.js b/server/resolvers/vendorByNameResolver.js new file mode 100644 index 000000000..fcc6cb6ba --- /dev/null +++ b/server/resolvers/vendorByNameResolver.js @@ -0,0 +1,7 @@ +const { findVendorByName } = require('../models/services/VendorService'); + +const vendorByNameResolver = async (_, { name }, { transaction }) => { + return findVendorByName({ name, transaction }); +}; + +module.exports = vendorByNameResolver; diff --git a/server/resolvers/vendorResolver.js b/server/resolvers/vendorResolver.js new file mode 100644 index 000000000..a52a0b411 --- /dev/null +++ b/server/resolvers/vendorResolver.js @@ -0,0 +1,8 @@ +const { findVendorById } = require('../models/services/VendorService'); + +const vendorResolver = async (_, { id }, { transaction }) => { + // No auth since the vendors.txt file is public + return findVendorById({ id, transaction }); +}; + +module.exports = vendorResolver; diff --git a/server/resolvers/vendorsResolver.js b/server/resolvers/vendorsResolver.js new file mode 100644 index 000000000..25c9412f1 --- /dev/null +++ b/server/resolvers/vendorsResolver.js @@ -0,0 +1,8 @@ +const { findAllVendors } = require('../models/services/VendorService'); + +const vendorsResolver = async (_, __, { transaction }) => { + // No auth since the vendors.txt file is public + return findAllVendors({ transaction }); +}; + +module.exports = vendorsResolver; diff --git a/server/seeders/20240903165406-add-vendors.js b/server/seeders/20240903165406-add-vendors.js new file mode 100644 index 000000000..5041d66f3 --- /dev/null +++ b/server/seeders/20240903165406-add-vendors.js @@ -0,0 +1,64 @@ +'use strict'; + +const { VENDOR_NAME_TO_AT_MAPPING } = require('../util/constants'); +const getUsersFromFile = require('../util/getUsersFromFile'); + +module.exports = { + async up(queryInterface, Sequelize) { + const vendorLines = await getUsersFromFile('vendors.txt'); + + for (const line of vendorLines) { + const [username, companyName] = line.split('|'); + if (username && companyName) { + // Create vendor if it doesn't exist + const [vendor] = await queryInterface.sequelize.query( + `INSERT INTO "Vendor" (name, "createdAt", "updatedAt") + VALUES (:name, NOW(), NOW()) + ON CONFLICT (name) DO UPDATE SET name = :name + RETURNING id`, + { + replacements: { name: companyName }, + type: Sequelize.QueryTypes.INSERT + } + ); + + // Associate user with vendor if user exists + await queryInterface.sequelize.query( + `UPDATE "User" SET "vendorId" = :vendorId + WHERE username = :username`, + { + replacements: { vendorId: vendor[0].id, username }, + type: Sequelize.QueryTypes.UPDATE + } + ); + + if (VENDOR_NAME_TO_AT_MAPPING[companyName]) { + for (const atName of VENDOR_NAME_TO_AT_MAPPING[companyName]) { + await queryInterface.sequelize.query( + `UPDATE "At" SET "vendorId" = :vendorId + WHERE name = :atName`, + { + replacements: { vendorId: vendor[0].id, atName }, + type: Sequelize.QueryTypes.UPDATE + } + ); + } + } + } + } + }, + + async down(queryInterface, Sequelize) { + const vendorLines = await getUsersFromFile('vendors.txt'); + + await queryInterface.sequelize.query( + `DELETE FROM "Vendor" WHERE name IN (:vendorNames)`, + { + replacements: { + vendorNames: vendorLines.map(line => line.split('|')[1]) + }, + type: Sequelize.QueryTypes.DELETE + } + ); + } +}; diff --git a/server/tests/integration/graphql.test.js b/server/tests/integration/graphql.test.js index 7ffbf8c69..6c498662d 100644 --- a/server/tests/integration/graphql.test.js +++ b/server/tests/integration/graphql.test.js @@ -153,6 +153,7 @@ describe('graphql', () => { ['CollectionJob', 'testPlanRun'], ['CollectionJob', 'externalLogsUrl'], ['CollectionJob', 'testStatus'], + ['User', 'company'], // These interact with Response Scheduler API // which is mocked in other tests. ['Mutation', 'scheduleCollectionJob'], @@ -242,6 +243,10 @@ describe('graphql', () => { username roles isBot + company { + id + name + } } me { __typename @@ -252,6 +257,10 @@ describe('graphql', () => { id name } + company { + id + name + } } collectionJob(id: 1) { __typename @@ -271,6 +280,18 @@ describe('graphql', () => { id status } + vendors { + id + name + } + vendor(id: 1) { + id + name + } + vendorByName(name: "apple") { + id + name + } v2TestPlanVersion: testPlanVersion(id: 80) { __typename id diff --git a/server/tests/models/At.spec.js b/server/tests/models/At.spec.js index e9d8c7101..8ea1ddc65 100644 --- a/server/tests/models/At.spec.js +++ b/server/tests/models/At.spec.js @@ -8,6 +8,7 @@ const { const AtModel = require('../../models/At'); const AtVersionModel = require('../../models/AtVersion'); const BrowserModel = require('../../models/Browser'); +const VendorModel = require('../../models/Vendor'); describe('AtModel', () => { // A1 @@ -26,18 +27,27 @@ describe('AtModel', () => { // A1 const AT_VERSION_ASSOCIATION = { as: 'atVersions' }; const BROWSER_ASSOCIATION = { through: 'AtBrowsers', as: 'browsers' }; + const VENDOR_ASSOCIATION = { as: 'vendor' }; // A2 beforeAll(() => { Model.hasMany(AtVersionModel, AT_VERSION_ASSOCIATION); Model.hasMany(BrowserModel, BROWSER_ASSOCIATION); + Model.belongsTo(VendorModel, VENDOR_ASSOCIATION); }); it('defined a hasMany association with AtVersion', () => { // A3 expect(Model.hasMany).toHaveBeenCalledWith( AtVersionModel, - expect.objectContaining(Model.AT_VERSION_ASSOCIATION) + expect.objectContaining(AT_VERSION_ASSOCIATION) + ); + }); + + it('defined a belongsTo association with Vendor', () => { + expect(Model.belongsTo).toHaveBeenCalledWith( + VendorModel, + expect.objectContaining(VENDOR_ASSOCIATION) ); }); }); diff --git a/server/tests/models/User.spec.js b/server/tests/models/User.spec.js index b67c776e0..927229709 100644 --- a/server/tests/models/User.spec.js +++ b/server/tests/models/User.spec.js @@ -8,6 +8,7 @@ const { const UserModel = require('../../models/User'); const RoleModel = require('../../models/Role'); const TestPlanRunModel = require('../../models/TestPlanRun'); +const VendorModel = require('../../models/Vendor'); describe('UserModel Schema Checks', () => { // A1 @@ -28,11 +29,13 @@ describe('UserModel Schema Checks', () => { // A1 const ROLE_ASSOCIATION = { through: 'UserRoles', as: 'roles' }; const TEST_PLAN_RUN_ASSOCIATION = { as: 'testPlanRuns' }; + const VENDOR_ASSOCIATION = { as: 'company' }; // A2 beforeAll(() => { Model.belongsToMany(RoleModel, ROLE_ASSOCIATION); Model.hasMany(TestPlanRunModel, TEST_PLAN_RUN_ASSOCIATION); + Model.belongsTo(VendorModel, VENDOR_ASSOCIATION); }); // A3 @@ -49,5 +52,12 @@ describe('UserModel Schema Checks', () => { expect.objectContaining(Model.TEST_PLAN_RUN_ASSOCIATION) ); }); + + it('defined a belongsTo association with Vendor', () => { + expect(Model.belongsTo).toHaveBeenCalledWith( + VendorModel, + expect.objectContaining(Model.VENDOR_ASSOCIATION) + ); + }); }); }); diff --git a/server/tests/models/Vendor.spec.js b/server/tests/models/Vendor.spec.js new file mode 100644 index 000000000..e177ae98d --- /dev/null +++ b/server/tests/models/Vendor.spec.js @@ -0,0 +1,45 @@ +const { + sequelize, + dataTypes, + checkModelName, + checkPropertyExists +} = require('sequelize-test-helpers'); + +const VendorModel = require('../../models/Vendor'); +const AtModel = require('../../models/At'); +const UserModel = require('../../models/User'); + +describe('VendorModel', () => { + const Model = VendorModel(sequelize, dataTypes); + const modelInstance = new Model(); + + checkModelName(Model)('Vendor'); + + describe('properties', () => { + ['name'].forEach(checkPropertyExists(modelInstance)); + }); + + describe('associations', () => { + const AT_ASSOCIATION = { as: 'ats' }; + const USER_ASSOCIATION = { as: 'users' }; + + beforeAll(() => { + Model.hasMany(AtModel, AT_ASSOCIATION); + Model.hasMany(UserModel, USER_ASSOCIATION); + }); + + it('defined a hasMany association with At', () => { + expect(Model.hasMany).toHaveBeenCalledWith( + AtModel, + expect.objectContaining(Model.AT_ASSOCIATION) + ); + }); + + it('defined a hasMany association with User', () => { + expect(Model.hasMany).toHaveBeenCalledWith( + UserModel, + expect.objectContaining(Model.USER_ASSOCIATION) + ); + }); + }); +}); diff --git a/server/tests/models/services/VendorService.test.js b/server/tests/models/services/VendorService.test.js new file mode 100644 index 000000000..089c0068e --- /dev/null +++ b/server/tests/models/services/VendorService.test.js @@ -0,0 +1,74 @@ +const { sequelize } = require('../../../models'); +const VendorService = require('../../../models/services/VendorService'); +const randomStringGenerator = require('../../util/random-character-generator'); +const dbCleaner = require('../../util/db-cleaner'); + +afterAll(async () => { + await sequelize.close(); +}); + +describe('VendorService', () => { + it('should create and retrieve a vendor', async () => { + await dbCleaner(async transaction => { + const vendorName = randomStringGenerator(); + + const createdVendor = await VendorService.createVendor({ + values: { name: vendorName }, + transaction + }); + + expect(createdVendor).toHaveProperty('id'); + expect(createdVendor.name).toBe(vendorName); + + const retrievedVendor = await VendorService.findVendorById({ + id: createdVendor.id, + transaction + }); + + expect(retrievedVendor).toMatchObject(createdVendor); + }); + }); + + it('should update a vendor', async () => { + await dbCleaner(async transaction => { + const vendorName = randomStringGenerator(); + const updatedName = randomStringGenerator(); + + const createdVendor = await VendorService.createVendor({ + values: { name: vendorName }, + transaction + }); + + const updatedVendor = await VendorService.updateVendorById({ + id: createdVendor.id, + values: { name: updatedName }, + transaction + }); + + expect(updatedVendor.name).toBe(updatedName); + }); + }); + + it('should delete a vendor', async () => { + await dbCleaner(async transaction => { + const vendorName = randomStringGenerator(); + + const createdVendor = await VendorService.createVendor({ + values: { name: vendorName }, + transaction + }); + + await VendorService.removeVendorById({ + id: createdVendor.id, + transaction + }); + + const deletedVendor = await VendorService.findVendorById({ + id: createdVendor.id, + transaction + }); + + expect(deletedVendor).toBeNull(); + }); + }); +}); diff --git a/server/util/constants.js b/server/util/constants.js index d6c90d026..27c126e41 100644 --- a/server/util/constants.js +++ b/server/util/constants.js @@ -9,7 +9,14 @@ const AT_VERSIONS_SUPPORTED_BY_COLLECTION_JOBS = { NVDA: ['2024.1', '2023.3.3', '2023.3'] }; +const VENDOR_NAME_TO_AT_MAPPING = { + vispero: ['JAWS'], + nvAccess: ['NVDA'], + apple: ['VoiceOver for macOS'] +}; + module.exports = { NO_OUTPUT_STRING, - AT_VERSIONS_SUPPORTED_BY_COLLECTION_JOBS + AT_VERSIONS_SUPPORTED_BY_COLLECTION_JOBS, + VENDOR_NAME_TO_AT_MAPPING };