Skip to content

Commit a014c68

Browse files
authored
Merge pull request #6 from LibreCodeCoop/feat/config-contracts
feat(config): add EnvironmentConfig and CertConfig DTOs; refactor NfseClient constructor
2 parents 53d2193 + cc02652 commit a014c68

File tree

6 files changed

+221
-52
lines changed

6 files changed

+221
-52
lines changed

src/Config/CertConfig.php

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
<?php
2+
3+
// SPDX-FileCopyrightText: 2026 LibreCode coop and contributors
4+
// SPDX-License-Identifier: AGPL-3.0-or-later
5+
6+
declare(strict_types=1);
7+
8+
namespace LibreCodeCoop\NfsePHP\Config;
9+
10+
/**
11+
* Immutable certificate configuration for NFS-e mTLS authentication.
12+
*
13+
* Holds the contributor CNPJ, the filesystem path to the PFX bundle, and
14+
* the OpenBao KV path from which the PFX password is retrieved just-in-time.
15+
* The password is never cached across job boundaries.
16+
*/
17+
final readonly class CertConfig
18+
{
19+
public function __construct(
20+
/** CNPJ do prestador de serviço (only digits, 14 chars). */
21+
public string $cnpj,
22+
23+
/** Absolute filesystem path to the PFX certificate bundle. */
24+
public string $pfxPath,
25+
26+
/** OpenBao KV path for the PFX password (e.g. "secret/nfse/29842527000145"). */
27+
public string $vaultPath,
28+
) {
29+
}
30+
}

src/Config/EnvironmentConfig.php

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
<?php
2+
3+
// SPDX-FileCopyrightText: 2026 LibreCode coop and contributors
4+
// SPDX-License-Identifier: AGPL-3.0-or-later
5+
6+
declare(strict_types=1);
7+
8+
namespace LibreCodeCoop\NfsePHP\Config;
9+
10+
/**
11+
* Immutable configuration for the NFS-e environment (sandbox vs. production).
12+
*
13+
* When no custom base URL is supplied the appropriate official endpoint is
14+
* selected automatically from the sandboxMode flag:
15+
*
16+
* - Production: https://nfse.fazenda.gov.br/NFS-e/api/v1
17+
* - Sandbox: https://hml.nfse.fazenda.gov.br/NFS-e/api/v1
18+
*/
19+
final readonly class EnvironmentConfig
20+
{
21+
private const BASE_URL_PROD = 'https://nfse.fazenda.gov.br/NFS-e/api/v1';
22+
private const BASE_URL_SANDBOX = 'https://hml.nfse.fazenda.gov.br/NFS-e/api/v1';
23+
24+
public string $baseUrl;
25+
26+
public function __construct(
27+
public bool $sandboxMode = false,
28+
?string $baseUrl = null,
29+
) {
30+
$this->baseUrl = $baseUrl ?? ($sandboxMode
31+
? self::BASE_URL_SANDBOX
32+
: self::BASE_URL_PROD);
33+
}
34+
}

src/Http/NfseClient.php

Lines changed: 5 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77

88
namespace LibreCodeCoop\NfsePHP\Http;
99

