API updadte

This commit is contained in:
2025-11-08 23:44:33 +01:00
parent 81374cc659
commit 5835eb15ed
29 changed files with 4066 additions and 54 deletions

10
.gitignore vendored
View File

@@ -84,3 +84,13 @@ docker-compose.override.yml
/var/ /var/
/vendor/ /vendor/
###< symfony/framework-bundle ### ###< symfony/framework-bundle ###
###> phpunit/phpunit ###
/phpunit.xml
/.phpunit.cache/
###< phpunit/phpunit ###
###> friendsofphp/php-cs-fixer ###
/.php-cs-fixer.php
/.php-cs-fixer.cache
###< friendsofphp/php-cs-fixer ###

30
.php-cs-fixer.dist.php Normal file
View File

@@ -0,0 +1,30 @@
<?php
$finder = (new PhpCsFixer\Finder())
->in(__DIR__)
->exclude('var')
->exclude('vendor')
->exclude('migrations')
->notPath('bin/console')
->notPath('public/index.php')
;
return (new PhpCsFixer\Config())
->setRules([
'@Symfony' => true,
'array_syntax' => ['syntax' => 'short'],
'ordered_imports' => ['sort_algorithm' => 'alpha'],
'no_unused_imports' => true,
'not_operator_with_successor_space' => true,
'trailing_comma_in_multiline' => true,
'phpdoc_scalar' => true,
'unary_operator_spaces' => true,
'binary_operator_spaces' => true,
'blank_line_before_statement' => [
'statements' => ['break', 'continue', 'declare', 'return', 'throw', 'try'],
],
'phpdoc_single_line_var_spacing' => true,
'phpdoc_var_without_name' => true,
])
->setFinder($finder)
;

102
README.md
View File

@@ -14,6 +14,8 @@ Eine moderne Webanwendung zur Immobilienberechnung, entwickelt mit Symfony 7.3,
- **Öffentliche Ressourcen**: Bundesländer und Heizungstypen ohne Authentifizierung abrufbar - **Öffentliche Ressourcen**: Bundesländer und Heizungstypen ohne Authentifizierung abrufbar
- **Docker-Setup**: Vollständig containerisierte Entwicklungsumgebung - **Docker-Setup**: Vollständig containerisierte Entwicklungsumgebung
- **Datenbank-Migrationen**: Versionskontrollierte Datenbankschema-Verwaltung mit Doctrine - **Datenbank-Migrationen**: Versionskontrollierte Datenbankschema-Verwaltung mit Doctrine
- **Testing**: Umfassende Test-Suite mit PHPUnit für Unit- und Funktions-Tests
- **Code Quality**: PHP-CS-Fixer für konsistenten Code-Style nach Symfony Standards
- **CORS-Unterstützung**: Konfigurierbare CORS-Einstellungen für API-Zugriffe - **CORS-Unterstützung**: Konfigurierbare CORS-Einstellungen für API-Zugriffe
- **phpMyAdmin**: Integriertes Datenbank-Verwaltungstool - **phpMyAdmin**: Integriertes Datenbank-Verwaltungstool
@@ -182,6 +184,106 @@ Wichtigste Endpunkte:
- Grundbuchkosten: ca. 0,5% des Kaufpreises - Grundbuchkosten: ca. 0,5% des Kaufpreises
- Grunderwerbsteuer: abhängig vom Bundesland (3,5% - 6,5%) - Grunderwerbsteuer: abhängig vom Bundesland (3,5% - 6,5%)
## Testing & Code Quality
Das Projekt verwendet **PHPUnit** für Unit- und Funktionstests sowie **PHP-CS-Fixer** für Code-Qualität und Linting.
### Tests ausführen
#### Alle Tests ausführen
```bash
docker-compose exec web php bin/phpunit
```
#### Tests mit Ausgabedetails
```bash
docker-compose exec web php bin/phpunit --verbose
```
#### Nur bestimmte Testklassen ausführen
```bash
# Entity-Tests
docker-compose exec web php bin/phpunit tests/Entity
# API-Tests
docker-compose exec web php bin/phpunit tests/Api
# Einzelne Testklasse
docker-compose exec web php bin/phpunit tests/Entity/UserTest.php
```
#### Code Coverage Report (optional)
```bash
docker-compose exec web php bin/phpunit --coverage-text
```
### Code-Linting mit PHP-CS-Fixer
#### Code-Style prüfen (Dry-Run)
```bash
docker-compose exec web vendor/bin/php-cs-fixer fix --dry-run --diff
```
#### Code automatisch formatieren
```bash
docker-compose exec web vendor/bin/php-cs-fixer fix
```
#### Bestimmte Verzeichnisse prüfen
```bash
# Nur src/ Verzeichnis
docker-compose exec web vendor/bin/php-cs-fixer fix src --dry-run
# Nur tests/ Verzeichnis
docker-compose exec web vendor/bin/php-cs-fixer fix tests --dry-run
```
### Test-Struktur
```
tests/
├── Entity/ # Unit-Tests für Entities
│ ├── UserTest.php # User-Entity Tests
│ ├── ImmobilieTest.php # Immobilie-Entity Tests (inkl. Kaufnebenkosten)
│ ├── BundeslandTest.php # Bundesland-Entity Tests
│ └── HeizungstypTest.php # Heizungstyp-Entity Tests
└── Api/ # Funktions-/API-Tests
├── BundeslandApiTest.php # Bundesländer API-Tests
├── HeizungstypApiTest.php # Heizungstypen API-Tests
└── ApiDocumentationTest.php # API-Dokumentations-Tests
```
### Test-Abdeckung
Die Tests decken folgende Bereiche ab:
**Entity-Tests:**
- User-Entity: API-Key-Generierung, Rollen, UserInterface-Methoden
- Immobilie-Entity: Gesamtflächen-Berechnung, Kaufnebenkosten-Berechnung
- Bundesland-Entity: Grunderwerbsteuer-Werte für alle Bundesländer
- Heizungstyp-Entity: CRUD-Operationen
**API-Tests:**
- Öffentlicher Zugriff auf Bundesländer und Heizungstypen (GET)
- Authentifizierung für CREATE-Operationen
- API-Dokumentation Zugänglichkeit
### Code-Style Regeln
Die PHP-CS-Fixer-Konfiguration (`.php-cs-fixer.dist.php`) verwendet:
- Symfony Coding Standards
- Short Array Syntax
- Sortierte Imports
- Trailing Commas in Multiline Arrays
- Und weitere PSR-12 kompatible Regeln
## Entwicklung ## Entwicklung
### Neue Entity erstellen ### Neue Entity erstellen

