Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@
## v1.1.0

### Added
- Added `guidanceText` and `sampleText` fields to `addQuestionCustomization` and added `json`, `questionText`, `requirementText`, `guidanceText`, `sampleText`, `useSampleTextAsDefault` and `required` to `addCustomQuestionInput` [#130]
- Added `questionCustomizationByVersionedQuestion` resolver [#130]
- Added `findByCustomizationAndVersionedQuestion` method to `QuestionCustomization` model [#130]
- Added some opensearch variables for running it locally in docker-compose.yaml [#118]
- Added opensearch to the docker compose file
- Added OpenSearch integration for full-text search of re3data repositories
Expand Down Expand Up @@ -96,6 +99,8 @@
- Removed `ioredis` package

### Fixed
- Had issue running `nuke-db.sh` and `process.sh`, so I turned off SSL by using `--ssl=off` instead
- Fixed `fetchTemplateData` query in `TemplateCustomization` model because `questionCustomizationHasSampleText` was incorrectly returning true [#130]
- In `preparePaginationOptions` function, wrapped each cursorField with `COALESCE` to handle `NULL` values in SQL `CONCAT`, otherwise if any cursorField is NULL, it would just return a null value due to the way `CONCAT` works [#107]
- Fixed breaking cloning of template. The `addTemplate` was updated to accept a `copyFromVersionedTemplateId` so that we copy from versioned template, section and questions, when it's not a template from the user's org. Otherwise we check for `copyFromTemplateId` to copy/clone from templates, sections and questions, and if neither are present, we continue to create a new record for `templates` table [#1006]
- Fixed issue with templates not cloning with sections and questions by updating the `addTemplate` mutation to clone from non-versioned template, section and question [#1006]
Expand Down
7 changes: 5 additions & 2 deletions data-migrations/nuke-db.sh
Original file line number Diff line number Diff line change
Expand Up @@ -44,9 +44,12 @@ drop_tables() {
fi

# If we are running locally, the MySQL cert will exist in /etc
# if [ -f "/etc/mysql-cert.pem" ]; then
# MIGRATION_ARGS="${MIGRATION_ARGS} --ssl-ca=/etc/mysql-cert.pem --ssl-verify-server-cert=OFF"
# fi
if [ -f "/etc/mysql-cert.pem" ]; then
MIGRATION_ARGS="${MIGRATION_ARGS} --ssl-ca=/etc/mysql-cert.pem --ssl-verify-server-cert=OFF"
fi
MIGRATION_ARGS="${MIGRATION_ARGS} --ssl=off"
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The -ssl-verify-server-cert=OFF" was not working for me and the nuke-db.sh was breaking so I switched to using --ssl=off for both this and process.sh and it worked

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you leave the --ssl-verify-server-cert=OFF commented out below the line?

I'm not sure why our mariadb would be different, but I'd prefer to keep that there in case I need to use it on mine

fi

mariadb $MIGRATION_ARGS -N $1 <<< "$FKEY_OFF"

Expand Down
2 changes: 1 addition & 1 deletion data-migrations/process.sh
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ process_migration() {

# If we are running locally, the MySQL cert will exist in /etc
if [ -f "/etc/mysql-cert.pem" ]; then
MIGRATION_ARGS="${MIGRATION_ARGS} --ssl-ca=/etc/mysql-cert.pem --ssl-verify-server-cert=OFF"
MIGRATION_ARGS="${MIGRATION_ARGS} --ssl=off"
fi

EXISTS=$(mariadb ${MIGRATION_ARGS} -N ${1} <<< "SELECT * FROM dataMigrations WHERE migrationFile = '$2';")
Expand Down
29 changes: 28 additions & 1 deletion src/models/QuestionCustomization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ export class QuestionCustomization extends MySqlModel {
this.addError('templateCustomizationId', 'Customization can\'t be blank');
}
if (isNullOrUndefined(this.questionId)) {
this.addError('questionId','Question can\'t be blank');
this.addError('questionId', 'Question can\'t be blank');
}

return Object.keys(this.errors).length === 0;
Expand Down Expand Up @@ -215,6 +215,33 @@ export class QuestionCustomization extends MySqlModel {
return Array.isArray(results) && results.length > 0 ? new QuestionCustomization(results[0]) : undefined;
}


/**
* Find the customization by the customization and versioned question
*
* @param reference The reference to use for logging errors.
* @param context The Apollo context.
* @param templateCustomizatonId The id of the template customization.
* @param versionedQuestionId The versioned question id.
* @returns The Question customization.
*/
static async findByCustomizationAndVersionedQuestion(
reference: string,
context: MyContext,
templateCustomizatonId: number,
versionedQuestionId: number
): Promise<QuestionCustomization> {
const results = await QuestionCustomization.query(
context,
`SELECT qc.* FROM ${QuestionCustomization.tableName} qc
INNER JOIN versionedQuestions vq ON qc.questionId = vq.questionId
WHERE qc.templateCustomizationId = ? AND vq.id = ?`,
[templateCustomizatonId.toString(), versionedQuestionId?.toString()],
reference
);
return Array.isArray(results) && results.length > 0 ? new QuestionCustomization(results[0]) : undefined;
}

/**
* Find all the question customizations for a specific template customization
*
Expand Down
61 changes: 31 additions & 30 deletions src/models/TemplateCustomization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -426,7 +426,7 @@ export class TemplateCustomizationOverview {
vq.displayOrder as versionedQuestionDisplayOrder,
qc.id AS questionCustomizationId, qc.migrationStatus AS questionCustomizationMigrationStatus,
(qc.guidanceText IS NOT NULL) AS questionCustomizationHasGuidanceText,
(qc.sampleText IS NOT NULL) AS questionCustomizationHasSampleText
(qc.sampleText IS NOT NULL AND qc.sampleText != '') AS questionCustomizationHasSampleText

FROM templateCustomizations AS tc
JOIN users AS u ON tc.modifiedById = u.id
Expand Down Expand Up @@ -516,6 +516,7 @@ export class TemplateCustomizationOverview {
[templateCustomizationId.toString()],
reference
);

return Array.isArray(results) ? results : [];
};
}
Expand Down Expand Up @@ -605,7 +606,7 @@ export class TemplateCustomization extends MySqlModel {
this.addError('affiliationId', 'Affiliation can\'t be blank');
}
if (isNullOrUndefined(this.templateId)) {
this.addError('templateId','Template can\'t be blank');
this.addError('templateId', 'Template can\'t be blank');
}
if (isNullOrUndefined(this.currentVersionedTemplateId)) {
this.addError(
Expand All @@ -630,43 +631,43 @@ export class TemplateCustomization extends MySqlModel {
this.addError('general', 'Customization has never been saved');

} else if (this.status === TemplateCustomizationStatus.PUBLISHED && !this.isDirty) {
// Can't publish if it is already published!
this.addError('general', 'Customization is already published!');
// Can't publish if it is already published!
this.addError('general', 'Customization is already published!');

} else {
// Make sure the record is valid
if (await this.isValid()) {

// Create a new published version of the customization
const newVersion = new VersionedTemplateCustomization(
{
affiliationId: this.affiliationId,
templateCustomizationId: this.id,
currentVersionedTemplateId: this.currentVersionedTemplateId,
active: true
}
)
// Make sure the record is valid
if (await this.isValid()) {

const created: VersionedTemplateCustomization = await newVersion.create(context);
// Create a new published version of the customization
const newVersion = new VersionedTemplateCustomization(
{
affiliationId: this.affiliationId,
templateCustomizationId: this.id,
currentVersionedTemplateId: this.currentVersionedTemplateId,
active: true
}
)

if (!isNullOrUndefined(created) && !created.hasErrors() && created.id) {
// Update the status of the customization to reflect the change
this.status = TemplateCustomizationStatus.PUBLISHED;
this.isDirty = false;
this.latestPublishedVersionId = created.id;
this.latestPublishedDate = created.created;
const published: TemplateCustomization = await this.update(context, true); // noTouch=true, the update method will not set isDirty to true
const created: VersionedTemplateCustomization = await newVersion.create(context);

if (!published) {
this.addError('general', 'Unable to publish');
}
} else {
this.errors = created?.errors ?? this.errors;
if (!isNullOrUndefined(created) && !created.hasErrors() && created.id) {
// Update the status of the customization to reflect the change
this.status = TemplateCustomizationStatus.PUBLISHED;
this.isDirty = false;
this.latestPublishedVersionId = created.id;
this.latestPublishedDate = created.created;
const published: TemplateCustomization = await this.update(context, true); // noTouch=true, the update method will not set isDirty to true

if (!published) {
this.addError('general', 'Unable to publish');
}
} else {
this.errors = created?.errors ?? this.errors;
}
}
return new TemplateCustomization(this);
}
return new TemplateCustomization(this);
}

/**
* Unpublish the customization
Expand Down
64 changes: 64 additions & 0 deletions src/models/__tests__/QuestionCustomization.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -442,6 +442,70 @@ describe("QuestionCustomization", () => {
});
});

describe("findByCustomizationAndVersionedQuestion", () => {
it("should find QuestionCustomization by templateCustomizationId and versionedQuestionId", async () => {
const mockQuery = jest.spyOn(MySqlModel, "query").mockResolvedValue([
{
id: 1,
templateCustomizationId: 100,
versionedQuestionId: 300,
},
]);

const result = await QuestionCustomization.findByCustomizationAndVersionedQuestion(
"test.ref",
mockContext,
100,
300
);

expect(mockQuery).toHaveBeenCalledWith(
mockContext,
`SELECT qc.* FROM questionCustomizations qc
INNER JOIN versionedQuestions vq ON qc.questionId = vq.questionId
WHERE qc.templateCustomizationId = ? AND vq.id = ?`,
["100", "300"],
"test.ref"
);
expect(result).toBeInstanceOf(QuestionCustomization);
expect(result.templateCustomizationId).toBe(100);
});

it("should return undefined when not found", async () => {
jest.spyOn(MySqlModel, "query").mockResolvedValue([]);

const result = await QuestionCustomization.findByCustomizationAndVersionedQuestion(
"test.ref",
mockContext,
100,
300
);

expect(result).toBeUndefined();
});

it("should handle null questionId", async () => {
const mockQuery = jest.spyOn(MySqlModel, "query").mockResolvedValue([]);

const result = await QuestionCustomization.findByCustomizationAndVersionedQuestion(
"test.ref",
mockContext,
100,
null
);

expect(mockQuery).toHaveBeenCalledWith(
mockContext,
`SELECT qc.* FROM questionCustomizations qc
INNER JOIN versionedQuestions vq ON qc.questionId = vq.questionId
WHERE qc.templateCustomizationId = ? AND vq.id = ?`,
["100", undefined],
"test.ref"
);
expect(result).toBeUndefined();
});
});

describe("findByCustomizationId", () => {
it("should find QuestionCustomizations by templateCustomizationId", async () => {
const mockQuery = jest.spyOn(MySqlModel, "query").mockResolvedValue([
Expand Down
91 changes: 87 additions & 4 deletions src/resolvers/__tests__/questionCustomization.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ let adminToken: JWTAccessToken;
let query: string;

// Proxy call to the Apollo server test server
async function executeQuery (
async function executeQuery(
query: string,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
variables: any,
Expand Down Expand Up @@ -168,6 +168,89 @@ describe('questionCustomization resolver', () => {
});
});

describe('Query.questionCustomizationByVersionedQuestion', () => {
beforeEach(() => {
query = `
query questionCustomizationByVersionedQuestion($templateCustomizationId: Int!, $versionedQuestionId: Int!) {
questionCustomizationByVersionedQuestion(templateCustomizationId: $templateCustomizationId, versionedQuestionId: $versionedQuestionId) {
id
templateCustomizationId
questionId
migrationStatus
guidanceText
sampleText
errors {
general
}
versionedQuestion {
id
questionText
}
}
}
`;
});

it('should return the section customization when found and user has permission', async () => {
const mockCustomization = {
id: 1,
templateCustomizationId: 10,
questionId: 5,
migrationStatus: 'OK',
guidanceText: 'Test guidance text',
sampleText: 'Test sample text'
};
const mockParent = { id: 10, isDirty: false };

(QuestionCustomization.findByCustomizationAndVersionedQuestion as jest.Mock).mockResolvedValue(mockCustomization);
(getValidatedCustomization as jest.Mock).mockResolvedValue(mockParent);

const vars = { templateCustomizationId: 1, versionedQuestionId: 5 };
const result = await executeQuery(query, vars, adminToken);

expect(result.body.singleResult.data.questionCustomizationByVersionedQuestion.id).toEqual(1);
expect(result.body.singleResult.data.questionCustomizationByVersionedQuestion.templateCustomizationId).toEqual(10);
expect(result.body.singleResult.data.questionCustomizationByVersionedQuestion.questionId).toEqual(5);
expect(result.body.singleResult.data.questionCustomizationByVersionedQuestion.migrationStatus).toEqual('OK');
expect(result.body.singleResult.data.questionCustomizationByVersionedQuestion.guidanceText).toEqual('Test guidance text');
expect(result.body.singleResult.data.questionCustomizationByVersionedQuestion.sampleText).toEqual('Test sample text');
expect(QuestionCustomization.findByCustomizationAndVersionedQuestion).toHaveBeenCalledWith(
'questionCustomizationByVersionedQuestion resolver',
expect.any(Object),
1,
5
);
});

it('should return NotFound error when section customization is not found', async () => {
(QuestionCustomization.findByCustomizationAndVersionedQuestion as jest.Mock).mockResolvedValue(null);

const vars = { templateCustomizationId: 1, versionedQuestionId: 999 };
const result = await executeQuery(query, vars, adminToken);

expect(result.body.kind).toEqual('single');
expect(result.body.singleResult.errors).toBeDefined();
expect(result.body.singleResult.errors[0].message).toEqual('Not Found');
});

it('should return NotFound error when parent template customization is not found', async () => {
const mockCustomization = {
id: 1,
templateCustomizationId: 10
};

(QuestionCustomization.findByCustomizationAndVersionedQuestion as jest.Mock).mockResolvedValue(mockCustomization);
(getValidatedCustomization as jest.Mock).mockResolvedValue(null);

const vars = { templateCustomizationId: 1, versionedQuestionId: 5 };
const result = await executeQuery(query, vars, adminToken);

expect(result.body.kind).toEqual('single');
expect(result.body.singleResult.errors).toBeDefined();
expect(result.body.singleResult.errors[0].message).toEqual('Not Found');
});
});

describe('Query.customQuestion', () => {
beforeEach(() => {
query = `
Expand Down Expand Up @@ -204,7 +287,7 @@ describe('questionCustomization resolver', () => {
sectionId: 5,
migrationStatus: 'OK'
};
const mockParent = {id: 10, isDirty: false};
const mockParent = { id: 10, isDirty: false };

(CustomQuestion.findById as jest.Mock).mockResolvedValue(mockCustomQuestion);
(getValidatedCustomization as jest.Mock).mockResolvedValue(mockParent);
Expand Down Expand Up @@ -322,8 +405,8 @@ describe('questionCustomization resolver', () => {
templateCustomizationId: 10,
versionedQuestionId: 5
};
const mockSection = {id: 5};
const mockParent = {id: 10, isDirty: false};
const mockSection = { id: 5 };
const mockParent = { id: 10, isDirty: false };
const mockCreated = {
id: 1,
questionId: 5,
Expand Down
Loading