10+
use LibreCodeCoop\NfsePHP\Config\CertConfig;
11+
use LibreCodeCoop\NfsePHP\Config\EnvironmentConfig;
1012
use LibreCodeCoop\NfsePHP\Contracts\NfseClientInterface;
1113
use LibreCodeCoop\NfsePHP\Contracts\SecretStoreInterface;
1214
use LibreCodeCoop\NfsePHP\Contracts\XmlSignerInterface;
@@ -25,25 +27,19 @@
2527
*
2628
* Communicates with the SEFIN gateway to issue, query, and cancel NFS-e.
2729
* All requests carry a signed DPS XML payload.
28-
*
29-
* Gateway sandbox base URL: https://hml.nfse.fazenda.gov.br/NFS-e/api/v1
30-
* Gateway production base URL: https://nfse.fazenda.gov.br/NFS-e/api/v1
3130
*/
3231
class NfseClient implements NfseClientInterface
3332
{
34-
private const BASE_URL_PROD = 'https://nfse.fazenda.gov.br/NFS-e/api/v1';
35-
private const BASE_URL_SANDBOX = 'https://hml.nfse.fazenda.gov.br/NFS-e/api/v1';
36-
3733
private readonly string $baseUrl;
3834
private readonly XmlSignerInterface $signer;
3935

4036
public function __construct(
37+
private readonly EnvironmentConfig $environment,
38+
private readonly CertConfig $cert,
4139
private readonly SecretStoreInterface $secretStore,
42-
private readonly bool $sandboxMode = false,
43-
?string $baseUrlOverride = null,
4440
?XmlSignerInterface $signer = null,
4541
) {
46-
$this->baseUrl = $baseUrlOverride ?? ($sandboxMode ? self::BASE_URL_SANDBOX : self::BASE_URL_PROD);
42+
$this->baseUrl = $environment->baseUrl;
4743
$this->signer = $signer ?? new DpsSigner($secretStore);
4844
}
4945

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
<?php
2+
3+
// SPDX-FileCopyrightText: 2026 LibreCode coop and contributors
4+
// SPDX-License-Identifier: AGPL-3.0-or-later
5+
6+
declare(strict_types=1);
7+
8+
namespace LibreCodeCoop\NfsePHP\Tests\Unit\Config;
9+
10+
use LibreCodeCoop\NfsePHP\Config\CertConfig;
11+
use LibreCodeCoop\NfsePHP\Tests\TestCase;
12+
13+
/**
14+
* @covers \LibreCodeCoop\NfsePHP\Config\CertConfig
15+
*/
16+
class CertConfigTest extends TestCase
17+
{
18+
public function testStoresAllProperties(): void
19+
{
20+
$config = new CertConfig(
21+
cnpj: '29842527000145',
22+
pfxPath: '/etc/nfse/certs/company.pfx',
23+
vaultPath: 'secret/nfse/29842527000145',
24+
);
25+
26+
self::assertSame('29842527000145', $config->cnpj);
27+
self::assertSame('/etc/nfse/certs/company.pfx', $config->pfxPath);
28+
self::assertSame('secret/nfse/29842527000145', $config->vaultPath);
29+
}
30+
31+
public function testCnpjIsReadonly(): void
32+
{
33+
$config = new CertConfig(
34+
cnpj: '29842527000145',
35+
pfxPath: '/etc/nfse/certs/company.pfx',
36+
vaultPath: 'secret/nfse/29842527000145',
37+
);
38+
39+
$this->expectException(\Error::class);
40+
/** @phpstan-ignore-next-line */
41+
$config->cnpj = 'other';
42+
}
43+
44+
public function testPfxPathIsReadonly(): void
45+
{
46+
$config = new CertConfig(
47+
cnpj: '29842527000145',
48+
pfxPath: '/etc/nfse/certs/company.pfx',
49+
vaultPath: 'secret/nfse/29842527000145',
50+
);
51+
52+
$this->expectException(\Error::class);
53+
/** @phpstan-ignore-next-line */
54+
$config->pfxPath = 'other';
55+
}
56+
57+
public function testVaultPathIsReadonly(): void
58+
{
59+
$config = new CertConfig(
60+
cnpj: '29842527000145',
61+
pfxPath: '/etc/nfse/certs/company.pfx',
62+
vaultPath: 'secret/nfse/29842527000145',
63+
);
64+
65+
$this->expectException(\Error::class);
66+
/** @phpstan-ignore-next-line */
67+
$config->vaultPath = 'other';
68+
}
69+
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
<?php
2+
3+
// SPDX-FileCopyrightText: 2026 LibreCode coop and contributors
4+
// SPDX-License-Identifier: AGPL-3.0-or-later
5+
6+
declare(strict_types=1);
7+
8+
namespace LibreCodeCoop\NfsePHP\Tests\Unit\Config;
9+
10+
use LibreCodeCoop\NfsePHP\Config\EnvironmentConfig;
11+
use LibreCodeCoop\NfsePHP\Tests\TestCase;
12+
13+
/**
14+
* @covers \LibreCodeCoop\NfsePHP\Config\EnvironmentConfig
15+
*/
16+
class EnvironmentConfigTest extends TestCase
17+
{
18+
public function testDefaultsToProductionUrl(): void
19+
{
20+
$config = new EnvironmentConfig();
21+
22+
self::assertFalse($config->sandboxMode);
23+
self::assertSame(
24+
'https://nfse.fazenda.gov.br/NFS-e/api/v1',
25+
$config->baseUrl,
26+
);
27+
}
28+
29+
public function testSandboxModeSelectsSandboxUrl(): void
30+
{
31+
$config = new EnvironmentConfig(sandboxMode: true);
32+
33+
self::assertTrue($config->sandboxMode);
34+
self::assertSame(
35+
'https://hml.nfse.fazenda.gov.br/NFS-e/api/v1',
36+
$config->baseUrl,
37+
);
38+
}
39+
40+
public function testCustomBaseUrlOverridesMode(): void
41+
{
42+
$custom = 'http://localhost:8080/NFS-e/api/v1';
43+
$config = new EnvironmentConfig(sandboxMode: false, baseUrl: $custom);
44+
45+
self::assertFalse($config->sandboxMode);
46+
self::assertSame($custom, $config->baseUrl);
47+
}
48+
49+
public function testCustomBaseUrlOverridesSandboxUrl(): void
50+
{
51+
$custom = 'http://mock-server/NFS-e/api/v1';
52+
$config = new EnvironmentConfig(sandboxMode: true, baseUrl: $custom);
53+
54+
self::assertSame($custom, $config->baseUrl);
55+
}
56+
}

tests/Unit/Http/NfseClientTest.php

Lines changed: 27 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@
99

1010
use donatj\MockWebServer\MockWebServer;
1111
use donatj\MockWebServer\Response;
12+
use LibreCodeCoop\NfsePHP\Config\CertConfig;
13+
use LibreCodeCoop\NfsePHP\Config\EnvironmentConfig;
1214
use LibreCodeCoop\NfsePHP\Contracts\XmlSignerInterface;
1315
use LibreCodeCoop\NfsePHP\Dto\DpsData;
1416
use LibreCodeCoop\NfsePHP\Exception\CancellationException;
@@ -65,13 +67,7 @@ public function testEmitReturnsReceiptDataOnSuccess(): void
6567
new Response($payload, ['Content-Type' => 'application/json'], 200)
6668
);
6769

68-
$store = new NoOpSecretStore();
69-
$client = new NfseClient(
70-
secretStore: $store,
71-
sandboxMode: false,
72-
baseUrlOverride: self::$server->getServerRoot() . '/NFS-e/api/v1',
73-
signer: $this->signer,
74-
);
70+
$client = $this->makeClient($this->signer);
7571

7672
$dps = $this->makeDps();
7773
$receipt = $client->emit($dps);
@@ -94,11 +90,7 @@ public function testQueryReturnsReceiptDataOnSuccess(): void
9490
new Response($payload, ['Content-Type' => 'application/json'], 200)
9591
);
9692

