mirror of
https://github.com/InvoiceShelf/InvoiceShelf.git
synced 2026-08-04 15:12:12 +00:00
Takes over #690 by csoscd. The companion-view idea is theirs; this reworks it onto the shared page setup and fills in the gaps that stopped it landing. A `{template}_header` or `{template}_footer` view next to a template is rendered alongside it and repeated by Chromium on every page. The suffix resolves through the pdf_templates:: namespace too, so custom templates get it with no extra wiring. Two things had to change for that to be useful. Companion views are now hidden from the template picker. getFormattedTemplates() lists every .blade.php it finds, so an invoice1_footer would otherwise appear as a separately selectable template with no preview image -- the feature would have introduced that the moment anyone used it. And it does something out of the box. #690 shipped no companion views, so both of its margin settings were visible no-ops until someone hand-wrote a Blade file. Instead there is a pdf_page_numbers setting, off by default, that supplies a footer when a template has none. A template's own companion still wins, so turning page numbers on cannot overwrite a designed footer. The setting sits under Gotenberg because only Chromium can repeat a footer; dompdf has no equivalent. Its value is still carried by the dompdf form so saving from there cannot clear the choice -- the field is absent from that payload, and the controller only writes it when present. Margins come from the page setup rather than #690's separate header_margin and footer_margin. Chromium draws header and footer inside the page margin, so the existing margins are the space they occupy; two more settings for the same distance would have been a second way to say the same thing. Verified against a live gotenberg:8 on a two-page document: off produces no footer, on produces "1/2" and "2/2" on the respective pages, and a companion footer replaces both. #690's own test is not carried over. It asserted nothing: a bare View::shouldReceive('exists') is an allowance rather than an expectation, andReturn(false) never entered the companion branch, and the call sat inside try { } catch (Throwable) { }, so it passed with the feature deleted. Claude-Session: https://claude.ai/code/session_01QmECndmNZwzN65Zz9P87dF
308 lines
10 KiB
PHP
308 lines
10 KiB
PHP
<?php
|
|
|
|
use App\Models\User;
|
|
use Illuminate\Support\Facades\Artisan;
|
|
use Laravel\Sanctum\Sanctum;
|
|
use Symfony\Component\HttpClient\HttpClient;
|
|
use Symfony\Component\Mailer\Bridge\Mailgun\Transport\MailgunTransportFactory;
|
|
use Symfony\Component\Mailer\Bridge\Postmark\Transport\PostmarkTransportFactory;
|
|
|
|
use function Pest\Laravel\getJson;
|
|
use function Pest\Laravel\postJson;
|
|
|
|
beforeEach(function () {
|
|
Artisan::call('db:seed', ['--class' => 'DatabaseSeeder', '--force' => true]);
|
|
Artisan::call('db:seed', ['--class' => 'DemoSeeder', '--force' => true]);
|
|
|
|
$user = User::find(1);
|
|
$this->withHeaders([
|
|
'company' => $user->companies()->first()->id,
|
|
]);
|
|
Sanctum::actingAs(
|
|
$user,
|
|
['*']
|
|
);
|
|
});
|
|
|
|
test('company settings config uses canonical v2 links', function () {
|
|
$links = collect(config('invoiceshelf.setting_menu'))->pluck('link');
|
|
|
|
expect($links)->toContain('/admin/settings/exchange-rate');
|
|
expect($links)->toContain('/admin/settings/payment-modes');
|
|
expect($links)->toContain('/admin/settings/expense-categories');
|
|
expect($links)->toContain('/admin/settings/roles');
|
|
expect($links)->toContain('/admin/settings/mail-config');
|
|
|
|
expect($links)->not->toContain('/admin/settings/roles-settings');
|
|
expect($links)->not->toContain('/admin/settings/exchange-rate-provider');
|
|
expect($links)->not->toContain('/admin/settings/payment-mode');
|
|
expect($links)->not->toContain('/admin/settings/expense-category');
|
|
expect($links)->not->toContain('/admin/settings/mail-configuration');
|
|
|
|
$bootstrapMenu = json_encode(getJson('/api/v1/bootstrap')
|
|
->assertOk()
|
|
->json('setting_menu'));
|
|
|
|
expect($bootstrapMenu)->not->toContain('roles-settings');
|
|
expect($bootstrapMenu)->not->toContain('exchange-rate-provider');
|
|
expect($bootstrapMenu)->not->toContain('payment-mode');
|
|
expect($bootstrapMenu)->not->toContain('expense-category');
|
|
expect($bootstrapMenu)->not->toContain('mail-configuration');
|
|
});
|
|
|
|
test('super admin bootstrap uses administration mode when requested', function () {
|
|
getJson('/api/v1/bootstrap?admin_mode=1')
|
|
->assertOk()
|
|
->assertJsonPath('admin_mode', true)
|
|
->assertJsonPath('current_company', null)
|
|
->assertJsonCount(0, 'setting_menu');
|
|
});
|
|
|
|
test('bootstrap without administration mode hydrates the selected company', function () {
|
|
$companyId = User::find(1)->companies()->first()->id;
|
|
|
|
getJson('/api/v1/bootstrap')
|
|
->assertOk()
|
|
->assertJsonPath('current_company.id', $companyId);
|
|
});
|
|
|
|
test('get global mail configuration', function () {
|
|
getJson('/api/v1/mail/config')
|
|
->assertOk()
|
|
->assertJsonStructure([
|
|
'mail_driver',
|
|
'from_name',
|
|
'from_mail',
|
|
]);
|
|
});
|
|
|
|
test('get global mail drivers returns capability-backed drivers', function () {
|
|
$drivers = getJson('/api/v1/mail/drivers')
|
|
->assertOk()
|
|
->json();
|
|
|
|
expect($drivers)->toContain('smtp', 'mail', 'sendmail', 'ses');
|
|
|
|
if (class_exists(HttpClient::class) && class_exists(MailgunTransportFactory::class)) {
|
|
expect($drivers)->toContain('mailgun');
|
|
} else {
|
|
expect($drivers)->not->toContain('mailgun');
|
|
}
|
|
|
|
if (class_exists(HttpClient::class) && class_exists(PostmarkTransportFactory::class)) {
|
|
expect($drivers)->toContain('postmark');
|
|
} else {
|
|
expect($drivers)->not->toContain('postmark');
|
|
}
|
|
});
|
|
|
|
test('save global mail configuration', function () {
|
|
postJson('/api/v1/mail/config', [
|
|
'mail_driver' => 'smtp',
|
|
'mail_host' => 'smtp.example.com',
|
|
'mail_port' => 587,
|
|
'mail_username' => 'demo-user',
|
|
'mail_password' => 'secret',
|
|
'mail_encryption' => 'tls',
|
|
'mail_scheme' => 'smtp',
|
|
'mail_url' => 'smtp://smtp.example.com',
|
|
'mail_timeout' => 30,
|
|
'mail_local_domain' => 'invoiceshelf.test',
|
|
'from_name' => 'InvoiceShelf',
|
|
'from_mail' => 'hello@example.com',
|
|
])
|
|
->assertOk()
|
|
->assertJson([
|
|
'success' => 'mail_variables_save_successfully',
|
|
]);
|
|
|
|
$this->assertDatabaseHas('settings', [
|
|
'option' => 'mail_driver',
|
|
'value' => 'smtp',
|
|
]);
|
|
|
|
$this->assertDatabaseHas('settings', [
|
|
'option' => 'from_mail',
|
|
'value' => 'hello@example.com',
|
|
]);
|
|
|
|
$this->assertDatabaseHas('settings', [
|
|
'option' => 'mail_timeout',
|
|
'value' => '30',
|
|
]);
|
|
});
|
|
|
|
test('save global postmark configuration', function () {
|
|
postJson('/api/v1/mail/config', [
|
|
'mail_driver' => 'postmark',
|
|
'mail_postmark_token' => 'postmark-token',
|
|
'mail_postmark_message_stream_id' => 'outbound',
|
|
'from_name' => 'InvoiceShelf',
|
|
'from_mail' => 'billing@example.com',
|
|
])
|
|
->assertOk()
|
|
->assertJson([
|
|
'success' => 'mail_variables_save_successfully',
|
|
]);
|
|
|
|
$this->assertDatabaseHas('settings', [
|
|
'option' => 'mail_driver',
|
|
'value' => 'postmark',
|
|
]);
|
|
|
|
$this->assertDatabaseHas('settings', [
|
|
'option' => 'mail_postmark_token',
|
|
'value' => 'postmark-token',
|
|
]);
|
|
});
|
|
|
|
test('get pdf configuration', function () {
|
|
getJson('/api/v1/pdf/config')
|
|
->assertOk()
|
|
->assertJsonStructure([
|
|
'pdf_driver',
|
|
'gotenberg_host',
|
|
'pdf_paper_width',
|
|
'pdf_paper_height',
|
|
'pdf_orientation',
|
|
'pdf_margin_top',
|
|
'pdf_margin_right',
|
|
'pdf_margin_bottom',
|
|
'pdf_margin_left',
|
|
]);
|
|
});
|
|
|
|
/**
|
|
* Page geometry is saved for whichever driver is selected. It used to hang off
|
|
* gotenberg_papersize, so picking dompdf meant having no paper size at all and
|
|
* switching drivers threw the setting away.
|
|
*/
|
|
test('save pdf configuration stores the page setup for dompdf too', function () {
|
|
postJson('/api/v1/pdf/config', [
|
|
'pdf_driver' => 'dompdf',
|
|
'pdf_paper_width' => '8.5in',
|
|
'pdf_paper_height' => '14in',
|
|
'pdf_orientation' => 'landscape',
|
|
'pdf_margin_top' => '5mm',
|
|
'pdf_margin_right' => '6mm',
|
|
'pdf_margin_bottom' => '7mm',
|
|
'pdf_margin_left' => '8mm',
|
|
])
|
|
->assertOk()
|
|
->assertJson(['success' => 'pdf_variables_save_successfully']);
|
|
|
|
foreach ([
|
|
'pdf_driver' => 'dompdf',
|
|
'pdf_paper_width' => '8.5in',
|
|
'pdf_paper_height' => '14in',
|
|
'pdf_orientation' => 'landscape',
|
|
'pdf_margin_top' => '5mm',
|
|
'pdf_margin_right' => '6mm',
|
|
'pdf_margin_bottom' => '7mm',
|
|
'pdf_margin_left' => '8mm',
|
|
] as $option => $value) {
|
|
$this->assertDatabaseHas('settings', compact('option', 'value'));
|
|
}
|
|
});
|
|
|
|
test('pdf configuration rejects a length with no unit', function () {
|
|
postJson('/api/v1/pdf/config', [
|
|
'pdf_driver' => 'dompdf',
|
|
'pdf_paper_width' => '210',
|
|
'pdf_paper_height' => '297mm',
|
|
'pdf_orientation' => 'portrait',
|
|
])->assertStatus(422)->assertJsonValidationErrors('pdf_paper_width');
|
|
});
|
|
|
|
test('pdf configuration rejects an unknown orientation', function () {
|
|
postJson('/api/v1/pdf/config', [
|
|
'pdf_driver' => 'dompdf',
|
|
'pdf_paper_width' => '210mm',
|
|
'pdf_paper_height' => '297mm',
|
|
'pdf_orientation' => 'sideways',
|
|
])->assertStatus(422)->assertJsonValidationErrors('pdf_orientation');
|
|
});
|
|
|
|
/**
|
|
* A zero margin is a deliberate choice. AppConfigProvider guards its settings
|
|
* with !empty(), and '0mm' is fine there, but a bare '0' would not be -- this
|
|
* pins the behaviour either way.
|
|
*/
|
|
test('page numbers can be turned on and read back', function () {
|
|
postJson('/api/v1/pdf/config', [
|
|
'pdf_driver' => 'gotenberg',
|
|
'gotenberg_host' => 'https://pdf.example.com',
|
|
'pdf_paper_width' => '210mm',
|
|
'pdf_paper_height' => '297mm',
|
|
'pdf_orientation' => 'portrait',
|
|
'pdf_page_numbers' => true,
|
|
])->assertOk();
|
|
|
|
getJson('/api/v1/pdf/config')->assertOk()->assertJson(['pdf_page_numbers' => true]);
|
|
});
|
|
|
|
/**
|
|
* The dompdf form does not render the page-numbers control, since dompdf cannot
|
|
* repeat a footer. Saving from it must leave the stored choice alone rather than
|
|
* writing an absent field as false.
|
|
*/
|
|
test('saving from the dompdf form leaves the page-number choice alone', function () {
|
|
postJson('/api/v1/pdf/config', [
|
|
'pdf_driver' => 'gotenberg',
|
|
'gotenberg_host' => 'https://pdf.example.com',
|
|
'pdf_paper_width' => '210mm',
|
|
'pdf_paper_height' => '297mm',
|
|
'pdf_orientation' => 'portrait',
|
|
'pdf_page_numbers' => true,
|
|
])->assertOk();
|
|
|
|
postJson('/api/v1/pdf/config', [
|
|
'pdf_driver' => 'dompdf',
|
|
'pdf_paper_width' => '210mm',
|
|
'pdf_paper_height' => '297mm',
|
|
'pdf_orientation' => 'portrait',
|
|
])->assertOk();
|
|
|
|
getJson('/api/v1/pdf/config')->assertOk()->assertJson(['pdf_page_numbers' => true]);
|
|
});
|
|
|
|
test('page numbers can be turned back off', function () {
|
|
postJson('/api/v1/pdf/config', [
|
|
'pdf_driver' => 'gotenberg',
|
|
'gotenberg_host' => 'https://pdf.example.com',
|
|
'pdf_paper_width' => '210mm',
|
|
'pdf_paper_height' => '297mm',
|
|
'pdf_orientation' => 'portrait',
|
|
'pdf_page_numbers' => false,
|
|
])->assertOk();
|
|
|
|
// Stored as the string '0', which !empty() would have discarded.
|
|
$this->assertDatabaseHas('settings', ['option' => 'pdf_page_numbers', 'value' => '0']);
|
|
|
|
getJson('/api/v1/pdf/config')->assertOk()->assertJson(['pdf_page_numbers' => false]);
|
|
});
|
|
|
|
test('a zero margin survives the round trip', function () {
|
|
postJson('/api/v1/pdf/config', [
|
|
'pdf_driver' => 'dompdf',
|
|
'pdf_paper_width' => '210mm',
|
|
'pdf_paper_height' => '297mm',
|
|
'pdf_orientation' => 'portrait',
|
|
'pdf_margin_top' => '0mm',
|
|
'pdf_margin_right' => '0mm',
|
|
'pdf_margin_bottom' => '0mm',
|
|
'pdf_margin_left' => '0mm',
|
|
])->assertOk();
|
|
|
|
getJson('/api/v1/pdf/config')->assertOk()->assertJson(['pdf_margin_top' => '0mm']);
|
|
});
|
|
|
|
test('get app version', function () {
|
|
getJson('/api/v1/app/version')
|
|
->assertOk()
|
|
->assertJsonStructure([
|
|
'version',
|
|
'channel',
|
|
]);
|
|
});
|