4
bin/phpunit Normal file
View File

@@ -0,0 +1,4 @@
#!/usr/bin/env php
<?php
require dirname(__DIR__).'/vendor/phpunit/phpunit/phpunit';

View File

@@ -45,5 +45,11 @@
"cache:clear": "symfony-cmd", "cache:clear": "symfony-cmd",
"assets:install %PUBLIC_DIR%": "symfony-cmd" "assets:install %PUBLIC_DIR%": "symfony-cmd"
} }
},
"require-dev": {
"phpunit/phpunit": "^12.4",
"symfony/browser-kit": "^7.3",
"symfony/css-selector": "^7.3",
"friendsofphp/php-cs-fixer": "^3.89"
} }
} }

3281
composer.lock generated

File diff suppressed because it is too large Load Diff

44
phpunit.dist.xml Normal file
View File

@@ -0,0 +1,44 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- https://phpunit.readthedocs.io/en/latest/configuration.html -->
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
colors="true"
failOnDeprecation="true"
failOnNotice="true"
failOnWarning="true"
bootstrap="tests/bootstrap.php"
cacheDirectory=".phpunit.cache"
>
<php>
<ini name="display_errors" value="1" />
<ini name="error_reporting" value="-1" />
<server name="APP_ENV" value="test" force="true" />
<server name="SHELL_VERBOSITY" value="-1" />
</php>
<testsuites>
<testsuite name="Project Test Suite">
<directory>tests</directory>
</testsuite>
</testsuites>
<source ignoreSuppressionOfDeprecations="true"
ignoreIndirectDeprecations="true"
restrictNotices="true"
restrictWarnings="true"
>
<include>
<directory>src</directory>
</include>
<deprecationTrigger>
<method>Doctrine\Deprecations\Deprecation::trigger</method>
<method>Doctrine\Deprecations\Deprecation::delegateTriggerToBackend</method>
<function>trigger_deprecation</function>
</deprecationTrigger>
</source>
<extensions>
</extensions>
</phpunit>

View File

@@ -19,12 +19,12 @@ class CurrentUserExtension implements QueryCollectionExtensionInterface, QueryIt
) { ) {
} }
public function applyToCollection(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, Operation $operation = null, array $context = []): void public function applyToCollection(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void
{ {
$this->addWhere($queryBuilder, $resourceClass); $this->addWhere($queryBuilder, $resourceClass);
} }
public function applyToItem(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, array $identifiers, Operation $operation = null, array $context = []): void public function applyToItem(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, array $identifiers, ?Operation $operation = null, array $context = []): void
{ {
$this->addWhere($queryBuilder, $resourceClass); $this->addWhere($queryBuilder, $resourceClass);
} }
@@ -41,11 +41,12 @@ class CurrentUserExtension implements QueryCollectionExtensionInterface, QueryIt
// Wenn nicht eingeloggt, keine Ergebnisse // Wenn nicht eingeloggt, keine Ergebnisse
if (! $user instanceof User) { if (! $user instanceof User) {
$queryBuilder->andWhere('1 = 0'); $queryBuilder->andWhere('1 = 0');
return; return;
} }
// Admin sieht alles // Admin sieht alles
if ($user->getRole() === UserRole::ADMIN) { if (UserRole::ADMIN === $user->getRole()) {
return; return;
} }

View File

@@ -3,11 +3,11 @@
namespace App\Entity; namespace App\Entity;
use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Post; use ApiPlatform\Metadata\Post;
use ApiPlatform\Metadata\Put; use ApiPlatform\Metadata\Put;
use ApiPlatform\Metadata\Delete;
use App\Repository\BundeslandRepository; use App\Repository\BundeslandRepository;
use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert; use Symfony\Component\Validator\Constraints as Assert;
@@ -20,7 +20,7 @@ use Symfony\Component\Validator\Constraints as Assert;
new GetCollection(security: 'is_granted("PUBLIC_ACCESS")'), new GetCollection(security: 'is_granted("PUBLIC_ACCESS")'),
new Post(security: 'is_granted("ROLE_ADMIN") or is_granted("ROLE_TECHNICAL")'), new Post(security: 'is_granted("ROLE_ADMIN") or is_granted("ROLE_TECHNICAL")'),
new Put(security: 'is_granted("ROLE_ADMIN") or is_granted("ROLE_TECHNICAL")'), new Put(security: 'is_granted("ROLE_ADMIN") or is_granted("ROLE_TECHNICAL")'),
new Delete(security: 'is_granted("ROLE_ADMIN") or is_granted("ROLE_TECHNICAL")') new Delete(security: 'is_granted("ROLE_ADMIN") or is_granted("ROLE_TECHNICAL")'),
] ]
)] )]
class Bundesland class Bundesland
@@ -53,6 +53,7 @@ class Bundesland
public function setName(string $name): self public function setName(string $name): self
{ {
$this->name = $name; $this->name = $name;
return $this; return $this;
} }
@@ -64,6 +65,7 @@ class Bundesland
public function setGrunderwerbsteuer(float $grunderwerbsteuer): self public function setGrunderwerbsteuer(float $grunderwerbsteuer): self
{ {
$this->grunderwerbsteuer = $grunderwerbsteuer; $this->grunderwerbsteuer = $grunderwerbsteuer;
return $this; return $this;
} }
} }

