Skip to content

Commit

Permalink
Create E2E tests for reset-project feature (#550)
Browse files Browse the repository at this point in the history
This PR creates two tests:

- Playwright E2E test that tests the function of the reset project
  modal, ensuring that it can reset a project to empty, and restore it
  to a previous state from a downloaded .zip file.

- C# integration test that verifies that Send/Receive still works after
  a project has been reset to empty. (We don't test Send/Receive after a
  project reset has uploaded a .zip file, because the Playwright test
  already checks that the uploaded .zip file can bring the project repo
  back to the exact same state it was in before.)
  • Loading branch information
rmunn authored Feb 14, 2024
1 parent 0ac6cbc commit 157b420
Show file tree
Hide file tree
Showing 7 changed files with 363 additions and 5 deletions.
132 changes: 131 additions & 1 deletion backend/Testing/SyncReverseProxy/SendReceiveServiceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
using Microsoft.EntityFrameworkCore;
using Shouldly;
using SIL.Progress;
using System.Net.Http.Json;
using System.Text.Json.Nodes;
using Testing.ApiTests;
using Testing.Fixtures;
using Testing.Logging;
Expand All @@ -25,7 +27,7 @@ public class SendReceiveServiceTests
public SendReceiveAuth UnauthorizedUser = new("user", TestingEnvironmentVariables.DefaultPassword);

private readonly ITestOutputHelper _output;
private string _basePath = Path.Join(Path.GetTempPath(), "SendReceiveTests");
private string _basePath = Path.Join(Path.GetTempPath(), "SR_Tests");
private SendReceiveService _sendReceiveService;

public SendReceiveServiceTests(ITestOutputHelper output)
Expand Down Expand Up @@ -58,6 +60,7 @@ private string GetProjectDir(string projectCode,
if (identifier is not null) projectDir = Path.Join(projectDir, identifier);
//fwdata file containing folder name will be the same as the file name
projectDir = Path.Join(projectDir, _folderIndex++.ToString(), projectCode);
projectDir.Length.ShouldBeLessThan(150, "Path may be too long with mercurial directories");
return projectDir;
}

Expand Down Expand Up @@ -176,6 +179,133 @@ query projectLastCommit {
lastCommitDateAfter.ShouldBeGreaterThan(lastCommitDate);
}


[Theory]
[InlineData(HgProtocol.Hgweb)]
[InlineData(HgProtocol.Resumable)]
public async Task SendReceiveAfterProjectReset(HgProtocol protocol)
{
// Create new project on server so we don't reset our master test project
var id = Guid.NewGuid();
var newProjectCode = $"{(protocol == HgProtocol.Hgweb ? "web": "res")}-sr-reset-{id:N}";
var apiTester = new ApiTestBase();
var auth = AdminAuth;
await apiTester.LoginAs(auth.Username, auth.Password);
await apiTester.ExecuteGql($$"""
mutation {
createProject(input: {
name: "Send new project test",
type: FL_EX,
id: "{{id}}",
code: "{{newProjectCode}}",
description: "A project created during a unit test to test Send/Receive operation via {{protocol}} after a project reset",
retentionPolicy: DEV
}) {
createProjectResponse {
id
result
}
errors {
__typename
... on DbError {
code
message
}
}
}
}
""");

// Ensure newly-created project is deleted after test completes
await using var deleteProject = Defer.Async(() => apiTester.HttpClient.DeleteAsync($"{apiTester.BaseUrl}/api/project/project/{id}"));

// Sleep 5 seconds to ensure hgweb picks up newly-created test project
await Task.Delay(TimeSpan.FromSeconds(5));

// Populate new project from original so we're not resetting original test project in E2E tests
// Note that this time we're cloning via hg clone rather than Chorus, because we don't want a .fwdata file yet
var progress = new NullProgress();
var origProjectCode = TestingEnvironmentVariables.ProjectCode;
var sourceProjectDir = GetProjectDir(origProjectCode);
Directory.CreateDirectory(sourceProjectDir);
var hgwebUrl = new UriBuilder
{
Scheme = TestingEnvironmentVariables.HttpScheme,
Host = HgProtocol.Hgweb.GetTestHostName(),
UserName = auth.Username,
Password = auth.Password
};
HgRunner.Run($"hg clone {hgwebUrl}{origProjectCode} {sourceProjectDir}", "", 15, progress);
HgRunner.Run($"hg push {hgwebUrl}{newProjectCode}", sourceProjectDir, 15, progress);

// Sleep 5 seconds to ensure hgweb picks up newly-pushed commits
await Task.Delay(TimeSpan.FromSeconds(5));

// Now clone again via Chorus so that we'll hvae a .fwdata file
var sendReceiveParams = GetParams(protocol, newProjectCode);
Directory.CreateDirectory(sendReceiveParams.DestDir);
var srResult = _sendReceiveService.CloneProject(sendReceiveParams, auth);
_output.WriteLine(srResult);
srResult.ShouldNotContain("abort");
srResult.ShouldNotContain("failure");
srResult.ShouldNotContain("error");

// Delete the Chorus revision cache if resumable, otherwise Chorus will send the wrong data during S/R
// Note that HgWeb protocol does *not* have this issue
string chorusStorageFolder = Path.Join(sendReceiveParams.DestDir, "Chorus", "ChorusStorage");
var revisionCache = new FileInfo(Path.Join(chorusStorageFolder, "revisioncache.json"));
if (revisionCache.Exists)
{
revisionCache.Delete();
}

// With all that setup out of the way, we can now start the actual test itself

// First, save the current value of `hg tip` from the original project
var tipUri = new UriBuilder
{
Scheme = TestingEnvironmentVariables.HttpScheme,
Host = TestingEnvironmentVariables.ServerHostname,
Path = $"hg/{newProjectCode}/tags",
Query = "?style=json"
};
var response = await apiTester.HttpClient.GetAsync(tipUri.Uri);
var jsonResult = await response.Content.ReadFromJsonAsync<JsonObject>();
var originalTip = jsonResult?["node"]?.AsValue()?.ToString();
originalTip.ShouldNotBeNull();

// /api/project/resetProject/{code}
// /api/project/finishResetProject/{code} // leave project empty
// /api/project/backupProject/{code} // download zip file
// /api/project/upload-zip/{code} // upload zip file

// Step 1: reset project
await apiTester.HttpClient.PostAsync($"{apiTester.BaseUrl}/api/project/resetProject/{newProjectCode}", null);
await apiTester.HttpClient.PostAsync($"{apiTester.BaseUrl}/api/project/finishResetProject/{newProjectCode}", null);

// Step 2: verify project is now empty, i.e. tip is "0000000..."
response = await apiTester.HttpClient.GetAsync(tipUri.Uri);
jsonResult = await response.Content.ReadFromJsonAsync<JsonObject>();
var emptyTip = jsonResult?["node"]?.AsValue()?.ToString();
emptyTip.ShouldNotBeNull();
emptyTip.ShouldNotBeEmpty();
emptyTip.Replace("0", "").ShouldBeEmpty();

// Step 3: do Send/Receive
var srResultStep3 = _sendReceiveService.SendReceiveProject(sendReceiveParams, auth);
_output.WriteLine(srResultStep3);
srResultStep3.ShouldNotContain("abort");
srResultStep3.ShouldNotContain("failure");
srResultStep3.ShouldNotContain("error");

// Step 4: verify project tip is same hash as original project tip
response = await apiTester.HttpClient.GetAsync(tipUri.Uri);
jsonResult = await response.Content.ReadFromJsonAsync<JsonObject>();
var postSRTip = jsonResult?["node"]?.AsValue()?.ToString();
postSRTip.ShouldNotBeNull();
postSRTip.ShouldBe(originalTip);
}

[Fact]
public async Task SendNewProject()
{
Expand Down
71 changes: 71 additions & 0 deletions frontend/tests/components/resetProjectModal.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { expect, type Download, type Locator, type Page } from '@playwright/test';
import { BaseComponent } from './baseComponent';

const PLEASE_CONFIRM_DOWNLOAD_TEXT = 'Please confirm you have downloaded a backup';
const PLEASE_CONFIRM_PROJECT_CODE_TEXT = 'Please type the project code to confirm reset';
const I_CONFIRM_DOWNLOAD_TEXT = 'I confirm that I have downloaded a backup';
const DOWNLOAD_PROJECT_BACKUP_TEXT = 'Download project backup';
const ENTER_PROJECT_CODE_TEXT = 'Enter project code to confirm';
const PROJECT_UPLOAD_CONTROL_LABEL = 'Project zip file';
const PROJECT_UPLOAD_BUTTON_LABEL = 'Upload Project';

export class ResetProjectModal extends BaseComponent {
get downloadProjectBackupButton(): Locator {
return this.componentLocator.getByRole('link').filter({hasText: DOWNLOAD_PROJECT_BACKUP_TEXT});
}

get confirmBackupDownloadedCheckbox(): Locator {
return this.componentLocator.locator('form#reset-form').getByRole('checkbox', {name: I_CONFIRM_DOWNLOAD_TEXT});
}

get confirmProjectCodeInput(): Locator {
return this.componentLocator.locator('form#reset-form').getByLabel(ENTER_PROJECT_CODE_TEXT);
}

get projectUploadControl(): Locator {
return this.componentLocator.getByLabel(PROJECT_UPLOAD_CONTROL_LABEL);
}

get projectUploadButton(): Locator {
return this.componentLocator.getByRole('button', {name: PROJECT_UPLOAD_BUTTON_LABEL});
}

get errorNoBackupDownloaded(): Locator {
return this.componentLocator.locator('form#reset-form').getByText(PLEASE_CONFIRM_DOWNLOAD_TEXT);
}

get errorProjectCodeDoesNotMatch(): Locator {
return this.componentLocator.locator('form#reset-form').getByText(PLEASE_CONFIRM_PROJECT_CODE_TEXT);
}

// TODO: method that confirms which modal step (by number) is active (1, 2, 3, 4)

constructor(page: Page) {
super(page, page.locator('.reset-modal dialog.modal'));
}

async downloadProjectBackup(): Promise<Download> {
const downloadPromise = this.page.waitForEvent('download');
await this.downloadProjectBackupButton.click();
return downloadPromise;
}

async clickNextStepButton(expectedLabel: string): Promise<void> {
await this.componentLocator.getByRole('button', {name: expectedLabel}).click();
await expect(this.errorNoBackupDownloaded).toBeHidden();
await expect(this.errorProjectCodeDoesNotMatch).toBeHidden();

}

async confirmProjectBackupReceived(projectCode: string): Promise<void> {
await this.confirmBackupDownloadedCheckbox.check();
await this.confirmProjectCodeInput.fill(projectCode);
}

async uploadProjectZipFile(filename: string): Promise<void> {
await this.projectUploadControl.setInputFiles(filename);
await expect(this.projectUploadButton).toBeVisible();
await expect(this.projectUploadButton).toBeEnabled();
await this.projectUploadButton.click();
}
}
Binary file added frontend/tests/data/test-project-one-commit.zip
Binary file not shown.
52 changes: 51 additions & 1 deletion frontend/tests/fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { test as base, expect, type BrowserContext, type BrowserContextOptions }
import * as testEnv from './envVars';
import { type UUID, randomUUID } from 'crypto';
import { deleteUser, loginAs, registerUser } from './utils/authHelpers';
import { executeGql } from './utils/gqlHelpers';

export interface TempUser {
id: UUID
Expand All @@ -11,9 +12,16 @@ export interface TempUser {
password: string
}

export interface TempProject {
id: UUID
code: string
name: string
}

type Fixtures = {
tempUser: TempUser,
contextFactory: (options: BrowserContextOptions) => Promise<BrowserContext>,
tempUser: TempUser,
tempProject: TempProject
}

function addUnexpectedResponseListener(context: BrowserContext): void {
Expand Down Expand Up @@ -61,4 +69,46 @@ export const test = base.extend<Fixtures>({
await deleteUser(context.request, tempUser.id);
await context.close();
},
tempProject: async ({ page }, use, testInfo) => {
const titleForCode =
testInfo.title
.replaceAll(' ','-')
.replaceAll(/[^a-z-]/g,'');
const code = `test-${titleForCode}-${testInfo.testId}`;
const name = `Temporary project for ${testInfo.title} unit test ${testInfo.testId}`;
const loginData = {
emailOrUsername: 'admin',
password: testEnv.defaultPassword,
preHashedPassword: false,
}
const response = await page.request.post(`${testEnv.serverBaseUrl}/api/login`, {data: loginData});
expect(response.ok()).toBeTruthy();
const gqlResponse = await executeGql(page.request, `
mutation {
createProject(input: {
name: "${name}",
type: FL_EX,
code: "${code}",
description: "temporary project created during the ${testInfo.title} unit test",
retentionPolicy: DEV
}) {
createProjectResponse {
id
result
}
errors {
__typename
... on DbError {
code
message
}
}
}
}
`);
const id = (gqlResponse as {data: {createProject: {createProjectResponse: {id: UUID}}}}).data.createProject.createProjectResponse.id;
await use({id, code, name});
const deleteResponse = await page.request.delete(`${testEnv.serverBaseUrl}/api/project/project/${id}`);
expect(deleteResponse.ok()).toBeTruthy();
}
});
28 changes: 27 additions & 1 deletion frontend/tests/pages/projectPage.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,34 @@
import type { Page } from '@playwright/test';
import type { Locator, Page } from '@playwright/test';
import { BasePage } from './basePage';
import { ResetProjectModal } from '../components/resetProjectModal';

export class ProjectPage extends BasePage {
get moreSettingsDiv(): Locator { return this.page.locator('.collapse').filter({ hasText: 'More settings' }); }
get deleteProjectButton(): Locator { return this.moreSettingsDiv.getByRole('button', {name: 'Delete project'}); }
get resetProjectButton(): Locator { return this.moreSettingsDiv.getByRole('button', {name: 'Reset project'}); }
get verifyRepoButton(): Locator { return this.moreSettingsDiv.getByRole('button', {name: 'Verify repository'}); }

constructor(page: Page, name: string, code: string) {
super(page, page.locator(`.breadcrumbs :text('${name}')`), `/project/${code}`);
}

openMoreSettings(): Promise<void> {
return this.moreSettingsDiv.getByRole('checkbox').check();
}

async clickDeleteProject(): Promise<void> {
await this.openMoreSettings();
await this.deleteProjectButton.click();
}

async clickResetProject(): Promise<ResetProjectModal> {
await this.openMoreSettings();
await this.resetProjectButton.click();
return new ResetProjectModal(this.page).waitFor()
}

async clickVerifyRepo(): Promise<void> {
await this.openMoreSettings();
await this.verifyRepoButton.click();
}
}
Loading

0 comments on commit 157b420

Please sign in to comment.