1
0
mirror of https://github.com/invoiceninja/invoiceninja.git synced 2024-11-08 12:12:48 +01:00

Add Cypress for client portal UI tests

This commit is contained in:
David Bomba 2023-01-25 07:15:54 +11:00
parent 8886a4a33d
commit 6880e67210
17 changed files with 8741 additions and 77 deletions

View File

@ -54,6 +54,7 @@ use Database\Factories\BankTransactionRuleFactory;
use Faker\Factory;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
use stdClass;
@ -203,6 +204,22 @@ class CreateSingleAccount extends Command
'applies_to' => (bool)rand(0,1) ? 'CREDIT' : 'DEBIT',
]);
$client = Client::factory()->create([
'user_id' => $user->id,
'company_id' => $company->id,
'name' => 'cypress'
]);
ClientContact::factory()->create([
'user_id' => $user->id,
'client_id' => $client->id,
'company_id' => $company->id,
'is_primary' => 1,
'email' => 'cypress@example.com',
'password' => Hash::make('password'),
]);
$this->info('Creating '.$this->count.' clients');
for ($x = 0; $x < $this->count; $x++) {
@ -356,7 +373,7 @@ class CreateSingleAccount extends Command
'client_id' => $client->id,
'company_id' => $company->id,
'is_primary' => 1,
'email' => 'user@example.com'
'email' => 'user@example.com',
]);
ClientContact::factory()->count(rand(1, 2))->create([

View File

@ -101,6 +101,7 @@
"darkaonline/l5-swagger": "8.1.0",
"fakerphp/faker": "^1.14",
"filp/whoops": "^2.7",
"laracasts/cypress": "^3.0",
"laravel/dusk": "^6.15",
"mockery/mockery": "^1.4.4",
"nunomaduro/collision": "^6.1",

61
composer.lock generated
View File

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "451d3dbdd5b0a87940e0f8fffadab4ae",
"content-hash": "fee0057b8444e2a245cea87dab6c1b3a",
"packages": [
{
"name": "afosto/yaac",
@ -13859,6 +13859,65 @@
},
"time": "2020-07-09T08:09:16+00:00"
},
{
"name": "laracasts/cypress",
"version": "3.0.0",
"source": {
"type": "git",
"url": "https://github.com/laracasts/cypress.git",
"reference": "9a9e5d25a51d2cbb410393e6a0d9883aa3304bf5"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/laracasts/cypress/zipball/9a9e5d25a51d2cbb410393e6a0d9883aa3304bf5",
"reference": "9a9e5d25a51d2cbb410393e6a0d9883aa3304bf5",
"shasum": ""
},
"require": {
"illuminate/support": "^6.0|^7.0|^8.0|^9.0",
"php": "^8.0"
},
"require-dev": {
"orchestra/testbench": "^6.0|^7.0",
"phpunit/phpunit": "^8.0|^9.5.10",
"spatie/laravel-ray": "^1.29"
},
"type": "library",
"extra": {
"laravel": {
"providers": [
"Laracasts\\Cypress\\CypressServiceProvider"
]
}
},
"autoload": {
"psr-4": {
"Laracasts\\Cypress\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Jeffrey Way",
"email": "jeffrey@laracasts.com",
"role": "Developer"
}
],
"description": "Laravel Cypress Boilerplate",
"homepage": "https://github.com/laracasts/cypress",
"keywords": [
"cypress",
"laracasts"
],
"support": {
"issues": "https://github.com/laracasts/cypress/issues",
"source": "https://github.com/laracasts/cypress/tree/3.0.0"
},
"time": "2022-06-27T13:49:35+00:00"
},
{
"name": "laravel/dusk",
"version": "v6.25.2",

19
cypress.config.js vendored Normal file
View File

@ -0,0 +1,19 @@
const { defineConfig } = require('cypress')
module.exports = defineConfig({
chromeWebSecurity: false,
retries: 2,
defaultCommandTimeout: 5000,
watchForFileChanges: false,
videosFolder: 'tests/cypress/videos',
screenshotsFolder: 'tests/cypress/screenshots',
fixturesFolder: 'tests/cypress/fixture',
e2e: {
setupNodeEvents(on, config) {
return require('./tests/cypress/plugins/index.js')(on, config)
},
baseUrl: 'http://ninja.test:8000/',
specPattern: 'tests/cypress/integration/**/*.cy.{js,jsx,ts,tsx}',
supportFile: 'tests/cypress/support/index.js',
},
})

View File

@ -118,67 +118,23 @@ class RandomDataSeeder extends Seeder
'settings' => null,
]);
$u2 = User::where('email', 'demo@invoiceninja.com')->first();
if (! $u2) {
$u2 = User::factory()->create([
'email' => 'demo@invoiceninja.com',
'password' => Hash::make('demo'),
'account_id' => $account->id,
'confirmation_code' => $this->createDbHash(config('database.default')),
]);
$company_token = CompanyToken::create([
'user_id' => $u2->id,
'company_id' => $company->id,
'account_id' => $account->id,
'name' => 'test token',
'token' => 'TOKEN',
]);
$u2->companies()->attach($company->id, [
'account_id' => $account->id,
'is_owner' => 1,
'is_admin' => 1,
'is_locked' => 0,
'notifications' => CompanySettings::notificationDefaults(),
'permissions' => '',
'settings' => null,
]);
}
$client = Client::factory()->create([
'user_id' => $user->id,
'company_id' => $company->id,
]);
ClientContact::create([
'first_name' => $faker->firstName(),
'last_name' => $faker->lastName(),
'email' => config('ninja.testvars.username'),
'company_id' => $company->id,
'password' => Hash::make(config('ninja.testvars.password')),
'email_verified_at' => now(),
'client_id' =>$client->id,
'user_id' => $user->id,
'is_primary' => true,
'contact_key' => \Illuminate\Support\Str::random(40),
]);
Client::factory()->create(['user_id' => $user->id, 'company_id' => $company->id])->each(function ($c) use ($user, $company) {
ClientContact::factory()->create([
'user_id' => $user->id,
'client_id' => $c->id,
'company_id' => $company->id,
'is_primary' => 1,
'name' => 'cypress'
]);
ClientContact::factory()->count(5)->create([
'user_id' => $user->id,
'client_id' => $c->id,
'company_id' => $company->id,
]);
});
$client->number = $client->getNextClientNumber($client);
$client->save();
ClientContact::factory()->create([
'user_id' => $user->id,
'client_id' => $client->id,
'company_id' => $company->id,
'is_primary' => 1,
'email' => 'cypress@example.com',
'password' => Hash::make('password'),
]);
/* Product Factory */
Product::factory()->count(2)->create(['user_id' => $user->id, 'company_id' => $company->id]);
@ -200,8 +156,6 @@ class RandomDataSeeder extends Seeder
$invoice = $invoice_calc->build()->getInvoice();
$invoice->save();
$invoice->service()->createInvitations()->markSent()->save();
$invoice->ledger()->updateInvoiceBalance($invoice->balance);
@ -220,16 +174,16 @@ class RandomDataSeeder extends Seeder
$payment->invoices()->save($invoice);
$payment_hash = new PaymentHash;
$payment_hash->hash = Str::random(128);
$payment_hash->data = [['invoice_id' => $invoice->hashed_id, 'amount' => $invoice->balance]];
$payment_hash->fee_total = 0;
$payment_hash->fee_invoice_id = $invoice->id;
$payment_hash->save();
// $payment_hash = new PaymentHash;
// $payment_hash->hash = Str::random(128);
// $payment_hash->data = [['invoice_id' => $invoice->hashed_id, 'amount' => $invoice->balance]];
// $payment_hash->fee_total = 0;
// $payment_hash->fee_invoice_id = $invoice->id;
// $payment_hash->save();
event(new PaymentWasCreated($payment, $payment->company, Ninja::eventVars()));
$payment->service()->updateInvoicePayment($payment_hash);
// $payment->service()->updateInvoicePayment($payment_hash);
// UpdateInvoicePayment::dispatchNow($payment, $payment->company);
}
@ -256,7 +210,6 @@ class RandomDataSeeder extends Seeder
$credit->service()->createInvitations()->markSent()->save();
//$invoice->markSent()->save();
});
/* Recurring Invoice Factory */
@ -286,14 +239,6 @@ class RandomDataSeeder extends Seeder
//$invoice->markSent()->save();
});
$clients = Client::all();
foreach ($clients as $client) {
//$client->getNextClientNumber($client);
$client->number = $client->getNextClientNumber($client);
$client->save();
}
GroupSetting::create([
'company_id' => $company->id,
'user_id' => $user->id,

2478
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -12,12 +12,13 @@
"@babel/compat-data": "7.15.0",
"@babel/plugin-proposal-class-properties": "^7.14.5",
"@tailwindcss/aspect-ratio": "^0.4.2",
"cypress": "^12.3.0",
"laravel-mix-purgecss": "^6.0.0",
"vue-template-compiler": "^2.6.14"
},
"dependencies": {
"@tailwindcss/line-clamp": "^0.3.1",
"@tailwindcss/forms": "^0.3.4",
"@tailwindcss/line-clamp": "^0.3.1",
"autoprefixer": "^10.3.7",
"axios": "^0.25",
"card-js": "^1.0.13",

43
tests/cypress/integration/login.cy.js vendored Normal file
View File

@ -0,0 +1,43 @@
describe('Test Login Page', () => {
it('Shows the Password Reset Pasge.', () => {
cy.visit('/client/password/reset');
cy.contains('Password Recovery');
cy.get('input[name=email]').type('cypress@example.com{enter}');
cy.contains('We have e-mailed your password reset link!');
cy.visit('/client/password/reset');
cy.contains('Password Recovery');
cy.get('input[name=email]').type('nono@example.com{enter}');
cy.contains("We can't find a user with that e-mail address.");
});
it('Shows the login page.', () => {
cy.visit('/client/login');
cy.contains('Client Portal');
cy.get('input[name=email]').type('cypress@example.com');
cy.get('input[name=password]').type('password{enter}');
cy.url().should('include', '/invoices');
cy.visit('/client/recurring_invoices').contains('Recurring Invoices');
cy.visit('/client/payments').contains('Payments');
cy.visit('/client/quotes').contains('Quotes');
cy.visit('/client/credits').contains('Credits');
cy.visit('/client/payment_methods').contains('Payment Methods');
cy.visit('/client/documents').contains('Documents');
cy.visit('/client/statement').contains('Statement');
cy.visit('/client/subscriptions').contains('Subscriptions');
cy.get('[data-ref="client-profile-dropdown"]').click();
cy.get('[data-ref="client-profile-dropdown-settings"]').click();
cy.contains('Client Information');
});
});

23
tests/cypress/plugins/index.js vendored Normal file
View File

@ -0,0 +1,23 @@
/// <reference types="cypress" />
// ***********************************************************
// This example plugins/index.js can be used to load plugins
//
// You can change the location of this file or turn off loading
// the plugins file with the 'pluginsFile' configuration option.
//
// You can read more here:
// https://on.cypress.io/plugins-guide
// ***********************************************************
// This function is called when a project is opened or re-opened (e.g. due to
// the project's config changing)
/**
* @type {Cypress.PluginConfig}
*/
module.exports = (on, config) => {
// `on` is used to hook into various events Cypress emits
// `config` is the resolved Cypress config
on('task', require('./swap-env'));
};

21
tests/cypress/plugins/swap-env.js vendored Normal file
View File

@ -0,0 +1,21 @@
let fs = require('fs');
module.exports = {
activateCypressEnvFile() {
if (fs.existsSync('.env.cypress')) {
fs.renameSync('.env', '.env.backup');
fs.renameSync('.env.cypress', '.env');
}
return null;
},
activateLocalEnvFile() {
if (fs.existsSync('.env.backup')) {
fs.renameSync('.env', '.env.cypress');
fs.renameSync('.env.backup', '.env');
}
return null;
}
};

3
tests/cypress/support/assertions.js vendored Normal file
View File

@ -0,0 +1,3 @@
Cypress.Commands.add('assertRedirect', path => {
cy.location('pathname').should('eq', `/${path}`.replace(/^\/\//, '/'));
});

92
tests/cypress/support/index.d.ts vendored Normal file
View File

@ -0,0 +1,92 @@
/// <reference types="cypress" />
declare namespace Cypress {
interface Chainable<Subject> {
/**
* Log in the user with the given attributes, or create a new user and then log them in.
*
* @example
* cy.login()
* cy.login({ id: 1 })
*/
login(attributes?: object): Chainable<any>;
/**
* Log out the current user.
*
* @example
* cy.logout()
*/
logout(): Chainable<any>;
/**
* Fetch the currently authenticated user.
*
* @example
* cy.currentUser()
*/
currentUser(): Chainable<any>;
/**
* Fetch a CSRF token from the server.
*
* @example
* cy.logout()
*/
csrfToken(): Chainable<any>;
/**
* Fetch a fresh list of URI routes from the server.
*
* @example
* cy.logout()
*/
refreshRoutes(): Chainable<any>;
/**
* Create and persist a new Eloquent record using Laravel model factories.
*
* @example
* cy.create('App\\User');
* cy.create('App\\User', 2);
* cy.create('App\\User', 2, { active: false });
* cy.create({ model: 'App\\User', state: ['guest'], relations: ['profile'], count: 2 }
*/
create(): Chainable<any>;
/**
* Refresh the database state using Laravel's migrate:fresh command.
*
* @example
* cy.refreshDatabase()
* cy.refreshDatabase({ '--drop-views': true }
*/
refreshDatabase(options?: object): Chainable<any>;
/**
* Run Artisan's db:seed command.
*
* @example
* cy.seed()
* cy.seed('PlansTableSeeder')
*/
seed(seederClass?: string): Chainable<any>;
/**
* Run an Artisan command.
*
* @example
* cy.artisan()
*/
artisan(command: string, parameters?: object, options?: object): Chainable<any>;
/**
* Execute arbitrary PHP on the server.
*
* @example
* cy.php('2 + 2')
* cy.php('App\\User::count()')
*/
php(command: string): Chainable<any>;
}
}

32
tests/cypress/support/index.js vendored Normal file
View File

@ -0,0 +1,32 @@
// ***********************************************************
// This example support/index.js is processed and
// loaded automatically before your test files.
//
// This is a great place to put global configuration and
// behavior that modifies Cypress.
//
// You can change the location of this file or turn off
// automatically serving support files with the
// 'supportFile' configuration option.
//
// You can read more here:
// https://on.cypress.io/configuration
// ***********************************************************
/// <reference types="./" />
import './laravel-commands';
import './laravel-routes';
import './assertions';
before(() => {
cy.task('activateCypressEnvFile', {}, { log: false });
cy.artisan('config:clear', {}, { log: false });
cy.refreshRoutes();
cy.seed("RandomDataSeeder");
});
after(() => {
cy.task('activateLocalEnvFile', {}, { log: false });
cy.artisan('config:clear', {}, { log: false });
});

View File

@ -0,0 +1,301 @@
/**
* Create a new user and log them in.
*
* @param {Object} attributes
*
* @example cy.login();
* cy.login({ name: 'JohnDoe' });
* cy.login({ attributes: { name: 'JohnDoe' }, state: 'guest', load: ['comments] });
*/
Cypress.Commands.add('login', (attributes = {}) => {
// Are we using the new object system.
let requestBody = attributes.attributes || attributes.state || attributes.load ? attributes : { attributes };
return cy
.csrfToken()
.then((token) => {
return cy.request({
method: 'POST',
url: '/__cypress__/login',
body: { ...requestBody, _token: token },
log: false,
});
})
.then(({ body }) => {
Cypress.Laravel.currentUser = body;
Cypress.log({
name: 'login',
message: JSON.stringify(body),
consoleProps: () => ({ user: body }),
});
})
.its('body', { log: false });
});
/**
* Fetch the currently authenticated user object.
*
* @example cy.currentUser();
*/
Cypress.Commands.add('currentUser', () => {
return cy.csrfToken().then((token) => {
return cy
.request({
method: 'POST',
url: '/__cypress__/current-user',
body: { _token: token },
log: false,
})
.then((response) => {
if (!response.body) {
cy.log('No authenticated user found.');
}
Cypress.Laravel.currentUser = response?.body;
return response?.body;
});
});
});
/**
* Logout the current user.
*
* @example cy.logout();
*/
Cypress.Commands.add('logout', () => {
return cy
.csrfToken()
.then((token) => {
return cy.request({
method: 'POST',
url: '/__cypress__/logout',
body: { _token: token },
log: false,
});
})
.then(() => {
Cypress.log({ name: 'logout', message: '' });
});
});
/**
* Fetch a CSRF token.
*
* @example cy.csrfToken();
*/
Cypress.Commands.add('csrfToken', () => {
return cy
.request({
method: 'GET',
url: '/__cypress__/csrf_token',
log: false,
})
.its('body', { log: false });
});
/**
* Fetch and store all named routes.
*
* @example cy.refreshRoutes();
*/
Cypress.Commands.add('refreshRoutes', () => {
return cy.csrfToken().then((token) => {
return cy
.request({
method: 'POST',
url: '/__cypress__/routes',
body: { _token: token },
log: false,
})
.its('body', { log: false })
.then((routes) => {
cy.writeFile(Cypress.config().supportFolder + '/routes.json', routes, {
log: false,
});
Cypress.Laravel.routes = routes;
});
});
});
/**
* Visit the given URL or route.
*
* @example cy.visit('foo/path');
* cy.visit({ route: 'home' });
* cy.visit({ route: 'team', parameters: { team: 1 } });
*/
Cypress.Commands.overwrite('visit', (originalFn, subject, options) => {
if (subject.route) {
return originalFn({
url: Cypress.Laravel.route(subject.route, subject.parameters || {}),
method: Cypress.Laravel.routes[subject.route].method[0],
...options
});
}
return originalFn(subject, options);
});
/**
* Create a new Eloquent factory.
*
* @param {String} model
* @param {Number|null} times
* @param {Object} attributes
*
* @example cy.create('App\\User');
* cy.create('App\\User', 2);
* cy.create('App\\User', 2, { active: false });
* cy.create('App\\User', { active: false });
* cy.create('App\\User', 2, { active: false });
* cy.create('App\\User', 2, { active: false }, ['profile']);
* cy.create('App\\User', 2, { active: false }, ['profile'], ['guest']);
* cy.create('App\\User', { active: false }, ['profile']);
* cy.create('App\\User', { active: false }, ['profile'], ['guest']);
* cy.create('App\\User', ['profile']);
* cy.create('App\\User', ['profile'], ['guest']);
* cy.create({ model: 'App\\User', state: ['guest'], relations: ['profile'], count: 2 }
*/
Cypress.Commands.add('create', (model, count = 1, attributes = {}, load = [], state = []) => {
let requestBody = {};
if (typeof model !== 'object') {
if (Array.isArray(count)) {
state = attributes;
attributes = {};
load = count;
count = 1;
}
if (typeof count === 'object') {
state = load;
load = attributes;
attributes = count;
count = 1;
}
requestBody = { model, state, attributes, load, count };
} else {
requestBody = model;
}
return cy
.csrfToken()
.then((token) => {
return cy.request({
method: 'POST',
url: '/__cypress__/factory',
body: { ...requestBody, _token: token },
log: false,
});
})
.then((response) => {
Cypress.log({
name: 'create',
message: requestBody.model + (requestBody.count > 1 ? ` (${requestBody.count} times)` : ''),
consoleProps: () => ({ [model]: response.body }),
});
})
.its('body', { log: false });
});
/**
* Refresh the database state.
*
* @param {Object} options
*
* @example cy.refreshDatabase();
* cy.refreshDatabase({ '--drop-views': true });
*/
Cypress.Commands.add('refreshDatabase', (options = {}) => {
return cy.artisan('migrate:fresh', options);
});
/**
* Seed the database.
*
* @param {String} seederClass
*
* @example cy.seed();
* cy.seed('PlansTableSeeder');
*/
Cypress.Commands.add('seed', (seederClass = '') => {
let options = {};
if (seederClass) {
options['--class'] = seederClass;
}
return cy.artisan('db:seed', options);
});
/**
* Trigger an Artisan command.
*
* @param {String} command
* @param {Object} parameters
* @param {Object} options
*
* @example cy.artisan('cache:clear');
*/
Cypress.Commands.add('artisan', (command, parameters = {}, options = {}) => {
options = Object.assign({}, { log: true }, options);
if (options.log) {
Cypress.log({
name: 'artisan',
message: (() => {
let message = command;
for (let key in parameters) {
message += ` ${key}="${parameters[key]}"`;
}
return message;
})(),
consoleProps: () => ({ command, parameters }),
});
}
return cy.csrfToken().then((token) => {
return cy.request({
method: 'POST',
url: '/__cypress__/artisan',
body: { command: command, parameters: parameters, _token: token },
log: false,
});
});
});
/**
* Execute arbitrary PHP.
*
* @param {String} command
*
* @example cy.php('2 + 2');
* cy.php('App\\User::count()');
*/
Cypress.Commands.add('php', (command) => {
return cy
.csrfToken()
.then((token) => {
return cy.request({
method: 'POST',
url: '/__cypress__/run-php',
body: { command: command, _token: token },
log: false,
});
})
.then((response) => {
Cypress.log({
name: 'php',
message: command,
consoleProps: () => ({ result: response.body.result }),
});
})
.its('body.result', { log: false });
});

21
tests/cypress/support/laravel-routes.js vendored Normal file
View File

@ -0,0 +1,21 @@
Cypress.Laravel = {
routes: {},
route: (name, parameters = {}) => {
assert(
Cypress.Laravel.routes.hasOwnProperty(name),
`Laravel route "${name}" does not exist.`
);
return ((uri) => {
Object.keys(parameters).forEach((parameter) => {
uri = uri.replace(
new RegExp(`{${parameter}}`),
parameters[parameter]
);
});
return uri;
})(Cypress.Laravel.routes[name].uri);
},
};

File diff suppressed because it is too large Load Diff

Binary file not shown.