View File

@@ -3,11 +3,11 @@
namespace App\Entity; namespace App\Entity;
use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Post; use ApiPlatform\Metadata\Post;
use ApiPlatform\Metadata\Put; use ApiPlatform\Metadata\Put;
use ApiPlatform\Metadata\Delete;
use App\Repository\HeizungstypRepository; use App\Repository\HeizungstypRepository;
use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert; use Symfony\Component\Validator\Constraints as Assert;
@@ -20,7 +20,7 @@ use Symfony\Component\Validator\Constraints as Assert;
new GetCollection(security: 'is_granted("PUBLIC_ACCESS")'), new GetCollection(security: 'is_granted("PUBLIC_ACCESS")'),
new Post(security: 'is_granted("ROLE_ADMIN") or is_granted("ROLE_TECHNICAL")'), new Post(security: 'is_granted("ROLE_ADMIN") or is_granted("ROLE_TECHNICAL")'),
new Put(security: 'is_granted("ROLE_ADMIN") or is_granted("ROLE_TECHNICAL")'), new Put(security: 'is_granted("ROLE_ADMIN") or is_granted("ROLE_TECHNICAL")'),
new Delete(security: 'is_granted("ROLE_ADMIN") or is_granted("ROLE_TECHNICAL")') new Delete(security: 'is_granted("ROLE_ADMIN") or is_granted("ROLE_TECHNICAL")'),
] ]
)] )]
class Heizungstyp class Heizungstyp
@@ -48,6 +48,7 @@ class Heizungstyp
public function setName(string $name): self public function setName(string $name): self
{ {
$this->name = $name; $this->name = $name;
return $this; return $this;
} }
} }

View File