97-
$store = new NoOpSecretStore();
98-
$client = new NfseClient(
99-
secretStore: $store,
100-
baseUrlOverride: self::$server->getServerRoot() . '/NFS-e/api/v1',
101-
);
93+
$client = $this->makeClient();
10294

10395
$receipt = $client->query('xyz-456');
10496

@@ -112,11 +104,7 @@ public function testCancelReturnsTrueOnSuccess(): void
112104
new Response('{}', ['Content-Type' => 'application/json'], 200)
113105
);
114106

115-
$store = new NoOpSecretStore();
116-
$client = new NfseClient(
117-
secretStore: $store,
118-
baseUrlOverride: self::$server->getServerRoot() . '/NFS-e/api/v1',
119-
);
107+
$client = $this->makeClient();
120108

121109
self::assertTrue($client->cancel('abc-123', 'Cancelamento a pedido do tomador'));
122110
}
@@ -134,11 +122,7 @@ public function testEmitThrowsIssuanceExceptionWhenGatewayRejects(): void
134122
new Response($payload, ['Content-Type' => 'application/json'], 422),
135123
);
136124

137-
$client = new NfseClient(
138-
secretStore: new NoOpSecretStore(),
139-
baseUrlOverride: self::$server->getServerRoot() . '/NFS-e/api/v1',
140-
signer: $this->signer,
141-
);
125+
$client = $this->makeClient($this->signer);
142126

