From 797ea7348effb7c5ca64938d0f11c2c3e98cfc9e Mon Sep 17 00:00:00 2001 From: jalel Date: Mon, 15 Dec 2025 11:05:04 +0000 Subject: [PATCH 1/6] Add phpunit.xml --- phpunit.xml | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 phpunit.xml diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..d703241 --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,35 @@ + + + + + tests/Unit + + + tests/Feature + + + + + app + + + + + + + + + + + + + + + + + + From 3c304d8b72e8810e84aa666eb052589532ae75aa Mon Sep 17 00:00:00 2001 From: jalel Date: Mon, 15 Dec 2025 11:06:41 +0000 Subject: [PATCH 2/6] Add tests/TestCase.php --- tests/TestCase.php | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 tests/TestCase.php diff --git a/tests/TestCase.php b/tests/TestCase.php new file mode 100644 index 0000000..fe1ffc2 --- /dev/null +++ b/tests/TestCase.php @@ -0,0 +1,10 @@ + Date: Mon, 15 Dec 2025 11:07:19 +0000 Subject: [PATCH 3/6] Add missing test files --- tests/Feature/Api/Base64ApiTest.php | 116 ++++++ tests/Feature/Api/CsvApiTest.php | 88 +++++ tests/Feature/Api/MarkdownApiTest.php | 66 ++++ tests/Feature/Api/SqlApiTest.php | 81 +++++ tests/Feature/Api/YamlApiTest.php | 79 +++++ tests/Unit/Services/Base64ServiceTest.php | 137 ++++++++ .../Unit/Services/CsvConverterServiceTest.php | 114 ++++++ tests/Unit/Services/CsvEdgeCasesTest.php | 330 ++++++++++++++++++ .../Services/MarkdownConverterServiceTest.php | 123 +++++++ .../Unit/Services/SqlFormatterServiceTest.php | 103 ++++++ .../Services/YamlConverterServiceTest.php | 95 +++++ 11 files changed, 1332 insertions(+) create mode 100644 tests/Feature/Api/Base64ApiTest.php create mode 100644 tests/Feature/Api/CsvApiTest.php create mode 100644 tests/Feature/Api/MarkdownApiTest.php create mode 100644 tests/Feature/Api/SqlApiTest.php create mode 100644 tests/Feature/Api/YamlApiTest.php create mode 100644 tests/Unit/Services/Base64ServiceTest.php create mode 100644 tests/Unit/Services/CsvConverterServiceTest.php create mode 100644 tests/Unit/Services/CsvEdgeCasesTest.php create mode 100644 tests/Unit/Services/MarkdownConverterServiceTest.php create mode 100644 tests/Unit/Services/SqlFormatterServiceTest.php create mode 100644 tests/Unit/Services/YamlConverterServiceTest.php diff --git a/tests/Feature/Api/Base64ApiTest.php b/tests/Feature/Api/Base64ApiTest.php new file mode 100644 index 0000000..7ff460c --- /dev/null +++ b/tests/Feature/Api/Base64ApiTest.php @@ -0,0 +1,116 @@ +postJson('/api/v1/base64/encode', [ + 'input' => 'Hello World', + ]); + + $response->assertStatus(200) + ->assertJson([ + 'success' => true, + 'result' => 'SGVsbG8gV29ybGQ=', + ]); + } + + public function test_decode_text(): void + { + $response = $this->postJson('/api/v1/base64/decode', [ + 'input' => 'SGVsbG8gV29ybGQ=', + ]); + + $response->assertStatus(200) + ->assertJson([ + 'success' => true, + 'result' => 'Hello World', + 'is_binary' => false, + ]); + } + + public function test_decode_invalid_base64(): void + { + $response = $this->postJson('/api/v1/base64/decode', [ + 'input' => '!!!invalid!!!', + ]); + + $response->assertStatus(422) + ->assertJson(['success' => false]); + } + + public function test_encode_unicode(): void + { + $response = $this->postJson('/api/v1/base64/encode', [ + 'input' => 'こんにちは', + ]); + + $response->assertStatus(200) + ->assertJson([ + 'success' => true, + 'result' => '44GT44KT44Gr44Gh44Gv', + ]); + } + + public function test_validation_requires_input_for_encode(): void + { + $response = $this->postJson('/api/v1/base64/encode', []); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['input']); + } + + public function test_validation_requires_input_for_decode(): void + { + $response = $this->postJson('/api/v1/base64/decode', []); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['input']); + } + + public function test_encode_file(): void + { + $file = UploadedFile::fake()->create('test.txt', 1, 'text/plain'); + file_put_contents($file->getRealPath(), 'Hello World'); + + $response = $this->post('/api/v1/base64/encode-file', [ + 'file' => $file, + ], [ + 'Accept' => 'application/json', + ]); + + $response->assertStatus(200) + ->assertJson(['success' => true]); + + $this->assertStringStartsWith('data:text/plain;base64,', $response->json('result')); + } + + public function test_encode_file_validates_size(): void + { + $file = UploadedFile::fake()->create('large.txt', 6000); // 6MB, over 5MB limit + + $response = $this->post('/api/v1/base64/encode-file', [ + 'file' => $file, + ], [ + 'Accept' => 'application/json', + ]); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['file']); + } + + public function test_encode_file_requires_file(): void + { + $response = $this->post('/api/v1/base64/encode-file', [], [ + 'Accept' => 'application/json', + ]); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['file']); + } +} diff --git a/tests/Feature/Api/CsvApiTest.php b/tests/Feature/Api/CsvApiTest.php new file mode 100644 index 0000000..82e297a --- /dev/null +++ b/tests/Feature/Api/CsvApiTest.php @@ -0,0 +1,88 @@ +postJson('/api/v1/csv/convert', [ + 'csv' => "name,age\nJohn,30\nJane,25", + 'format' => 'json', + 'has_headers' => true, + ]); + + $response->assertStatus(200) + ->assertJson(['success' => true]); + + $result = json_decode($response->json('result'), true); + $this->assertCount(2, $result); + $this->assertEquals('John', $result[0]['name']); + } + + public function test_convert_csv_to_sql(): void + { + $response = $this->postJson('/api/v1/csv/convert', [ + 'csv' => "name,age\nJohn,30", + 'format' => 'sql', + 'table_name' => 'users', + 'has_headers' => true, + ]); + + $response->assertStatus(200) + ->assertJson(['success' => true]); + + $this->assertStringContainsString('INSERT INTO `users`', $response->json('result')); + } + + public function test_convert_csv_to_php(): void + { + $response = $this->postJson('/api/v1/csv/convert', [ + 'csv' => "name,age\nJohn,30", + 'format' => 'php', + 'has_headers' => true, + ]); + + $response->assertStatus(200) + ->assertJson(['success' => true]); + + $this->assertStringContainsString("'name' => 'John'", $response->json('result')); + } + + public function test_validation_requires_csv(): void + { + $response = $this->postJson('/api/v1/csv/convert', [ + 'format' => 'json', + ]); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['csv']); + } + + public function test_validation_requires_valid_format(): void + { + $response = $this->postJson('/api/v1/csv/convert', [ + 'csv' => 'test', + 'format' => 'invalid', + ]); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['format']); + } + + public function test_custom_delimiter(): void + { + $response = $this->postJson('/api/v1/csv/convert', [ + 'csv' => "name;age\nJohn;30", + 'format' => 'json', + 'delimiter' => ';', + 'has_headers' => true, + ]); + + $response->assertStatus(200); + $result = json_decode($response->json('result'), true); + $this->assertEquals('John', $result[0]['name']); + } +} diff --git a/tests/Feature/Api/MarkdownApiTest.php b/tests/Feature/Api/MarkdownApiTest.php new file mode 100644 index 0000000..88f829a --- /dev/null +++ b/tests/Feature/Api/MarkdownApiTest.php @@ -0,0 +1,66 @@ +postJson('/api/v1/markdown/convert', [ + 'markdown' => '# Hello World', + ]); + + $response->assertStatus(200) + ->assertJson(['success' => true]); + + $this->assertStringContainsString('

Hello World

', $response->json('result')); + } + + public function test_convert_with_formatting(): void + { + $response = $this->postJson('/api/v1/markdown/convert', [ + 'markdown' => 'This is **bold** and *italic*', + ]); + + $response->assertStatus(200); + $this->assertStringContainsString('bold', $response->json('result')); + $this->assertStringContainsString('italic', $response->json('result')); + } + + public function test_convert_full_page(): void + { + $response = $this->postJson('/api/v1/markdown/convert', [ + 'markdown' => '# Test', + 'full_page' => true, + 'title' => 'My Document', + ]); + + $response->assertStatus(200); + $result = $response->json('result'); + + $this->assertStringContainsString('', $result); + $this->assertStringContainsString('My Document', $result); + } + + public function test_validation_requires_markdown(): void + { + $response = $this->postJson('/api/v1/markdown/convert', []); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['markdown']); + } + + public function test_code_blocks_converted(): void + { + $response = $this->postJson('/api/v1/markdown/convert', [ + 'markdown' => "```php\necho 'hello';\n```", + ]); + + $response->assertStatus(200); + $this->assertStringContainsString('
', $response->json('result'));
+        // Code block has language class
+        $this->assertMatchesRegularExpression('/]*>/', $response->json('result'));
+    }
+}
diff --git a/tests/Feature/Api/SqlApiTest.php b/tests/Feature/Api/SqlApiTest.php
new file mode 100644
index 0000000..f5e4d70
--- /dev/null
+++ b/tests/Feature/Api/SqlApiTest.php
@@ -0,0 +1,81 @@
+postJson('/api/v1/sql/format', [
+            'sql' => 'SELECT id, name FROM users WHERE active = 1',
+            'mode' => 'format',
+        ]);
+
+        $response->assertStatus(200)
+            ->assertJson(['success' => true]);
+
+        $result = $response->json('result');
+        $this->assertStringContainsString("SELECT", $result);
+        $this->assertGreaterThan(1, substr_count($result, "\n"));
+    }
+
+    public function test_compress_sql(): void
+    {
+        $response = $this->postJson('/api/v1/sql/format', [
+            'sql' => "SELECT\n  id,\n  name\nFROM\n  users",
+            'mode' => 'compress',
+        ]);
+
+        $response->assertStatus(200)
+            ->assertJson(['success' => true]);
+
+        $result = $response->json('result');
+        $this->assertEquals(0, substr_count($result, "\n"));
+    }
+
+    public function test_highlight_sql(): void
+    {
+        $response = $this->postJson('/api/v1/sql/format', [
+            'sql' => 'SELECT id FROM users',
+            'mode' => 'highlight',
+        ]);
+
+        $response->assertStatus(200)
+            ->assertJson(['success' => true]);
+
+        // Highlighted should contain HTML
+        $this->assertStringContainsString('<', $response->json('result'));
+    }
+
+    public function test_default_mode_is_format(): void
+    {
+        $response = $this->postJson('/api/v1/sql/format', [
+            'sql' => 'SELECT id FROM users',
+        ]);
+
+        $response->assertStatus(200);
+        // Should be formatted (has newlines)
+        $this->assertGreaterThan(0, substr_count($response->json('result'), "\n"));
+    }
+
+    public function test_validation_requires_sql(): void
+    {
+        $response = $this->postJson('/api/v1/sql/format', []);
+
+        $response->assertStatus(422)
+            ->assertJsonValidationErrors(['sql']);
+    }
+
+    public function test_validation_requires_valid_mode(): void
+    {
+        $response = $this->postJson('/api/v1/sql/format', [
+            'sql' => 'SELECT 1',
+            'mode' => 'invalid',
+        ]);
+
+        $response->assertStatus(422)
+            ->assertJsonValidationErrors(['mode']);
+    }
+}
diff --git a/tests/Feature/Api/YamlApiTest.php b/tests/Feature/Api/YamlApiTest.php
new file mode 100644
index 0000000..8dc7c22
--- /dev/null
+++ b/tests/Feature/Api/YamlApiTest.php
@@ -0,0 +1,79 @@
+postJson('/api/v1/yaml/convert', [
+            'input' => "name: John\nage: 30",
+            'direction' => 'yaml-to-json',
+        ]);
+
+        $response->assertStatus(200)
+            ->assertJson(['success' => true]);
+
+        $result = json_decode($response->json('result'), true);
+        $this->assertEquals('John', $result['name']);
+        $this->assertEquals(30, $result['age']);
+    }
+
+    public function test_json_to_yaml(): void
+    {
+        $response = $this->postJson('/api/v1/yaml/convert', [
+            'input' => '{"name": "John", "age": 30}',
+            'direction' => 'json-to-yaml',
+        ]);
+
+        $response->assertStatus(200)
+            ->assertJson(['success' => true]);
+
+        $this->assertStringContainsString('name: John', $response->json('result'));
+    }
+
+    public function test_invalid_yaml_returns_error(): void
+    {
+        $response = $this->postJson('/api/v1/yaml/convert', [
+            'input' => "invalid: yaml: syntax:",
+            'direction' => 'yaml-to-json',
+        ]);
+
+        $response->assertStatus(422)
+            ->assertJson(['success' => false]);
+    }
+
+    public function test_invalid_json_returns_error(): void
+    {
+        $response = $this->postJson('/api/v1/yaml/convert', [
+            'input' => '{invalid}',
+            'direction' => 'json-to-yaml',
+        ]);
+
+        $response->assertStatus(422)
+            ->assertJson(['success' => false]);
+    }
+
+    public function test_validation_requires_input(): void
+    {
+        $response = $this->postJson('/api/v1/yaml/convert', [
+            'direction' => 'yaml-to-json',
+        ]);
+
+        $response->assertStatus(422)
+            ->assertJsonValidationErrors(['input']);
+    }
+
+    public function test_validation_requires_valid_direction(): void
+    {
+        $response = $this->postJson('/api/v1/yaml/convert', [
+            'input' => 'test',
+            'direction' => 'invalid',
+        ]);
+
+        $response->assertStatus(422)
+            ->assertJsonValidationErrors(['direction']);
+    }
+}
diff --git a/tests/Unit/Services/Base64ServiceTest.php b/tests/Unit/Services/Base64ServiceTest.php
new file mode 100644
index 0000000..2497254
--- /dev/null
+++ b/tests/Unit/Services/Base64ServiceTest.php
@@ -0,0 +1,137 @@
+service = new Base64Service();
+    }
+
+    public function test_encode_simple_string(): void
+    {
+        $result = $this->service->encode('Hello World');
+        $this->assertEquals('SGVsbG8gV29ybGQ=', $result);
+    }
+
+    public function test_encode_empty_string(): void
+    {
+        $result = $this->service->encode('');
+        $this->assertEquals('', $result);
+    }
+
+    public function test_encode_unicode(): void
+    {
+        $result = $this->service->encode('こんにちは');
+        $this->assertEquals('44GT44KT44Gr44Gh44Gv', $result);
+    }
+
+    public function test_encode_special_characters(): void
+    {
+        $result = $this->service->encode('Hello & ');
+        $decoded = base64_decode($result);
+        $this->assertEquals('Hello & ', $decoded);
+    }
+
+    public function test_decode_valid_base64(): void
+    {
+        $result = $this->service->decode('SGVsbG8gV29ybGQ=');
+
+        $this->assertTrue($result['success']);
+        $this->assertEquals('Hello World', $result['result']);
+        $this->assertFalse($result['is_binary']);
+    }
+
+    public function test_decode_invalid_base64(): void
+    {
+        $result = $this->service->decode('!!!invalid!!!');
+
+        $this->assertFalse($result['success']);
+        $this->assertStringContainsString('Invalid', $result['error']);
+    }
+
+    public function test_decode_detects_binary_content(): void
+    {
+        // Create base64 of binary data (null bytes)
+        $binary = base64_encode("\x00\x01\x02\x03");
+        $result = $this->service->decode($binary);
+
+        $this->assertTrue($result['success']);
+        $this->assertTrue($result['is_binary']);
+    }
+
+    public function test_decode_unicode(): void
+    {
+        $result = $this->service->decode('44GT44KT44Gr44Gh44Gv');
+
+        $this->assertTrue($result['success']);
+        $this->assertEquals('こんにちは', $result['result']);
+        $this->assertFalse($result['is_binary']);
+    }
+
+    public function test_encode_file_creates_data_url(): void
+    {
+        $content = 'Hello World';
+        $mimeType = 'text/plain';
+
+        $result = $this->service->encodeFile($content, $mimeType);
+
+        $this->assertStringStartsWith('data:text/plain;base64,', $result);
+        $this->assertStringContainsString('SGVsbG8gV29ybGQ=', $result);
+    }
+
+    public function test_encode_file_with_image_mime(): void
+    {
+        $content = 'fake image data';
+        $mimeType = 'image/png';
+
+        $result = $this->service->encodeFile($content, $mimeType);
+
+        $this->assertStringStartsWith('data:image/png;base64,', $result);
+    }
+
+    public function test_decode_data_url_valid(): void
+    {
+        $dataUrl = 'data:text/plain;base64,SGVsbG8gV29ybGQ=';
+        $result = $this->service->decodeDataUrl($dataUrl);
+
+        $this->assertTrue($result['success']);
+        $this->assertEquals('text/plain', $result['mime_type']);
+        $this->assertEquals('Hello World', $result['content']);
+        $this->assertEquals(11, $result['size']);
+    }
+
+    public function test_decode_data_url_invalid_format(): void
+    {
+        $result = $this->service->decodeDataUrl('not a data url');
+
+        $this->assertFalse($result['success']);
+        $this->assertStringContainsString('Invalid data URL', $result['error']);
+    }
+
+    public function test_decode_data_url_invalid_base64(): void
+    {
+        $dataUrl = 'data:text/plain;base64,!!!invalid!!!';
+        $result = $this->service->decodeDataUrl($dataUrl);
+
+        $this->assertFalse($result['success']);
+        $this->assertStringContainsString('Invalid Base64', $result['error']);
+    }
+
+    public function test_roundtrip_encode_decode(): void
+    {
+        $original = 'Test string with special chars: @#$%^&*()';
+        $encoded = $this->service->encode($original);
+        $decoded = $this->service->decode($encoded);
+
+        $this->assertTrue($decoded['success']);
+        $this->assertEquals($original, $decoded['result']);
+    }
+}
diff --git a/tests/Unit/Services/CsvConverterServiceTest.php b/tests/Unit/Services/CsvConverterServiceTest.php
new file mode 100644
index 0000000..e8700bd
--- /dev/null
+++ b/tests/Unit/Services/CsvConverterServiceTest.php
@@ -0,0 +1,114 @@
+service = new CsvConverterService();
+    }
+
+    public function test_parse_simple_csv(): void
+    {
+        $csv = "name,age\nJohn,30\nJane,25";
+        $result = $this->service->parse($csv);
+
+        $this->assertCount(3, $result);
+        $this->assertEquals(['name', 'age'], $result[0]);
+        $this->assertEquals(['John', '30'], $result[1]);
+        $this->assertEquals(['Jane', '25'], $result[2]);
+    }
+
+    public function test_parse_with_semicolon_delimiter(): void
+    {
+        $csv = "name;age\nJohn;30";
+        $result = $this->service->parse($csv, ';');
+
+        $this->assertCount(2, $result);
+        $this->assertEquals(['name', 'age'], $result[0]);
+        $this->assertEquals(['John', '30'], $result[1]);
+    }
+
+    public function test_to_json_with_headers(): void
+    {
+        $csv = "name,age\nJohn,30\nJane,25";
+        $result = $this->service->toJson($csv, ',', true);
+        $decoded = json_decode($result, true);
+
+        $this->assertCount(2, $decoded);
+        $this->assertEquals('John', $decoded[0]['name']);
+        $this->assertEquals('30', $decoded[0]['age']);
+        $this->assertEquals('Jane', $decoded[1]['name']);
+    }
+
+    public function test_to_json_without_headers(): void
+    {
+        $csv = "John,30\nJane,25";
+        $result = $this->service->toJson($csv, ',', false);
+        $decoded = json_decode($result, true);
+
+        $this->assertCount(2, $decoded);
+        $this->assertEquals(['John', '30'], $decoded[0]);
+    }
+
+    public function test_to_json_empty_csv(): void
+    {
+        $result = $this->service->toJson('', ',', true);
+        $this->assertEquals('[]', $result);
+    }
+
+    public function test_to_sql_with_headers(): void
+    {
+        $csv = "name,age\nJohn,30";
+        $result = $this->service->toSql($csv, 'users', ',', true);
+
+        $this->assertStringContainsString('INSERT INTO `users`', $result);
+        $this->assertStringContainsString('`name`', $result);
+        $this->assertStringContainsString('`age`', $result);
+        $this->assertStringContainsString("'John'", $result);
+        $this->assertStringContainsString("'30'", $result);
+    }
+
+    public function test_to_sql_escapes_quotes(): void
+    {
+        $csv = "name\nO'Brien";
+        $result = $this->service->toSql($csv, 'users', ',', true);
+
+        $this->assertStringContainsString("O\\'Brien", $result);
+    }
+
+    public function test_to_sql_handles_null_values(): void
+    {
+        $csv = "name,age\nJohn,";
+        $result = $this->service->toSql($csv, 'users', ',', true);
+
+        $this->assertStringContainsString('NULL', $result);
+    }
+
+    public function test_to_php_array_with_headers(): void
+    {
+        $csv = "name,age\nJohn,30";
+        $result = $this->service->toPhpArray($csv, ',', true);
+
+        $this->assertStringContainsString("'name' => 'John'", $result);
+        // Numeric values are output as numbers, not strings
+        $this->assertStringContainsString("'age' => 30", $result);
+    }
+
+    public function test_to_php_array_without_headers(): void
+    {
+        $csv = "John,30";
+        $result = $this->service->toPhpArray($csv, ',', false);
+
+        $this->assertStringContainsString("'John'", $result);
+        // Numeric values are output as numbers
+        $this->assertStringContainsString("30", $result);
+    }
+}
diff --git a/tests/Unit/Services/CsvEdgeCasesTest.php b/tests/Unit/Services/CsvEdgeCasesTest.php
new file mode 100644
index 0000000..46f13f0
--- /dev/null
+++ b/tests/Unit/Services/CsvEdgeCasesTest.php
@@ -0,0 +1,330 @@
+service = new CsvConverterService();
+    }
+
+    // ==================== Quoted Fields ====================
+
+    public function test_quoted_field_containing_comma(): void
+    {
+        $csv = "name,address\nJohn,\"123 Main St, Apt 4\"";
+        $result = $this->service->parse($csv);
+
+        $this->assertCount(2, $result);
+        $this->assertEquals('123 Main St, Apt 4', $result[1][1]);
+    }
+
+    public function test_quoted_field_containing_newline(): void
+    {
+        // Note: This tests if the parser handles embedded newlines in quoted fields
+        $csv = "name,note\nJohn,\"Line 1\nLine 2\"";
+        $result = $this->service->parse($csv);
+
+        // The basic str_getcsv splits on newlines first, so this tests current behavior
+        $this->assertGreaterThanOrEqual(2, count($result));
+    }
+
+    public function test_double_quotes_inside_quoted_field(): void
+    {
+        $csv = "name,quote\nJohn,\"He said \"\"Hello\"\"\"";
+        $result = $this->service->parse($csv);
+
+        $this->assertCount(2, $result);
+        // CSV standard: doubled quotes become single quotes
+        $this->assertStringContainsString('Hello', $result[1][1]);
+    }
+
+    public function test_empty_quoted_field(): void
+    {
+        $csv = "name,value\nJohn,\"\"";
+        $result = $this->service->parse($csv);
+
+        $this->assertCount(2, $result);
+        $this->assertEquals('', $result[1][1]);
+    }
+
+    // ==================== Unicode Characters ====================
+
+    public function test_unicode_emoji(): void
+    {
+        $csv = "name,mood\nJohn,😀";
+        $json = $this->service->toJson($csv, ',', true);
+        $decoded = json_decode($json, true);
+
+        $this->assertEquals('😀', $decoded[0]['mood']);
+    }
+
+    public function test_unicode_cjk_characters(): void
+    {
+        $csv = "name,greeting\n田中,こんにちは";
+        $json = $this->service->toJson($csv, ',', true);
+        $decoded = json_decode($json, true);
+
+        $this->assertEquals('田中', $decoded[0]['name']);
+        $this->assertEquals('こんにちは', $decoded[0]['greeting']);
+    }
+
+    public function test_unicode_arabic(): void
+    {
+        $csv = "name,greeting\nأحمد,مرحبا";
+        $json = $this->service->toJson($csv, ',', true);
+        $decoded = json_decode($json, true);
+
+        $this->assertEquals('أحمد', $decoded[0]['name']);
+        $this->assertEquals('مرحبا', $decoded[0]['greeting']);
+    }
+
+    public function test_unicode_in_sql_output(): void
+    {
+        $csv = "name\n田中";
+        $sql = $this->service->toSql($csv, 'users', ',', true);
+
+        $this->assertStringContainsString('田中', $sql);
+    }
+
+    // ==================== Delimiters ====================
+
+    public function test_tab_delimiter(): void
+    {
+        $csv = "name\tage\nJohn\t30";
+        $result = $this->service->parse($csv, "\t");
+
+        $this->assertCount(2, $result);
+        $this->assertEquals(['name', 'age'], $result[0]);
+        $this->assertEquals(['John', '30'], $result[1]);
+    }
+
+    public function test_pipe_delimiter(): void
+    {
+        $csv = "name|age\nJohn|30";
+        $result = $this->service->parse($csv, '|');
+
+        $this->assertCount(2, $result);
+        $this->assertEquals('John', $result[1][0]);
+    }
+
+    public function test_delimiter_in_quoted_field(): void
+    {
+        $csv = "name,value\nJohn,\"a,b,c\"";
+        $result = $this->service->parse($csv);
+
+        $this->assertEquals('a,b,c', $result[1][1]);
+    }
+
+    // ==================== Edge Cases with Empty/Whitespace ====================
+
+    public function test_empty_csv(): void
+    {
+        $result = $this->service->parse('');
+        $this->assertEmpty($result);
+    }
+
+    public function test_whitespace_only_csv(): void
+    {
+        $result = $this->service->parse("   \n   \n   ");
+        $this->assertEmpty($result);
+    }
+
+    public function test_single_row_no_data(): void
+    {
+        $csv = "name,age";
+        $json = $this->service->toJson($csv, ',', true);
+        $decoded = json_decode($json, true);
+
+        // With only headers and hasHeaders=true, but only 1 row,
+        // it falls through to non-header mode and returns the row as data
+        $this->assertCount(1, $decoded);
+        $this->assertEquals(['name', 'age'], $decoded[0]);
+    }
+
+    public function test_trailing_newlines(): void
+    {
+        $csv = "name,age\nJohn,30\n\n\n";
+        $result = $this->service->parse($csv);
+
+        $this->assertCount(2, $result);
+    }
+
+    public function test_leading_whitespace_in_values(): void
+    {
+        $csv = "name,age\n  John  ,  30  ";
+        $result = $this->service->parse($csv);
+
+        // Values should preserve whitespace (trimming is user's choice)
+        $this->assertCount(2, $result);
+    }
+
+    // ==================== Inconsistent Data ====================
+
+    public function test_inconsistent_column_count_more_columns(): void
+    {
+        $csv = "name,age\nJohn,30,extra";
+        $result = $this->service->parse($csv);
+
+        $this->assertCount(2, $result);
+        $this->assertCount(3, $result[1]); // Extra column preserved
+    }
+
+    public function test_inconsistent_column_count_fewer_columns(): void
+    {
+        $csv = "name,age,city\nJohn,30";
+        $json = $this->service->toJson($csv, ',', true);
+        $decoded = json_decode($json, true);
+
+        $this->assertCount(1, $decoded);
+        $this->assertEquals('John', $decoded[0]['name']);
+        $this->assertEquals('30', $decoded[0]['age']);
+        $this->assertNull($decoded[0]['city']);
+    }
+
+    public function test_trailing_comma(): void
+    {
+        $csv = "name,age,\nJohn,30,";
+        $result = $this->service->parse($csv);
+
+        $this->assertCount(2, $result);
+        // Trailing comma creates empty field
+        $this->assertCount(3, $result[0]);
+    }
+
+    // ==================== Large Data ====================
+
+    public function test_very_long_field(): void
+    {
+        $longValue = str_repeat('a', 10000);
+        $csv = "name,data\nJohn,{$longValue}";
+        $result = $this->service->parse($csv);
+
+        $this->assertEquals($longValue, $result[1][1]);
+    }
+
+    public function test_many_columns(): void
+    {
+        $headers = implode(',', range(1, 100));
+        $values = implode(',', array_fill(0, 100, 'x'));
+        $csv = "{$headers}\n{$values}";
+
+        $result = $this->service->parse($csv);
+
+        $this->assertCount(100, $result[0]);
+        $this->assertCount(100, $result[1]);
+    }
+
+    public function test_many_rows(): void
+    {
+        $rows = ["name,age"];
+        for ($i = 0; $i < 1000; $i++) {
+            $rows[] = "User{$i},{$i}";
+        }
+        $csv = implode("\n", $rows);
+
+        $result = $this->service->parse($csv);
+
+        $this->assertCount(1001, $result);
+    }
+
+    // ==================== Special Characters ====================
+
+    public function test_backslash_in_value(): void
+    {
+        $csv = "path\nC:\\Users\\John";
+        $result = $this->service->parse($csv);
+
+        $this->assertStringContainsString('\\', $result[1][0]);
+    }
+
+    public function test_sql_special_chars_escaped(): void
+    {
+        $csv = "name\nO'Brien";
+        $sql = $this->service->toSql($csv, 'users', ',', true);
+
+        // Single quotes should be escaped
+        $this->assertStringContainsString("\\'", $sql);
+    }
+
+    public function test_html_special_chars_preserved(): void
+    {
+        $csv = "code\n";
+        $json = $this->service->toJson($csv, ',', true);
+        $decoded = json_decode($json, true);
+
+        // HTML chars should be preserved in JSON (escaping is for display)
+        $this->assertStringContainsString('
+@endpush
+
+@section('content')
+
+
+
+

Laravel Visitor Tracker

+

Server-side analytics for Laravel - Unblockable by ad blockers

+
+ ← Back +
+ + + + + +
+

Live Statistics from dev-tools.online

+

Real data collected by this package running on this site:

+ +
+
+

{{ number_format($summary['total_visitors']) }}

+

Total Visitors

+
+
+

{{ number_format($summary['total_page_views']) }}

+

Page Views

+
+
+

{{ number_format($summary['online_visitors']) }}

+

Online Now

+
+
+

{{ number_format($summary['today_visitors']) }}

+

Today

+
+
+
+ +
+ +
+

Your Browser Detected

+

This is what the package detects about your current visit:

+ +
+
+ Browser + {{ $parsedUA['browser'] ?? 'Unknown' }} {{ $parsedUA['browser_version'] ?? '' }} +
+
+ Platform + {{ $parsedUA['platform'] ?? 'Unknown' }} {{ $parsedUA['platform_version'] ?? '' }} +
+
+ Device Type + {{ $parsedUA['device_type'] ?? 'Unknown' }} +
+
+ Is Bot? + + {{ $isBot ? 'Yes' . ($botName ? " ({$botName})" : '') : 'No (Human)' }} + +
+ @if($botCategory) +
+ Bot Category + {{ str_replace('_', ' ', $botCategory) }} +
+ @endif +
+ IP Address + {{ $visitorIp }} +
+
+ +
+

User Agent:

+

{{ $userAgent }}

+
+
+ + +
+
+

Browser Stats

+
+ @php $totalBrowsers = $browsers->sum('count'); @endphp + @forelse($browsers as $browser) +
+
+ {{ $browser->browser ?? 'Unknown' }} + {{ $totalBrowsers > 0 ? round($browser->count / $totalBrowsers * 100, 1) : 0 }}% +
+
+
+
+
+ @empty +

No data yet

+ @endforelse +
+
+ +
+

Device Types

+
+ @php + $totalDevices = $devices->sum('count'); + $deviceColors = ['desktop' => 'bg-purple-600', 'mobile' => 'bg-green-600', 'tablet' => 'bg-yellow-500']; + @endphp + @forelse($devices as $device) +
+
+ {{ $device->device_type ?? 'Unknown' }} + {{ number_format($device->count) }} ({{ $totalDevices > 0 ? round($device->count / $totalDevices * 100, 1) : 0 }}%) +
+
+
+
+
+ @empty +

No data yet

+ @endforelse +
+
+
+
+ + + @if($topPages->isNotEmpty()) +
+

Top Pages

+
+ + + + + + + + + + @foreach($topPages as $page) + + + + + + @endforeach + +
PathViewsUnique
/{{ $page->path }}{{ number_format($page->visits) }}{{ number_format($page->unique_visitors) }}
+
+
+ @endif + + +
+

Package Features

+
+
+
+ +
+
+

Unblockable

+

Server-side tracking can't be blocked by ad blockers

+
+
+
+
+ +
+
+

Native Detection

+

100+ bot patterns, browser/device parsing - no dependencies

+
+
+
+
+ +
+
+

GDPR Compliant

+

GDPR Safe Mode, IP anonymization, DNT support

+
+
+
+
+ +
+
+

Zero Dependencies

+

Only uses Laravel's built-in features

+
+
+
+
+ +
+
+

Geolocation

+

IP-based location via free APIs (optional)

+
+
+
+
+ +
+
+

Built-in Dashboard

+

Tailwind CSS dashboard with token auth

+
+
+
+
+ + +
+

Quick Installation

+
+
+

1. Install via Composer:

+
composer require ghdj/laravel-visitor-tracker
+
+
+

2. Publish config and run migrations:

+
php artisan vendor:publish --tag="visitor-tracker-config"
+php artisan migrate
+
+
+

3. Add middleware to your routes:

+
// In bootstrap/app.php (Laravel 11+)
+->withMiddleware(function (Middleware $middleware) {
+    $middleware->web(append: [
+        \Ghdj\VisitorTracker\Middleware\TrackVisitor::class,
+    ]);
+})
+
+
+

4. Use it:

+
use Ghdj\VisitorTracker\Facades\VisitorTracker;
+
+// Get statistics
+$stats = VisitorTracker::stats()->summary();
+$online = VisitorTracker::stats()->onlineVisitors();
+$topPages = VisitorTracker::stats()->mostVisitedPages(10);
+
+
+
+ + +
+

Laravel Visitor Tracker v1.0.0 - MIT License

+

+ Documentation + · + Packagist +

+
+
+@endsection diff --git a/routes/web.php b/routes/web.php index 63e04f2..68c5470 100644 --- a/routes/web.php +++ b/routes/web.php @@ -29,6 +29,7 @@ Route::get('/jwt', [ToolController::class, 'jwt'])->name('jwt'); Route::get('/timestamp', [ToolController::class, 'timestamp'])->name('timestamp'); Route::get('/diff', [ToolController::class, 'diff'])->name('diff'); + Route::get('/visitor-tracker', [ToolController::class, 'visitorTracker'])->name('visitor-tracker'); }); // Static Pages @@ -62,6 +63,7 @@ ['loc' => route('tools.jwt'), 'priority' => '0.8', 'changefreq' => 'monthly'], ['loc' => route('tools.timestamp'), 'priority' => '0.8', 'changefreq' => 'monthly'], ['loc' => route('tools.diff'), 'priority' => '0.8', 'changefreq' => 'monthly'], + ['loc' => route('tools.visitor-tracker'), 'priority' => '0.9', 'changefreq' => 'monthly'], ['loc' => route('about'), 'priority' => '0.5', 'changefreq' => 'monthly'], ['loc' => route('privacy'), 'priority' => '0.3', 'changefreq' => 'yearly'], ]; From 47bc7712b6653e523ef328c4314a841adb8dc114 Mon Sep 17 00:00:00 2001 From: jalel Date: Wed, 29 Apr 2026 00:47:16 +0100 Subject: [PATCH 5/6] ci: disable visitor tracker dashboard in tests workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The package's service provider throws a RuntimeException during package:discover when the dashboard is enabled but no auth method is configured. CI has no env vars set, so composer install fails before tests can run. Disable the dashboard for the tests job — the suite doesn't exercise it. --- .github/workflows/tests.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 08480d4..a1418b0 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -16,6 +16,9 @@ jobs: name: PHP ${{ matrix.php }} + env: + VISITOR_TRACKER_DASHBOARD_ENABLED: false + steps: - name: Checkout code uses: actions/checkout@v4 From 5dc2641f97c6aed440568619eac5e664eb414372 Mon Sep 17 00:00:00 2001 From: jalel Date: Wed, 29 Apr 2026 08:42:51 +0100 Subject: [PATCH 6/6] Prepare v1.4.0 release - CHANGELOG: add 1.4.0 entry (visitor tracker integration, test scaffolding, deploy hardening, operator notes for required env vars). - CHANGELOG: backfill missing 1.3.0 entry (Sort Lines tool, code editor PHP parse fix), since the 1.3.0 tag shipped without one. - Bump footer version in layouts/app.blade.php from v1.2.0 to v1.4.0 (was stale by one release). --- CHANGELOG.md | 50 +++++++++++++++++++++++++++ resources/views/layouts/app.blade.php | 2 +- 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a98f09..112e721 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,56 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.4.0] - 2026-04-28 + +### Added + +- **Visitor Tracker**: First-party visitor analytics for dev-tools.online, + powered by the `ghdj/laravel-visitor-tracker` package. + - New `/tools/visitor-tracker` public-facing tool page (and sitemap entry) + - `TrackVisitor` middleware wired into the `web` group so all web requests + are recorded + - Built-in dashboard at `/admin/visitor-tracker`, with env-driven auth: + token-based locally, `allow_unprotected` in production where Cloudflare + Access gates `/admin/*` at the edge + - Geolocation enabled (ip-api provider) for country breakdowns + - Test scaffolding: `phpunit.xml`, `tests/TestCase.php`, and unit/feature + tests for the Base64, CSV, Markdown, SQL, and YAML services/APIs + +### Operations + +- New `cron/migrate.php` entrypoint that bootstraps Laravel and runs + `migrate --force`, used by the OVH cron to apply package migrations on + shared hosting. +- Deploy workflow (`deploy.yml`) now strips `database/*.sqlite*` and + `bootstrap/cache/*.php` from the build before SFTP, so dev artifacts + never reach production and the server re-caches config/routes after + deploy. +- `.gitignore` excludes local SQLite databases and cached bootstrap files. +- Tests workflow (`tests.yml`) sets `VISITOR_TRACKER_DASHBOARD_ENABLED=false` + so the package's boot-time auth guard does not trip during CI. + +### Operator notes + +When deploying to a fresh environment, set in the server `.env`: + +- `VISITOR_TRACKER_ALLOW_UNPROTECTED=true` (or `VISITOR_TRACKER_TOKEN=...`) + — required, otherwise the package's service provider throws on boot. +- `DB_CONNECTION=sqlite` if no DB server is available. + +## [1.3.0] - 2025-12-15 + +### Added + +- **Sort Lines**: Sort lines of text alphabetically, numerically, or by + length, with options for case sensitivity, reverse order, and removing + duplicates. + +### Fixed + +- Resolved a PHP parse error in the online code editor that prevented + some snippets from rendering. + ## [1.2.0] - 2025-12-15 ### Added diff --git a/resources/views/layouts/app.blade.php b/resources/views/layouts/app.blade.php index ca2ea7e..ec5b34c 100644 --- a/resources/views/layouts/app.blade.php +++ b/resources/views/layouts/app.blade.php @@ -126,7 +126,7 @@ class="absolute top-1 w-6 h-6 rounded-full shadow-lg transition-all duration-500 About Privacy GitHub - v1.2.0 + v1.4.0