@@ -3,11 +3,11 @@
namespace App\Entity; namespace App\Entity;
use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Post;
use ApiPlatform\Metadata\Patch; use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Delete; use ApiPlatform\Metadata\Post;
use App\Enum\ImmobilienTyp; use App\Enum\ImmobilienTyp;
use App\Repository\ImmobilieRepository; use App\Repository\ImmobilieRepository;
use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\Mapping as ORM;
@@ -22,7 +22,7 @@ use Symfony\Component\Validator\Constraints as Assert;
new GetCollection(), new GetCollection(),
new Post(), new Post(),
new Patch(security: 'is_granted("edit", object)'), new Patch(security: 'is_granted("edit", object)'),
new Delete(security: 'is_granted("delete", object)') new Delete(security: 'is_granted("delete", object)'),
], ],
security: 'is_granted("ROLE_USER")' security: 'is_granted("ROLE_USER")'
)] )]
@@ -125,6 +125,7 @@ class Immobilie
public function setAdresse(string $adresse): self public function setAdresse(string $adresse): self
{ {
$this->adresse = $adresse; $this->adresse = $adresse;
return $this; return $this;
} }
@@ -136,6 +137,7 @@ class Immobilie
public function setWohnflaeche(int $wohnflaeche): self public function setWohnflaeche(int $wohnflaeche): self
{ {
$this->wohnflaeche = $wohnflaeche; $this->wohnflaeche = $wohnflaeche;
return $this; return $this;
} }
@@ -147,6 +149,7 @@ class Immobilie
public function setNutzflaeche(int $nutzflaeche): self public function setNutzflaeche(int $nutzflaeche): self
{ {
$this->nutzflaeche = $nutzflaeche; $this->nutzflaeche = $nutzflaeche;
return $this; return $this;
} }
@@ -163,6 +166,7 @@ class Immobilie
public function setGarage(bool $garage): self public function setGarage(bool $garage): self
{ {
$this->garage = $garage; $this->garage = $garage;
return $this; return $this;
} }
@@ -174,6 +178,7 @@ class Immobilie
public function setZimmer(int $zimmer): self public function setZimmer(int $zimmer): self
{ {
$this->zimmer = $zimmer; $this->zimmer = $zimmer;
return $this; return $this;
} }
@@ -185,6 +190,7 @@ class Immobilie
public function setBaujahr(?int $baujahr): self public function setBaujahr(?int $baujahr): self
{ {
$this->baujahr = $baujahr; $this->baujahr = $baujahr;
return $this; return $this;
} }
@@ -196,6 +202,7 @@ class Immobilie
public function setTyp(ImmobilienTyp $typ): self public function setTyp(ImmobilienTyp $typ): self
{ {
$this->typ = $typ; $this->typ = $typ;
return $this; return $this;
} }
@@ -207,6 +214,7 @@ class Immobilie
public function setBeschreibung(?string $beschreibung): self public function setBeschreibung(?string $beschreibung): self
{ {
$this->beschreibung = $beschreibung; $this->beschreibung = $beschreibung;
return $this; return $this;
} }
@@ -218,6 +226,7 @@ class Immobilie
public function setEtage(?int $etage): self public function setEtage(?int $etage): self
{ {
$this->etage = $etage; $this->etage = $etage;
return $this; return $this;
} }
@@ -229,6 +238,7 @@ class Immobilie
public function setHeizungstyp(?Heizungstyp $heizungstyp): self public function setHeizungstyp(?Heizungstyp $heizungstyp): self
{ {
$this->heizungstyp = $heizungstyp; $this->heizungstyp = $heizungstyp;
return $this; return $this;
} }
@@ -240,6 +250,7 @@ class Immobilie
public function setCreatedAt(\DateTimeInterface $createdAt): self public function setCreatedAt(\DateTimeInterface $createdAt): self
{ {
$this->createdAt = $createdAt; $this->createdAt = $createdAt;
return $this; return $this;
} }
@@ -251,6 +262,7 @@ class Immobilie
public function setUpdatedAt(\DateTimeInterface $updatedAt): self public function setUpdatedAt(\DateTimeInterface $updatedAt): self
{ {
$this->updatedAt = $updatedAt; $this->updatedAt = $updatedAt;
return $this; return $this;
} }
@@ -262,6 +274,7 @@ class Immobilie
public function setAbschreibungszeit(?int $abschreibungszeit): self public function setAbschreibungszeit(?int $abschreibungszeit): self
{ {
$this->abschreibungszeit = $abschreibungszeit; $this->abschreibungszeit = $abschreibungszeit;
return $this; return $this;
} }
@@ -273,6 +286,7 @@ class Immobilie
public function setBundesland(?Bundesland $bundesland): self public function setBundesland(?Bundesland $bundesland): self
{ {
$this->bundesland = $bundesland; $this->bundesland = $bundesland;
return $this; return $this;
} }
@@ -284,6 +298,7 @@ class Immobilie
public function setKaufpreis(?int $kaufpreis): self public function setKaufpreis(?int $kaufpreis): self
{ {
$this->kaufpreis = $kaufpreis; $this->kaufpreis = $kaufpreis;
return $this; return $this;
} }
@@ -295,11 +310,12 @@ class Immobilie
public function setVerwalter(User $verwalter): self public function setVerwalter(User $verwalter): self
{ {
$this->verwalter = $verwalter; $this->verwalter = $verwalter;
return $this; return $this;
} }
/** /**
* Berechnet die Gesamtfläche (Wohnfläche + Nutzfläche) * Berechnet die Gesamtfläche (Wohnfläche + Nutzfläche).
*/ */
public function getGesamtflaeche(): int public function getGesamtflaeche(): int
{ {
@@ -308,7 +324,7 @@ class Immobilie
/** /**
* Berechnet die Kaufnebenkosten basierend auf dem Bundesland * Berechnet die Kaufnebenkosten basierend auf dem Bundesland
* Rückgabe: Array mit Notar, Grundbuch, Grunderwerbsteuer und Gesamt * Rückgabe: Array mit Notar, Grundbuch, Grunderwerbsteuer und Gesamt.
*/ */
public function getKaufnebenkosten(): array public function getKaufnebenkosten(): array
{ {
@@ -317,7 +333,7 @@ class Immobilie
'notar' => 0, 'notar' => 0,
'grundbuch' => 0, 'grundbuch' => 0,
'grunderwerbsteuer' => 0, 'grunderwerbsteuer' => 0,
'gesamt' => 0 'gesamt' => 0,
]; ];
} }
@@ -337,7 +353,7 @@ class Immobilie
'notar' => $notar, 'notar' => $notar,
'grundbuch' => $grundbuch, 'grundbuch' => $grundbuch,
'grunderwerbsteuer' => $grunderwerbsteuer, 'grunderwerbsteuer' => $grunderwerbsteuer,
'gesamt' => $gesamt 'gesamt' => $gesamt,
]; ];
} }
} }

View File