143127
$this->expectException(IssuanceException::class);
144128
$client->emit($this->makeDps());
@@ -153,11 +137,7 @@ public function testIssuanceExceptionCarriesErrorCodeHttpStatusAndUpstreamPayloa
153137
new Response(json_encode($errorData, JSON_THROW_ON_ERROR), ['Content-Type' => 'application/json'], 422),
154138
);
155139

156-
$client = new NfseClient(
157-
secretStore: new NoOpSecretStore(),
158-
baseUrlOverride: self::$server->getServerRoot() . '/NFS-e/api/v1',
159-
signer: $this->signer,
160-
);
140+
$client = $this->makeClient($this->signer);
161141

162142
try {
163143
$client->emit($this->makeDps());
@@ -176,10 +156,7 @@ public function testQueryThrowsQueryExceptionWhenGatewayReturnsError(): void
176156
new Response('{"error":"not found"}', ['Content-Type' => 'application/json'], 404),
177157
);
178158

179-
$client = new NfseClient(
180-
secretStore: new NoOpSecretStore(),
181-
baseUrlOverride: self::$server->getServerRoot() . '/NFS-e/api/v1',
182-
);
159+
$client = $this->makeClient();
183160

184161
$this->expectException(QueryException::class);
185162
$client->query('missing-key');
@@ -192,10 +169,7 @@ public function testQueryExceptionCarriesErrorCodeAndHttpStatus(): void
192169
new Response('{"error":"not found"}', ['Content-Type' => 'application/json'], 404),
193170
);
194171

195-
$client = new NfseClient(
196-
secretStore: new NoOpSecretStore(),
197-
baseUrlOverride: self::$server->getServerRoot() . '/NFS-e/api/v1',
198-
);
172+
$client = $this->makeClient();
199173

200174
try {
201175
$client->query('missing-key');
@@ -213,10 +187,7 @@ public function testCancelThrowsCancellationExceptionWhenGatewayReturnsError():
213187
new Response('{"error":"cannot cancel"}', ['Content-Type' => 'application/json'], 409),
214188
);
215189

216-
$client = new NfseClient(
217-
secretStore: new NoOpSecretStore(),
218-
baseUrlOverride: self::$server->getServerRoot() . '/NFS-e/api/v1',
219-
);
190+
$client = $this->makeClient();
220191

221192
$this->expectException(CancellationException::class);
222193
$client->cancel('blocked-key', 'a pedido do tomador');
@@ -229,10 +200,7 @@ public function testCancellationExceptionCarriesErrorCodeAndHttpStatus(): void
229200
new Response('{"error":"cannot cancel"}', ['Content-Type' => 'application/json'], 409),
230201
);
231202

232-
$client = new NfseClient(
233-
secretStore: new NoOpSecretStore(),
234-
baseUrlOverride: self::$server->getServerRoot() . '/NFS-e/api/v1',
235-
);
203+
$client = $this->makeClient();
236204

237205
try {
238206
$client->cancel('blocked-key', 'a pedido do tomador');
@@ -245,6 +213,22 @@ public function testCancellationExceptionCarriesErrorCodeAndHttpStatus(): void
245213

246214
// -------------------------------------------------------------------------
247215

216+
private function makeClient(?XmlSignerInterface $signer = null): NfseClient
217+
{
218+
return new NfseClient(
219+
environment: new EnvironmentConfig(
220+
baseUrl: self::$server->getServerRoot() . '/NFS-e/api/v1',
221+
),
222+
cert: new CertConfig(
223+
cnpj: '29842527000145',
224+
pfxPath: '/dev/null',
225+
vaultPath: 'secret/nfse/29842527000145',
226+
),
227+
secretStore: new NoOpSecretStore(),
228+
signer: $signer,
229+
);
230+
}
231+
248232
private function makeDps(): DpsData
249233
{
250234
return new DpsData(

0 commit comments

Comments
 (0)