From f0d98a6e9f321e3e6892d8f07699afc595b927a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20Tegn=C3=A9r?= Date: Fri, 19 Jan 2024 21:48:40 +0100 Subject: [PATCH 1/5] Created tests for interim numbers. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Johannes Tegnér --- src/Personnummer.php | 1 + tests/InterimNumberTest.php | 108 ++++++++++++++++++++++++++++++++++++ tests/PersonnummerData.php | 26 +++++++++ 3 files changed, 135 insertions(+) create mode 100644 tests/InterimNumberTest.php create mode 100644 tests/PersonnummerData.php diff --git a/src/Personnummer.php b/src/Personnummer.php index 7d3d7e8..1f85ee0 100644 --- a/src/Personnummer.php +++ b/src/Personnummer.php @@ -257,6 +257,7 @@ private function parseOptions(array $options): array { $defaultOptions = [ 'allowCoordinationNumber' => true, + 'allowInterimNumber' => false, ]; if ($unknownKeys = array_diff_key($options, $defaultOptions)) { diff --git a/tests/InterimNumberTest.php b/tests/InterimNumberTest.php new file mode 100644 index 0000000..9da74db --- /dev/null +++ b/tests/InterimNumberTest.php @@ -0,0 +1,108 @@ + true, + ]; + + private static function init(): void + { + if (self::$interim === null) { + $data = json_decode(file_get_contents('https://raw.githubusercontent.com/personnummer/meta/master/testdata/interim.json'), true, 512, JSON_THROW_ON_ERROR); // phpcs:ignore + self::$interim = array_map( + static fn(array $p) => new PersonnummerData($p), + $data, + ); + } + } + + public static function validProvider(): array + { + self::init(); + return array_map( + static fn(PersonnummerData $p) => ['num' => $p], + array_filter(self::$interim, static fn($p) => $p->valid) + ); + } + public static function invalidProvider(): array + { + self::init(); + return array_map( + static fn(PersonnummerData $p) => ['num' => $p], + array_filter(self::$interim, static fn($p) => !$p->valid) + ); + } + + #[DataProvider('validProvider')] + public function testValidateInterim(PersonnummerData $num): void + { + self::assertTrue(Personnummer::valid($num->longFormat, $this->options)); + self::assertTrue(Personnummer::valid($num->separatedFormat, $this->options)); + } + + #[DataProvider('invalidProvider')] + public function testValidateInvalidInterim(PersonnummerData $num): void + { + self::assertFalse(Personnummer::valid($num->longFormat, $this->options)); + self::assertFalse(Personnummer::valid($num->separatedFormat, $this->options)); + } + + #[DataProvider('validProvider')] + public function testFormatLongInterim(PersonnummerData $num): void + { + $p = Personnummer::parse($num->longFormat, $this->options); + self::assertEquals($p->format(true), $num->longFormat); + self::assertEquals($p->format(false), $num->separatedFormat); + } + + #[DataProvider('validProvider')] + public function testFormatShortInterim(PersonnummerData $num): void + { + $p = Personnummer::parse($num->separatedFormat, $this->options); + self::assertEquals($p->format(true), $num->longFormat); + self::assertEquals($p->format(false), $num->separatedFormat); + } + + #[DataProvider('invalidProvider')] + public function testInvalidInterimThrows(PersonnummerData $num): void + { + $this->assertThrows( + PersonnummerException::class, + fn () => Personnummer::parse($num->longFormat, $this->options) + ); + $this->assertThrows( + PersonnummerException::class, + fn () => Personnummer::parse($num->separatedFormat, $this->options) + ); + } + + #[DataProvider('validProvider')] + public function testInterimThrowsIfNotActive(PersonnummerData $num): void + { + $this->assertThrows( + PersonnummerException::class, + fn () => Personnummer::parse($num->longFormat, [ + 'allowInterimNumber' => false, + ]) + ); + $this->assertThrows( + PersonnummerException::class, + fn () => Personnummer::parse($num->separatedFormat, [ + 'allowInterimNumber' => false, + ]) + ); + } +} diff --git a/tests/PersonnummerData.php b/tests/PersonnummerData.php new file mode 100644 index 0000000..a00b0f1 --- /dev/null +++ b/tests/PersonnummerData.php @@ -0,0 +1,26 @@ +longFormat = $p['long_format']; + $this->shortFormat = $p['short_format']; + $this->separatedFormat = $p['separated_format']; + $this->separatedLong = $p['separated_long']; + $this->valid = $p['valid']; + $this->type = $p['type']; + $this->isMale = $p['isMale']; + $this->isFemale = $p['isFemale']; + } + + public string $longFormat; + public string $shortFormat; + public string $separatedFormat; + public string $separatedLong; + public bool $valid; + public string $type; + public bool $isMale; + public bool $isFemale; +} From 16e90c6862699af621ea544351f8707ca0c2723a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20Tegn=C3=A9r?= Date: Sat, 20 Jan 2024 11:31:55 +0100 Subject: [PATCH 2/5] Added interim support to Personnummer parsing. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Johannes Tegnér --- src/Personnummer.php | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/src/Personnummer.php b/src/Personnummer.php index 1f85ee0..a2310e3 100644 --- a/src/Personnummer.php +++ b/src/Personnummer.php @@ -111,7 +111,7 @@ public static function valid(string $ssn, array $options = []): bool private static function getParts(string $ssn): array { // phpcs:ignore - $reg = '/^(?\'century\'\d{2}){0,1}(?\'year\'\d{2})(?\'month\'\d{2})(?\'day\'\d{2})(?\'sep\'[\+\-]?)(?\'num\'(?!000)\d{3})(?\'check\'\d)$/'; + $reg = '/^(?\'century\'\d{2}){0,1}(?\'year\'\d{2})(?\'month\'\d{2})(?\'day\'\d{2})(?\'sep\'[\+\-]?)(?\'num\'(?!000)\d{3}|[TRSUWXJKLMN]\d{2})(?\'check\'\d)$/'; preg_match($reg, $ssn, $match); if (empty($match)) { @@ -139,6 +139,7 @@ private static function getParts(string $ssn): array $parts['fullYear'] = $parts['century'] . $parts['year']; + $parts['original'] = $ssn; return $parts; } @@ -241,13 +242,29 @@ private function isValid(): bool { $parts = $this->parts; + // Correct interim if allowed. + $interimTest = '/(?![-+])\D/'; + $isInterim = preg_match($interimTest, $parts['original']) !== 0; + + if ($this->options['allowInterimNumber'] === false && $isInterim) { + throw new PersonnummerException(sprintf( + '%s contains non-integer characters and options are set to not allow interim numbers', + $parts['original'] + )); + } + + $num = $parts['num']; + if ($this->options['allowInterimNumber'] && $isInterim) { + $num = preg_replace($interimTest, '1', $num); + } + if ($this->options['allowCoordinationNumber'] && $this->isCoordinationNumber()) { $validDate = true; } else { $validDate = checkdate($parts['month'], $parts['day'], $parts['century'] . $parts['year']); } - $checkStr = $parts['year'] . $parts['month'] . $parts['day'] . $parts['num']; + $checkStr = $parts['year'] . $parts['month'] . $parts['day'] . $num; $validCheck = self::luhn($checkStr) === (int)$parts['check']; return $validDate && $validCheck; From afe031d9cc81414965d8c84c025087dae0ffde3e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20Tegn=C3=A9r?= Date: Sat, 20 Jan 2024 11:36:53 +0100 Subject: [PATCH 3/5] Added strlen sanitycheck in constructor. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Johannes Tegnér --- src/Personnummer.php | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/Personnummer.php b/src/Personnummer.php index a2310e3..1d28d32 100644 --- a/src/Personnummer.php +++ b/src/Personnummer.php @@ -182,6 +182,18 @@ public function __construct(string $ssn, array $options = []) $this->options = $this->parseOptions($options); $this->parts = self::getParts($ssn); + // Sanity checks. + $ssn = trim($ssn); + $len = strlen($ssn); + if ($len > 13 || $len < 10) { + throw new PersonnummerException( + sprintf( + 'Input string too %s', + $len < 10 ? 'short' : 'long' + ) + ); + } + if (!$this->isValid()) { throw new PersonnummerException(); } From 1d81bbca699fa86c19c2131128f230ffc39495d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20Tegn=C3=A9r?= Date: Sat, 20 Jan 2024 11:48:08 +0100 Subject: [PATCH 4/5] Updated readme with interim info. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Johannes Tegnér --- README.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index b66d1c4..2fe8714 100644 --- a/README.md +++ b/README.md @@ -17,12 +17,13 @@ composer require personnummer/personnummer #### Instance | Method | Arguments | Returns | -| ---------------------|:----------------|--------:| +|----------------------|:----------------|--------:| | format | bool longFormat | string | | getAge | none | int | | isMale | none | bool | | isFemale | none | bool | | isCoordinationNumber | none | bool | +| isInterimNumber | none | bool | | Property | Type | Description | | ---------|:-------|----------------------------:| @@ -39,9 +40,10 @@ composer require personnummer/personnummer When a personnummer is invalid a PersonnummerException is thrown. ## Options -| Option | Type | Default | Description | -| ------------------------|:-----|:--------|:---------------------------:| +| Option | Type | Default | Description | +|-------------------------|:-----|:--------|:---------------------------:| | allowCoordinationNumber | bool | true | Accept coordination numbers | +| allowInterimNumber | bool | false | Accept interim/T numbers | ## Examples From 7a69af3d59a353eb5b7679d3a08eac8afc0d34e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20Tegn=C3=A9r?= Date: Sat, 20 Jan 2024 11:52:39 +0100 Subject: [PATCH 5/5] Added isInterimNumber method and tests. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Johannes Tegnér --- src/Personnummer.php | 16 +++++++++++++--- src/PersonnummerInterface.php | 7 +++++++ tests/InterimNumberTest.php | 7 +++++++ tests/PersonnummerData.php | 2 +- tests/PersonnummerTest.php | 9 +++++++++ 5 files changed, 37 insertions(+), 4 deletions(-) diff --git a/src/Personnummer.php b/src/Personnummer.php index 1d28d32..015001b 100644 --- a/src/Personnummer.php +++ b/src/Personnummer.php @@ -25,6 +25,8 @@ final class Personnummer implements PersonnummerInterface private array $options; + private bool $isInterim; + /** * @inheritDoc */ @@ -88,6 +90,14 @@ public function isCoordinationNumber(): bool return checkdate((int)$parts['month'], $parts['day'] - 60, $parts['fullYear']); } + /** + * @inheritDoc + */ + public function isInterimNumber(): bool + { + return $this->isInterim; + } + /** * @inheritDoc */ @@ -256,9 +266,9 @@ private function isValid(): bool // Correct interim if allowed. $interimTest = '/(?![-+])\D/'; - $isInterim = preg_match($interimTest, $parts['original']) !== 0; + $this->isInterim = preg_match($interimTest, $parts['original']) !== 0; - if ($this->options['allowInterimNumber'] === false && $isInterim) { + if ($this->options['allowInterimNumber'] === false && $this->isInterim) { throw new PersonnummerException(sprintf( '%s contains non-integer characters and options are set to not allow interim numbers', $parts['original'] @@ -266,7 +276,7 @@ private function isValid(): bool } $num = $parts['num']; - if ($this->options['allowInterimNumber'] && $isInterim) { + if ($this->options['allowInterimNumber'] && $this->isInterim) { $num = preg_replace($interimTest, '1', $num); } diff --git a/src/PersonnummerInterface.php b/src/PersonnummerInterface.php index c41a3d6..cc87151 100644 --- a/src/PersonnummerInterface.php +++ b/src/PersonnummerInterface.php @@ -72,6 +72,13 @@ public function isMale(): bool; */ public function isCoordinationNumber(): bool; + /** + * Check if the Swedish social security number is an interim number. + * + * @return bool + */ + public function isInterimNumber(): bool; + public function __construct(string $ssn, array $options = []); /** diff --git a/tests/InterimNumberTest.php b/tests/InterimNumberTest.php index 9da74db..b1489f5 100644 --- a/tests/InterimNumberTest.php +++ b/tests/InterimNumberTest.php @@ -60,6 +60,13 @@ public function testValidateInvalidInterim(PersonnummerData $num): void self::assertFalse(Personnummer::valid($num->separatedFormat, $this->options)); } + #[DataProvider('validProvider')] + public function testIsInterim(PersonnummerData $num): void + { + self::assertTrue(Personnummer::parse($num->longFormat, $this->options)->isInterimNumber()); + self::assertTrue(Personnummer::parse($num->separatedFormat, $this->options)->isInterimNumber()); + } + #[DataProvider('validProvider')] public function testFormatLongInterim(PersonnummerData $num): void { diff --git a/tests/PersonnummerData.php b/tests/PersonnummerData.php index a00b0f1..5f3f79e 100644 --- a/tests/PersonnummerData.php +++ b/tests/PersonnummerData.php @@ -1,4 +1,5 @@ isMale = $p['isMale']; $this->isFemale = $p['isFemale']; } - public string $longFormat; public string $shortFormat; public string $separatedFormat; diff --git a/tests/PersonnummerTest.php b/tests/PersonnummerTest.php index 29fcba3..a9824e8 100644 --- a/tests/PersonnummerTest.php +++ b/tests/PersonnummerTest.php @@ -227,4 +227,13 @@ public function testMissingProperties(): void }, E_USER_NOTICE); $this->assertFalse(isset(Personnummer::parse('121212-1212')->missingProperty)); } + + public function testIsNotInterim(): void + { + foreach (self::$testdataList as $testdata) { + if ($testdata['valid']) { + $this->assertFalse(Personnummer::parse($testdata['separated_format'])->isInterimNumber()); + } + } + } }