@@ -3,11 +3,11 @@
namespace App\Entity; namespace App\Entity;
use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Post; use ApiPlatform\Metadata\Post;
use ApiPlatform\Metadata\Put; use ApiPlatform\Metadata\Put;
use ApiPlatform\Metadata\Delete;
use App\Enum\UserRole; use App\Enum\UserRole;
use App\Repository\UserRepository; use App\Repository\UserRepository;
use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\ArrayCollection;
@@ -24,7 +24,7 @@ use Symfony\Component\Validator\Constraints as Assert;
new GetCollection(), new GetCollection(),
new Post(), new Post(),
new Put(), new Put(),
new Delete() new Delete(),
] ]
)] )]
class User implements UserInterface class User implements UserInterface
@@ -65,7 +65,7 @@ class User implements UserInterface
} }
/** /**
* Generiert einen eindeutigen API-Key * Generiert einen eindeutigen API-Key.
*/ */
private function generateApiKey(): string private function generateApiKey(): string
{ {
@@ -85,6 +85,7 @@ class User implements UserInterface
public function setName(string $name): self public function setName(string $name): self
{ {
$this->name = $name; $this->name = $name;
return $this; return $this;
} }
@@ -96,6 +97,7 @@ class User implements UserInterface
public function setEmail(string $email): self public function setEmail(string $email): self
{ {
$this->email = $email; $this->email = $email;
return $this; return $this;
} }
@@ -107,6 +109,7 @@ class User implements UserInterface
public function setRole(UserRole $role): self public function setRole(UserRole $role): self
{ {
$this->role = $role; $this->role = $role;
return $this; return $this;
} }
@@ -118,6 +121,7 @@ class User implements UserInterface
public function setCreatedAt(\DateTimeInterface $createdAt): self public function setCreatedAt(\DateTimeInterface $createdAt): self
{ {
$this->createdAt = $createdAt; $this->createdAt = $createdAt;
return $this; return $this;
} }
@@ -142,6 +146,7 @@ class User implements UserInterface
public function removeImmobilie(Immobilie $immobilie): self public function removeImmobilie(Immobilie $immobilie): self
{ {
$this->immobilien->removeElement($immobilie); $this->immobilien->removeElement($immobilie);
return $this; return $this;
} }
@@ -151,16 +156,17 @@ class User implements UserInterface
} }
/** /**
* Generiert einen neuen API-Key * Generiert einen neuen API-Key.
*/ */
public function regenerateApiKey(): self public function regenerateApiKey(): self
{ {
$this->apiKey = $this->generateApiKey(); $this->apiKey = $this->generateApiKey();
return $this; return $this;
} }
/** /**
* UserInterface Methods * UserInterface Methods.
*/ */
public function getUserIdentifier(): string public function getUserIdentifier(): string
{ {
@@ -171,11 +177,11 @@ class User implements UserInterface
{ {
$roles = ['ROLE_USER']; $roles = ['ROLE_USER'];
if ($this->role === UserRole::ADMIN) { if (UserRole::ADMIN === $this->role) {
$roles[] = 'ROLE_ADMIN'; $roles[] = 'ROLE_ADMIN';
} elseif ($this->role === UserRole::MODERATOR) { } elseif (UserRole::MODERATOR === $this->role) {
$roles[] = 'ROLE_MODERATOR'; $roles[] = 'ROLE_MODERATOR';
} elseif ($this->role === UserRole::TECHNICAL) { } elseif (UserRole::TECHNICAL === $this->role) {
$roles[] = 'ROLE_TECHNICAL'; $roles[] = 'ROLE_TECHNICAL';
} }

View File

@@ -45,7 +45,7 @@ enum Bundesland: string
/** /**
* Gibt die Grunderwerbsteuer in Prozent für das Bundesland zurück * Gibt die Grunderwerbsteuer in Prozent für das Bundesland zurück
* Stand: 2025 * Stand: 2025.
*/ */
public function getGrunderwerbsteuer(): float public function getGrunderwerbsteuer(): float
{ {

View File

@@ -18,7 +18,7 @@ class ImmobilieRepository extends ServiceEntityRepository
} }
/** /**
* Find available properties * Find available properties.
*/ */
public function findVerfuegbare(): array public function findVerfuegbare(): array
{ {
@@ -31,7 +31,7 @@ class ImmobilieRepository extends ServiceEntityRepository
} }
/** /**
* Find properties by type * Find properties by type.
*/ */
public function findByTyp(ImmobilienTyp $typ): array public function findByTyp(ImmobilienTyp $typ): array
{ {
@@ -44,7 +44,7 @@ class ImmobilieRepository extends ServiceEntityRepository
} }
/** /**
* Find properties within price range * Find properties within price range.
*/ */
public function findByPreisRange(float $minPreis, float $maxPreis): array public function findByPreisRange(float $minPreis, float $maxPreis): array
{ {
@@ -60,7 +60,7 @@ class ImmobilieRepository extends ServiceEntityRepository
} }
/** /**
* Find properties within area range * Find properties within area range.
*/ */
public function findByFlaecheRange(float $minFlaeche, float $maxFlaeche): array public function findByFlaecheRange(float $minFlaeche, float $maxFlaeche): array
{ {
@@ -76,7 +76,7 @@ class ImmobilieRepository extends ServiceEntityRepository
} }
/** /**
* Find properties with garage * Find properties with garage.
*/ */
public function findMitGarage(): array public function findMitGarage(): array
{ {
@@ -91,7 +91,7 @@ class ImmobilieRepository extends ServiceEntityRepository
} }
/** /**
* Find properties by minimum number of rooms * Find properties by minimum number of rooms.
*/ */
public function findByMinZimmer(int $minZimmer): array public function findByMinZimmer(int $minZimmer): array
{ {
@@ -106,7 +106,7 @@ class ImmobilieRepository extends ServiceEntityRepository
} }
/** /**
* Get average price per sqm by type * Get average price per sqm by type.
*/ */
public function getAveragePreisProQmByTyp(ImmobilienTyp $typ): float public function getAveragePreisProQmByTyp(ImmobilienTyp $typ): float
{ {
@@ -123,7 +123,7 @@ class ImmobilieRepository extends ServiceEntityRepository
} }
/** /**
* Search properties by address * Search properties by address.
*/ */
public function searchByAdresse(string $search): array public function searchByAdresse(string $search): array
{ {

View File

@@ -17,7 +17,7 @@ class UserRepository extends ServiceEntityRepository
} }
/** /**
* Find users by role * Find users by role.
*/ */
public function findByRole(string $role): array public function findByRole(string $role): array
{ {
@@ -30,7 +30,7 @@ class UserRepository extends ServiceEntityRepository
} }
/** /**
* Find user by email * Find user by email.
*/ */
public function findOneByEmail(string $email): ?User public function findOneByEmail(string $email): ?User
{ {

View File

@@ -56,7 +56,7 @@ class ApiKeyAuthenticator extends AbstractAuthenticator
public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response
{ {
return new JsonResponse([ return new JsonResponse([
'message' => strtr($exception->getMessageKey(), $exception->getMessageData()) 'message' => strtr($exception->getMessageKey(), $exception->getMessageData()),
], Response::HTTP_UNAUTHORIZED); ], Response::HTTP_UNAUTHORIZED);
} }
} }

View File

@@ -10,9 +10,9 @@ use Symfony\Component\Security\Core\Authorization\Voter\Voter;
class ImmobilieVoter extends Voter class ImmobilieVoter extends Voter
{ {
const VIEW = 'view'; public const VIEW = 'view';
const EDIT = 'edit'; public const EDIT = 'edit';
const DELETE = 'delete'; public const DELETE = 'delete';
protected function supports(string $attribute, mixed $subject): bool protected function supports(string $attribute, mixed $subject): bool
{ {
@@ -34,7 +34,7 @@ class ImmobilieVoter extends Voter
$immobilie = $subject; $immobilie = $subject;
// Admin hat uneingeschränkten Zugriff // Admin hat uneingeschränkten Zugriff
if ($user->getRole() === UserRole::ADMIN) { if (UserRole::ADMIN === $user->getRole()) {
return true; return true;
} }

View File

@@ -49,6 +49,18 @@
"./migrations/.gitignore" "./migrations/.gitignore"
] ]
}, },
"friendsofphp/php-cs-fixer": {
"version": "3.89",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "3.0",
"ref": "be2103eb4a20942e28a6dd87736669b757132435"
},
"files": [
".php-cs-fixer.dist.php"
]
},
"nelmio/cors-bundle": { "nelmio/cors-bundle": {
"version": "2.6", "version": "2.6",
"recipe": { "recipe": {
@@ -61,6 +73,21 @@
"./config/packages/nelmio_cors.yaml" "./config/packages/nelmio_cors.yaml"
] ]
}, },
"phpunit/phpunit": {
"version": "12.4",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "11.1",
"ref": "1117deb12541f35793eec9fff7494d7aa12283fc"
},
"files": [
".env.test",
"phpunit.dist.xml",
"tests/bootstrap.php",
"bin/phpunit"
]
},
"symfony/console": { "symfony/console": {
"version": "7.3", "version": "7.3",
"recipe": { "recipe": {

View File

@@ -0,0 +1,28 @@
<?php
namespace App\Tests\Api;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
class ApiDocumentationTest extends WebTestCase
{
public function testSwaggerUIAccessible(): void
{
$client = static::createClient();
// Test: Swagger UI ist öffentlich zugänglich
$client->request('GET', '/api/docs.html');
$this->assertResponseIsSuccessful();
}
public function testOpenAPIJsonLdAccessible(): void
{
$client = static::createClient();
// Test: OpenAPI JSON-LD ist öffentlich zugänglich
$client->request('GET', '/api/docs.jsonld');
$this->assertResponseIsSuccessful();
}
}

View File

@@ -0,0 +1,50 @@
<?php
namespace App\Tests\Api;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
class BundeslandApiTest extends WebTestCase
{
public function testGetBundeslaenderPublicAccess(): void
{
$client = static::createClient();
// Test: Bundesländer können ohne API-Key abgerufen werden
$client->request('GET', '/api/bundeslands');
$this->assertResponseIsSuccessful();
$this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8');
}
public function testGetSingleBundeslandPublicAccess(): void
{
$client = static::createClient();
// Test: Einzelnes Bundesland kann ohne API-Key abgerufen werden
$client->request('GET', '/api/bundeslands/1');
// Response kann 200 (OK) oder 404 (Not Found) sein, beides ist akzeptabel
$this->assertResponseStatusCodeSame(200);
}
public function testCreateBundeslandRequiresAuthentication(): void
{
$client = static::createClient();
// Test: Bundesland erstellen ohne API-Key sollte fehlschlagen
$client->request(
'POST',
'/api/bundeslands',
[],
[],
['CONTENT_TYPE' => 'application/json'],
json_encode([
'name' => 'Test Bundesland',
'grunderwerbsteuer' => 5.0,
])
);
$this->assertResponseStatusCodeSame(403); // Access Denied (no authentication on this firewall)
}
}

View File

@@ -0,0 +1,49 @@
<?php
namespace App\Tests\Api;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
class HeizungstypApiTest extends WebTestCase
{
public function testGetHeizungstypenPublicAccess(): void
{
$client = static::createClient();
// Test: Heizungstypen können ohne API-Key abgerufen werden
$client->request('GET', '/api/heizungstyps');
$this->assertResponseIsSuccessful();
$this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8');
}
public function testGetSingleHeizungstypPublicAccess(): void
{
$client = static::createClient();
// Test: Einzelner Heizungstyp kann ohne API-Key abgerufen werden
$client->request('GET', '/api/heizungstyps/1');
// Response kann 200 (OK) oder 404 (Not Found) sein
$this->assertResponseStatusCodeSame(200);
}
public function testCreateHeizungstypRequiresAuthentication(): void
{
$client = static::createClient();
// Test: Heizungstyp erstellen ohne API-Key sollte fehlschlagen
$client->request(
'POST',
'/api/heizungstyps',
[],
[],
['CONTENT_TYPE' => 'application/json'],
json_encode([
'name' => 'Test Heizung',
])
);
$this->assertResponseStatusCodeSame(401); // Unauthorized
}
}

View File

@@ -0,0 +1,65 @@
<?php
namespace App\Tests\Entity;
use App\Entity\Bundesland;
use PHPUnit\Framework\TestCase;
class BundeslandTest extends TestCase
{
public function testBundeslandCreation(): void
{
$bundesland = new Bundesland();
$bundesland->setName('Bayern');
$bundesland->setGrunderwerbsteuer(3.5);
$this->assertEquals('Bayern', $bundesland->getName());
$this->assertEquals(3.5, $bundesland->getGrunderwerbsteuer());
}
public function testGrunderwerbsteuerValues(): void
{
$testCases = [
['Baden-Württemberg', 5.0],
['Bayern', 3.5],
['Berlin', 6.0],
['Brandenburg', 6.5],
['Bremen', 5.0],
['Hamburg', 5.5],
['Hessen', 6.0],
['Mecklenburg-Vorpommern', 6.0],
['Niedersachsen', 5.0],
['Nordrhein-Westfalen', 6.5],
['Rheinland-Pfalz', 5.0],
['Saarland', 6.5],
['Sachsen', 5.5],
['Sachsen-Anhalt', 5.0],
['Schleswig-Holstein', 6.5],
['Thüringen', 5.0],
];
foreach ($testCases as [$name, $steuer]) {
$bundesland = new Bundesland();
$bundesland->setName($name);
$bundesland->setGrunderwerbsteuer($steuer);
$this->assertEquals($name, $bundesland->getName());
$this->assertEquals($steuer, $bundesland->getGrunderwerbsteuer());
}
}
public function testMinMaxGrunderwerbsteuer(): void
{
// Niedrigster Satz: Bayern mit 3.5%
$bayern = new Bundesland();
$bayern->setName('Bayern');
$bayern->setGrunderwerbsteuer(3.5);
$this->assertEquals(3.5, $bayern->getGrunderwerbsteuer());
// Höchster Satz: Brandenburg, NRW, Saarland, SH mit 6.5%
$nrw = new Bundesland();
$nrw->setName('Nordrhein-Westfalen');
$nrw->setGrunderwerbsteuer(6.5);
$this->assertEquals(6.5, $nrw->getGrunderwerbsteuer());
}
}

View File

@@ -0,0 +1,29 @@
<?php
namespace App\Tests\Entity;
use App\Entity\Heizungstyp;
use PHPUnit\Framework\TestCase;
class HeizungstypTest extends TestCase
{
public function testHeizungstypCreation(): void
{
$heizungstyp = new Heizungstyp();
$heizungstyp->setName('Wärmepumpe');
$this->assertEquals('Wärmepumpe', $heizungstyp->getName());
}
public function testCommonHeizungstypen(): void
{
$typen = ['Gasheizung', 'Wärmepumpe', 'Ölheizung', 'Fernwärme', 'Pelletheizung'];
foreach ($typen as $typName) {
$heizungstyp = new Heizungstyp();
$heizungstyp->setName($typName);
$this->assertEquals($typName, $heizungstyp->getName());
}
}
}

View File

@@ -0,0 +1,160 @@
<?php
namespace App\Tests\Entity;
use App\Entity\Bundesland;
use App\Entity\Immobilie;
use App\Entity\User;
use App\Enum\ImmobilienTyp;
use PHPUnit\Framework\TestCase;
class ImmobilieTest extends TestCase
{
private User $verwalter;
protected function setUp(): void
{
$this->verwalter = new User();
$this->verwalter->setName('Test Verwalter');
$this->verwalter->setEmail('verwalter@example.com');
}
public function testImmobilieCreation(): void
{
$immobilie = new Immobilie();
$immobilie->setVerwalter($this->verwalter);
$immobilie->setAdresse('Teststraße 123, 12345 Teststadt');
$immobilie->setWohnflaeche(100);
$immobilie->setNutzflaeche(20);
$immobilie->setZimmer(4);
$immobilie->setTyp(ImmobilienTyp::WOHNUNG);
$this->assertEquals('Teststraße 123, 12345 Teststadt', $immobilie->getAdresse());
$this->assertEquals(100, $immobilie->getWohnflaeche());
$this->assertEquals(20, $immobilie->getNutzflaeche());
$this->assertEquals(4, $immobilie->getZimmer());
$this->assertEquals(ImmobilienTyp::WOHNUNG, $immobilie->getTyp());
$this->assertEquals($this->verwalter, $immobilie->getVerwalter());
}
public function testGesamtflaecheCalculation(): void
{
$immobilie = new Immobilie();
$immobilie->setVerwalter($this->verwalter);
$immobilie->setAdresse('Test');
$immobilie->setWohnflaeche(85);
$immobilie->setNutzflaeche(15);
$immobilie->setZimmer(3);
$this->assertEquals(100, $immobilie->getGesamtflaeche());
}
public function testKaufnebenkostenWithoutBundesland(): void
{
$immobilie = new Immobilie();
$immobilie->setVerwalter($this->verwalter);
$immobilie->setAdresse('Test');
$immobilie->setWohnflaeche(100);
$immobilie->setNutzflaeche(0);
$immobilie->setZimmer(3);
$immobilie->setKaufpreis(300000);
$kosten = $immobilie->getKaufnebenkosten();
$this->assertEquals(0, $kosten['notar']);
$this->assertEquals(0, $kosten['grundbuch']);
$this->assertEquals(0, $kosten['grunderwerbsteuer']);
$this->assertEquals(0, $kosten['gesamt']);
}
public function testKaufnebenkostenWithBundesland(): void
{
$bundesland = new Bundesland();
$bundesland->setName('Bayern');
$bundesland->setGrunderwerbsteuer(3.5);
$immobilie = new Immobilie();
$immobilie->setVerwalter($this->verwalter);
$immobilie->setAdresse('Test');
$immobilie->setWohnflaeche(100);
$immobilie->setNutzflaeche(0);
$immobilie->setZimmer(3);
$immobilie->setKaufpreis(300000);
$immobilie->setBundesland($bundesland);
$kosten = $immobilie->getKaufnebenkosten();
// Notar: 1.5% von 300000 = 4500
$this->assertEquals(4500, $kosten['notar']);
// Grundbuch: 0.5% von 300000 = 1500
$this->assertEquals(1500, $kosten['grundbuch']);
// Grunderwerbsteuer: 3.5% von 300000 = 10500
$this->assertEqualsWithDelta(10500, $kosten['grunderwerbsteuer'], 0.01);
// Gesamt: 4500 + 1500 + 10500 = 16500
$this->assertEquals(16500, $kosten['gesamt']);
}
public function testKaufnebenkostenWithDifferentBundesland(): void
{
$bundesland = new Bundesland();
$bundesland->setName('Nordrhein-Westfalen');
$bundesland->setGrunderwerbsteuer(6.5);
$immobilie = new Immobilie();
$immobilie->setVerwalter($this->verwalter);
$immobilie->setAdresse('Test');
$immobilie->setWohnflaeche(100);
$immobilie->setNutzflaeche(0);
$immobilie->setZimmer(3);
$immobilie->setKaufpreis(400000);
$immobilie->setBundesland($bundesland);
$kosten = $immobilie->getKaufnebenkosten();
// Notar: 1.5% von 400000 = 6000
$this->assertEquals(6000, $kosten['notar']);
// Grundbuch: 0.5% von 400000 = 2000
$this->assertEquals(2000, $kosten['grundbuch']);
// Grunderwerbsteuer: 6.5% von 400000 = 26000
$this->assertEquals(26000, $kosten['grunderwerbsteuer']);
// Gesamt: 6000 + 2000 + 26000 = 34000
$this->assertEquals(34000, $kosten['gesamt']);
}
public function testDefaultValues(): void
{
$immobilie = new Immobilie();
$this->assertEquals(ImmobilienTyp::WOHNUNG, $immobilie->getTyp());
$this->assertEquals(0, $immobilie->getNutzflaeche());
$this->assertFalse($immobilie->getGarage());
$this->assertNotNull($immobilie->getCreatedAt());
$this->assertNotNull($immobilie->getUpdatedAt());
}
public function testOptionalFields(): void
{
$immobilie = new Immobilie();
$immobilie->setVerwalter($this->verwalter);
$immobilie->setAdresse('Test');
$immobilie->setWohnflaeche(100);
$immobilie->setNutzflaeche(0);
$immobilie->setZimmer(3);
$immobilie->setBaujahr(2020);
$immobilie->setEtage(3);
$immobilie->setAbschreibungszeit(50);
$immobilie->setBeschreibung('Schöne Wohnung');
$this->assertEquals(2020, $immobilie->getBaujahr());
$this->assertEquals(3, $immobilie->getEtage());
$this->assertEquals(50, $immobilie->getAbschreibungszeit());
$this->assertEquals('Schöne Wohnung', $immobilie->getBeschreibung());
}
}

92
tests/Entity/UserTest.php Normal file
View File

@@ -0,0 +1,92 @@
<?php
namespace App\Tests\Entity;
use App\Entity\User;
use App\Enum\UserRole;
use PHPUnit\Framework\TestCase;
class UserTest extends TestCase
{
public function testUserCreation(): void
{
$user = new User();
$user->setName('Max Mustermann');
$user->setEmail('max@example.com');
$user->setRole(UserRole::USER);
$this->assertEquals('Max Mustermann', $user->getName());
$this->assertEquals('max@example.com', $user->getEmail());
$this->assertEquals(UserRole::USER, $user->getRole());
$this->assertNotNull($user->getApiKey());
$this->assertNotNull($user->getCreatedAt());
}
public function testApiKeyGeneration(): void
{
$user = new User();
$user->setName('Test User');
$user->setEmail('test@example.com');
$apiKey = $user->getApiKey();
$this->assertNotEmpty($apiKey);
$this->assertEquals(64, strlen($apiKey)); // SHA256 hash length
}
public function testRegenerateApiKey(): void
{
$user = new User();
$user->setName('Test User');
$user->setEmail('test@example.com');
$originalKey = $user->getApiKey();
$user->regenerateApiKey();
$newKey = $user->getApiKey();
$this->assertNotEquals($originalKey, $newKey);
$this->assertEquals(64, strlen($newKey));
}
public function testUserRoles(): void
{
// Test USER role
$user = new User();
$user->setRole(UserRole::USER);
$this->assertContains('ROLE_USER', $user->getRoles());
// Test ADMIN role
$admin = new User();
$admin->setRole(UserRole::ADMIN);
$this->assertContains('ROLE_ADMIN', $admin->getRoles());
$this->assertContains('ROLE_USER', $admin->getRoles());
// Test TECHNICAL role
$technical = new User();
$technical->setRole(UserRole::TECHNICAL);
$this->assertContains('ROLE_TECHNICAL', $technical->getRoles());
$this->assertContains('ROLE_USER', $technical->getRoles());
// Test MODERATOR role
$moderator = new User();
$moderator->setRole(UserRole::MODERATOR);
$this->assertContains('ROLE_MODERATOR', $moderator->getRoles());
$this->assertContains('ROLE_USER', $moderator->getRoles());
}
public function testUserIdentifier(): void
{
$user = new User();
$user->setEmail('identifier@example.com');
$this->assertEquals('identifier@example.com', $user->getUserIdentifier());
}
public function testDefaultRole(): void
{
$user = new User();
// Default role should be USER
$this->assertEquals(UserRole::USER, $user->getRole());
}
}

13
tests/bootstrap.php Normal file
View File

@@ -0,0 +1,13 @@
<?php
use Symfony\Component\Dotenv\Dotenv;
require dirname(__DIR__).'/vendor/autoload.php';
if (method_exists(Dotenv::class, 'bootEnv')) {
(new Dotenv())->bootEnv(dirname(__DIR__).'/.env');
}
if ($_SERVER['APP_DEBUG']) {
umask(0000);
}