diff --git a/.gitignore b/.gitignore index 8044a40..2bd424f 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,6 @@ *.iml *.local.* *.swp + +# Tests +.phpunit.result.cache diff --git a/src/Report/ReportLoader.php b/src/Report/ReportLoader.php index b4b6e26..77c401e 100644 --- a/src/Report/ReportLoader.php +++ b/src/Report/ReportLoader.php @@ -8,11 +8,18 @@ class ReportLoader { + private string $reportPath; + + public function __construct(string $reportPath = Report::REPORT_FILENAME) + { + $this->reportPath = $reportPath; + } + public function loadReport(): ?Report { - if (file_exists(Report::REPORT_FILENAME)) { - $contents = file_get_contents(Report::REPORT_FILENAME); - $data = json_decode($contents, true, JSON_THROW_ON_ERROR); + if (file_exists($this->reportPath)) { + $contents = file_get_contents($this->reportPath); + $data = json_decode($contents, true, flags: JSON_THROW_ON_ERROR); Assert::isArray($data); return Report::fromArray($data); } diff --git a/src/Report/ReportWriter.php b/src/Report/ReportWriter.php index e02a591..d4aca84 100644 --- a/src/Report/ReportWriter.php +++ b/src/Report/ReportWriter.php @@ -6,8 +6,15 @@ class ReportWriter { + private string $reportPath; + + public function __construct(string $reportPath = Report::REPORT_FILENAME) + { + $this->reportPath = $reportPath; + } + public function write(Report $report): void { - file_put_contents(Report::REPORT_FILENAME, json_encode($report->toArray())); + file_put_contents($this->reportPath, json_encode($report->toArray())); } } diff --git a/tests/Unit/Console/Output/ConsoleLoggerTest.php b/tests/Unit/Console/Output/ConsoleLoggerTest.php new file mode 100644 index 0000000..7bde375 --- /dev/null +++ b/tests/Unit/Console/Output/ConsoleLoggerTest.php @@ -0,0 +1,305 @@ +output = $this->createMock(OutputInterface::class); + $this->output->method('getVerbosity')->willReturn(OutputInterface::VERBOSITY_NORMAL); + $this->logger = new ConsoleLogger($this->output); + } + + public function testLogInterpolatesStringPlaceholder(): void + { + $this->output->expects($this->once()) + ->method('writeln') + ->with( + $this->stringContains('Hello World'), + $this->anything() + ); + + $this->logger->info('Hello {name}', ['name' => 'World']); + } + + public function testLogInterpolatesIntegerPlaceholder(): void + { + $this->output->expects($this->once()) + ->method('writeln') + ->with( + $this->stringContains('Count: 42'), + $this->anything() + ); + + $this->logger->info('Count: {count}', ['count' => 42]); + } + + public function testLogInterpolatesFloatPlaceholder(): void + { + $this->output->expects($this->once()) + ->method('writeln') + ->with( + $this->stringContains('Value: 3.14'), + $this->anything() + ); + + $this->logger->info('Value: {value}', ['value' => 3.14]); + } + + public function testLogInterpolatesBooleanPlaceholder(): void + { + $this->output->expects($this->once()) + ->method('writeln') + ->with( + $this->stringContains('Active: 1'), + $this->anything() + ); + + $this->logger->info('Active: {active}', ['active' => true]); + } + + public function testLogInterpolatesObjectWithToString(): void + { + $object = new class { + public function __toString(): string + { + return 'StringableObject'; + } + }; + + $this->output->expects($this->once()) + ->method('writeln') + ->with( + $this->stringContains('Object: StringableObject'), + $this->anything() + ); + + $this->logger->info('Object: {obj}', ['obj' => $object]); + } + + public function testLogInterpolatesDateTimeInterface(): void + { + $date = new DateTime('2024-01-15T10:30:00+00:00'); + + $this->output->expects($this->once()) + ->method('writeln') + ->with( + $this->stringContains('Date: 2024-01-15T10:30:00+00:00'), + $this->anything() + ); + + $this->logger->info('Date: {date}', ['date' => $date]); + } + + public function testLogInterpolatesObjectWithoutToStringAsClassName(): void + { + $object = new \stdClass(); + + $this->output->expects($this->once()) + ->method('writeln') + ->with( + $this->stringContains('[object stdClass]'), + $this->anything() + ); + + $this->logger->info('Object: {obj}', ['obj' => $object]); + } + + public function testLogInterpolatesArrayAsArrayType(): void + { + $this->output->expects($this->once()) + ->method('writeln') + ->with( + $this->stringContains('[array]'), + $this->anything() + ); + + $this->logger->info('Data: {data}', ['data' => ['foo', 'bar']]); + } + + public function testLogInterpolatesNullAsEmpty(): void + { + $this->output->expects($this->once()) + ->method('writeln') + ->with( + $this->stringContains('Value: '), + $this->anything() + ); + + $this->logger->info('Value: {value}', ['value' => null]); + } + + public function testLogWithoutPlaceholdersReturnsMessageUnchanged(): void + { + $this->output->expects($this->once()) + ->method('writeln') + ->with( + $this->stringContains('Simple message'), + $this->anything() + ); + + $this->logger->info('Simple message'); + } + + public function testLogThrowsExceptionForInvalidLevel(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The log level "invalid" does not exist.'); + + $this->logger->log('invalid', 'test message'); + } + + public function testHasErroredReturnsFalseInitially(): void + { + $this->assertSame(false, $this->logger->hasErrored()); + } + + public function testHasErroredReturnsTrueAfterErrorLog(): void + { + $this->logger->error('An error occurred'); + + $this->assertSame(true, $this->logger->hasErrored()); + } + + public function testHasErroredReturnsTrueAfterCriticalLog(): void + { + $this->logger->critical('Critical error'); + + $this->assertSame(true, $this->logger->hasErrored()); + } + + public function testHasErroredReturnsFalseAfterInfoLog(): void + { + $this->logger->info('Info message'); + + $this->assertSame(false, $this->logger->hasErrored()); + } + + public function testErrorLevelWritesToErrorOutput(): void + { + $errorOutput = $this->createMock(OutputInterface::class); + $errorOutput->method('getVerbosity')->willReturn(OutputInterface::VERBOSITY_NORMAL); + $errorOutput->expects($this->once()) + ->method('writeln') + ->with( + $this->stringContains('error message'), + $this->anything() + ); + + $consoleOutput = $this->createMock(ConsoleOutputInterface::class); + $consoleOutput->method('getVerbosity')->willReturn(OutputInterface::VERBOSITY_NORMAL); + $consoleOutput->method('getErrorOutput')->willReturn($errorOutput); + + $logger = new ConsoleLogger($consoleOutput); + $logger->error('error message'); + } + + public function testDebugLevelNotWrittenAtNormalVerbosity(): void + { + $this->output->expects($this->never())->method('writeln'); + + $this->logger->debug('debug message'); + } + + public function testDebugLevelWrittenAtVerboseLevel(): void + { + $verboseOutput = $this->createMock(OutputInterface::class); + $verboseOutput->method('getVerbosity')->willReturn(OutputInterface::VERBOSITY_VERBOSE); + $verboseOutput->expects($this->once()) + ->method('writeln') + ->with( + $this->stringContains('debug message'), + $this->anything() + ); + + $logger = new ConsoleLogger($verboseOutput); + $logger->debug('debug message'); + } + + // Additional log level tests + + public function testHasErroredReturnsTrueAfterEmergencyLog(): void + { + $this->logger->emergency('Emergency error'); + + $this->assertSame(true, $this->logger->hasErrored()); + } + + public function testHasErroredReturnsTrueAfterAlertLog(): void + { + $this->logger->alert('Alert error'); + + $this->assertSame(true, $this->logger->hasErrored()); + } + + public function testHasErroredReturnsFalseAfterWarningLog(): void + { + $this->logger->warning('Warning message'); + + $this->assertSame(false, $this->logger->hasErrored()); + } + + public function testHasErroredReturnsFalseAfterNoticeLog(): void + { + $this->logger->notice('Notice message'); + + $this->assertSame(false, $this->logger->hasErrored()); + } + + public function testCustomVerbosityLevelMapIsUsed(): void + { + // Create custom map where info requires verbose + $customVerbosityMap = [ + LogLevel::INFO => OutputInterface::VERBOSITY_VERBOSE, + ]; + + $normalOutput = $this->createMock(OutputInterface::class); + $normalOutput->method('getVerbosity')->willReturn(OutputInterface::VERBOSITY_NORMAL); + $normalOutput->expects($this->never())->method('writeln'); + + $logger = new ConsoleLogger($normalOutput, $customVerbosityMap); + $logger->info('This should not be written at normal verbosity'); + } + + public function testCustomFormatLevelMapIsUsed(): void + { + // Create custom map where warning is treated as error (writes to error output) + $customFormatMap = [ + LogLevel::WARNING => ConsoleLogger::ERROR, + ]; + + $errorOutput = $this->createMock(OutputInterface::class); + $errorOutput->method('getVerbosity')->willReturn(OutputInterface::VERBOSITY_NORMAL); + $errorOutput->expects($this->once()) + ->method('writeln') + ->with( + $this->stringContains('warning treated as error'), + $this->anything() + ); + + $consoleOutput = $this->createMock(ConsoleOutputInterface::class); + $consoleOutput->method('getVerbosity')->willReturn(OutputInterface::VERBOSITY_NORMAL); + $consoleOutput->method('getErrorOutput')->willReturn($errorOutput); + + $logger = new ConsoleLogger($consoleOutput, [], $customFormatMap); + $logger->warning('warning treated as error'); + + // Warning with custom format map should set errored flag + $this->assertSame(true, $logger->hasErrored()); + } +} diff --git a/tests/Unit/Console/Output/OutputWatcherTest.php b/tests/Unit/Console/Output/OutputWatcherTest.php new file mode 100644 index 0000000..ea9df1b --- /dev/null +++ b/tests/Unit/Console/Output/OutputWatcherTest.php @@ -0,0 +1,185 @@ +wrappedOutput = $this->createMock(OutputInterface::class); + $this->wrappedOutput->method('getVerbosity')->willReturn(OutputInterface::VERBOSITY_NORMAL); + $this->watcher = new OutputWatcher($this->wrappedOutput); + } + + public function testGetWasWrittenReturnsFalseInitially(): void + { + $this->assertSame(false, $this->watcher->getWasWritten()); + } + + public function testWriteSetsWasWrittenToTrue(): void + { + $this->watcher->write('test message'); + + $this->assertSame(true, $this->watcher->getWasWritten()); + } + + public function testWritelnSetsWasWrittenToTrue(): void + { + $this->watcher->writeln('test message'); + + $this->assertSame(true, $this->watcher->getWasWritten()); + } + + public function testSetWasWrittenCanResetFlag(): void + { + $this->watcher->write('test'); + $this->assertSame(true, $this->watcher->getWasWritten()); + + $this->watcher->setWasWritten(false); + $this->assertSame(false, $this->watcher->getWasWritten()); + } + + public function testSetWasWrittenCanSetFlagDirectly(): void + { + $this->watcher->setWasWritten(true); + $this->assertSame(true, $this->watcher->getWasWritten()); + } + + public function testWriteDelegatesToWrappedOutput(): void + { + $this->wrappedOutput->expects($this->once()) + ->method('write') + ->with('test message', false, OutputInterface::OUTPUT_NORMAL); + + $this->watcher->write('test message'); + } + + public function testWritelnDelegatesToWrappedOutputWithNewline(): void + { + $this->wrappedOutput->expects($this->once()) + ->method('write') + ->with('test message', true, OutputInterface::OUTPUT_NORMAL); + + $this->watcher->writeln('test message'); + } + + public function testGetVerbosityDelegatesToWrappedOutput(): void + { + $verboseOutput = $this->createMock(OutputInterface::class); + $verboseOutput->expects($this->atLeastOnce()) + ->method('getVerbosity') + ->willReturn(OutputInterface::VERBOSITY_VERBOSE); + + $watcher = new OutputWatcher($verboseOutput); + + $this->assertSame(OutputInterface::VERBOSITY_VERBOSE, $watcher->getVerbosity()); + } + + public function testSetVerbosityDelegatesToWrappedOutput(): void + { + $this->wrappedOutput->expects($this->once()) + ->method('setVerbosity') + ->with(OutputInterface::VERBOSITY_DEBUG); + + $this->watcher->setVerbosity(OutputInterface::VERBOSITY_DEBUG); + } + + public function testIsQuietReturnsTrueWhenVerbosityIsQuiet(): void + { + $quietOutput = $this->createMock(OutputInterface::class); + $quietOutput->method('getVerbosity')->willReturn(OutputInterface::VERBOSITY_QUIET); + + $watcher = new OutputWatcher($quietOutput); + + $this->assertSame(true, $watcher->isQuiet()); + } + + public function testIsQuietReturnsFalseWhenVerbosityIsNormal(): void + { + $this->assertSame(false, $this->watcher->isQuiet()); + } + + public function testIsVerboseReturnsTrueWhenVerbosityIsVerbose(): void + { + $verboseOutput = $this->createMock(OutputInterface::class); + $verboseOutput->method('getVerbosity')->willReturn(OutputInterface::VERBOSITY_VERBOSE); + + $watcher = new OutputWatcher($verboseOutput); + + $this->assertSame(true, $watcher->isVerbose()); + } + + public function testIsVerboseReturnsFalseWhenVerbosityIsNormal(): void + { + $this->assertSame(false, $this->watcher->isVerbose()); + } + + public function testIsVeryVerboseReturnsTrueWhenVerbosityIsVeryVerbose(): void + { + $veryVerboseOutput = $this->createMock(OutputInterface::class); + $veryVerboseOutput->method('getVerbosity')->willReturn(OutputInterface::VERBOSITY_VERY_VERBOSE); + + $watcher = new OutputWatcher($veryVerboseOutput); + + $this->assertSame(true, $watcher->isVeryVerbose()); + } + + public function testIsDebugReturnsTrueWhenVerbosityIsDebug(): void + { + $debugOutput = $this->createMock(OutputInterface::class); + $debugOutput->method('getVerbosity')->willReturn(OutputInterface::VERBOSITY_DEBUG); + + $watcher = new OutputWatcher($debugOutput); + + $this->assertSame(true, $watcher->isDebug()); + } + + public function testIsDecoratedDelegatesToWrappedOutput(): void + { + $this->wrappedOutput->expects($this->once()) + ->method('isDecorated') + ->willReturn(true); + + $this->assertSame(true, $this->watcher->isDecorated()); + } + + public function testSetDecoratedDelegatesToWrappedOutput(): void + { + $this->wrappedOutput->expects($this->once()) + ->method('setDecorated') + ->with(true); + + $this->watcher->setDecorated(true); + } + + public function testGetFormatterDelegatesToWrappedOutput(): void + { + $formatter = $this->createMock(OutputFormatterInterface::class); + $this->wrappedOutput->expects($this->once()) + ->method('getFormatter') + ->willReturn($formatter); + + $this->assertSame($formatter, $this->watcher->getFormatter()); + } + + public function testSetFormatterDelegatesToWrappedOutput(): void + { + $formatter = $this->createMock(OutputFormatterInterface::class); + $this->wrappedOutput->expects($this->once()) + ->method('setFormatter') + ->with($formatter); + + $this->watcher->setFormatter($formatter); + } +} diff --git a/tests/Unit/Deployer/FunctionsTest.php b/tests/Unit/Deployer/FunctionsTest.php new file mode 100644 index 0000000..2285508 --- /dev/null +++ b/tests/Unit/Deployer/FunctionsTest.php @@ -0,0 +1,96 @@ +assertSame('first_value', $result); + } + + public function testGetenvFallbackReturnsSecondWhenFirstNotSet(): void + { + putenv(self::ENV_VAR_1); + putenv(self::ENV_VAR_2 . '=second_value'); + + $result = getenvFallback([self::ENV_VAR_1, self::ENV_VAR_2]); + + $this->assertSame('second_value', $result); + } + + public function testGetenvFallbackReturnsThirdWhenFirstTwoNotSet(): void + { + putenv(self::ENV_VAR_1); + putenv(self::ENV_VAR_2); + putenv(self::ENV_VAR_3 . '=third_value'); + + $result = getenvFallback([self::ENV_VAR_1, self::ENV_VAR_2, self::ENV_VAR_3]); + + $this->assertSame('third_value', $result); + } + + public function testGetenvFallbackThrowsExceptionWhenNoneSet(): void + { + putenv(self::ENV_VAR_1); + putenv(self::ENV_VAR_2); + + $this->expectException(EnvironmentVariableNotDefinedException::class); + $this->expectExceptionMessage( + 'None of the requested environment variables ' . self::ENV_VAR_1 . ', ' . self::ENV_VAR_2 . ' is defined' + ); + + getenvFallback([self::ENV_VAR_1, self::ENV_VAR_2]); + } + + public function testGetenvFallbackWithEmptyArrayThrowsException(): void + { + $this->expectException(EnvironmentVariableNotDefinedException::class); + $this->expectExceptionMessage('None of the requested environment variables is defined'); + + getenvFallback([]); + } + + // Tests for noop() function + + public function testNoopReturnsCallable(): void + { + $result = noop(); + + $this->assertInstanceOf(\Closure::class, $result); + } + + public function testNoopReturnedClosureDoesNothing(): void + { + $closure = noop(); + + // Should execute without error and return nothing + $result = $closure(); + + $this->assertNull($result); + } +} diff --git a/tests/Unit/Deployer/Task/IncrementedTaskTraitTest.php b/tests/Unit/Deployer/Task/IncrementedTaskTraitTest.php new file mode 100644 index 0000000..37f1bad --- /dev/null +++ b/tests/Unit/Deployer/Task/IncrementedTaskTraitTest.php @@ -0,0 +1,75 @@ +assertSame('test:task:0', $task->getTaskName()); + $this->assertSame('test:task:1', $task->getTaskName()); + $this->assertSame('test:task:2', $task->getTaskName()); + } + + public function testGetTaskNameWithIdentifierIncludesIdentifier(): void + { + $task = new TestIncrementedTask(); + + $result = $task->getTaskName('foo'); + + $this->assertSame('test:task:foo:0', $result); + } + + public function testGetTaskNameWithIdentifierIncrementsCounter(): void + { + $task = new TestIncrementedTask(); + + $this->assertSame('test:task:foo:0', $task->getTaskName('foo')); + $this->assertSame('test:task:bar:1', $task->getTaskName('bar')); + $this->assertSame('test:task:foo:2', $task->getTaskName('foo')); + } + + public function testGetTaskNameMixedWithAndWithoutIdentifier(): void + { + $task = new TestIncrementedTask(); + + $this->assertSame('test:task:0', $task->getTaskName()); + $this->assertSame('test:task:foo:1', $task->getTaskName('foo')); + $this->assertSame('test:task:2', $task->getTaskName()); + } + + public function testGetRegisteredTasksReturnsAllGeneratedNames(): void + { + $task = new TestIncrementedTask(); + + $task->getTaskName(); + $task->getTaskName('foo'); + $task->getTaskName(); + + $this->assertSame( + ['test:task:0', 'test:task:foo:1', 'test:task:2'], + $task->getRegisteredTasks() + ); + } + + public function testGetRegisteredTasksReturnsEmptyArrayInitially(): void + { + $task = new TestIncrementedTask(); + + $this->assertSame([], $task->getRegisteredTasks()); + } + + public function testGetTaskNameWithEmptyIdentifierTreatedAsNoIdentifier(): void + { + $task = new TestIncrementedTask(); + + // Empty string should be treated same as no identifier due to !empty() check + $this->assertSame('test:task:0', $task->getTaskName('')); + } +} diff --git a/tests/Unit/Deployer/Task/TestIncrementedTask.php b/tests/Unit/Deployer/Task/TestIncrementedTask.php new file mode 100644 index 0000000..ad6c3e1 --- /dev/null +++ b/tests/Unit/Deployer/Task/TestIncrementedTask.php @@ -0,0 +1,23 @@ +output = $this->createMock(OutputInterface::class); + $this->printer = new GithubWorkflowPrinter($this->output); + } + + public function testContentHasWorkflowCommandReturnsTrueForWarningCommand(): void + { + $this->assertSame(true, $this->printer->contentHasWorkflowCommand('::warning::some message')); + } + + public function testContentHasWorkflowCommandReturnsTrueForErrorCommand(): void + { + $this->assertSame(true, $this->printer->contentHasWorkflowCommand('::error::something went wrong')); + } + + public function testContentHasWorkflowCommandReturnsTrueForSetOutputCommand(): void + { + $this->assertSame(true, $this->printer->contentHasWorkflowCommand('::set-output name=foo::bar')); + } + + public function testContentHasWorkflowCommandReturnsTrueForDebugCommand(): void + { + $this->assertSame(true, $this->printer->contentHasWorkflowCommand('::debug::debug info')); + } + + public function testContentHasWorkflowCommandReturnsTrueForGroupCommand(): void + { + $this->assertSame(true, $this->printer->contentHasWorkflowCommand('::group::My Group')); + } + + public function testContentHasWorkflowCommandReturnsTrueForEndGroupCommand(): void + { + $this->assertSame(true, $this->printer->contentHasWorkflowCommand('::endgroup::')); + } + + public function testContentHasWorkflowCommandReturnsFalseForRegularText(): void + { + $this->assertSame(false, $this->printer->contentHasWorkflowCommand('Hello world')); + } + + public function testContentHasWorkflowCommandReturnsFalseForEmptyString(): void + { + $this->assertSame(false, $this->printer->contentHasWorkflowCommand('')); + } + + public function testContentHasWorkflowCommandReturnsFalseForPartialCommand(): void + { + $this->assertSame(false, $this->printer->contentHasWorkflowCommand('::invalid')); + } + + public function testContentHasWorkflowCommandReturnsFalseForColonsWithoutCommand(): void + { + $this->assertSame(false, $this->printer->contentHasWorkflowCommand(':: ::')); + } + + public function testContentHasWorkflowCommandReturnsTrueForCommandInMultilineContent(): void + { + $content = "Some regular output\n::warning::a warning message\nMore output"; + $this->assertSame(true, $this->printer->contentHasWorkflowCommand($content)); + } + + public function testContentHasWorkflowCommandReturnsTrueForCommandWithFileAndLine(): void + { + $this->assertSame(true, $this->printer->contentHasWorkflowCommand('::error file=app.js,line=10::error message')); + } + + // Tests for writeln() method + + public function testWritelnOutputsWorkflowCommandDirectlyForStdout(): void + { + $host = $this->createMock(Host::class); + + $this->output->expects($this->once()) + ->method('writeln') + ->with('::warning::test message'); + + $this->printer->writeln(Process::OUT, $host, '::warning::test message'); + } + + public function testWritelnDoesNotOutputEmptyLine(): void + { + $host = $this->createMock(Host::class); + + $this->output->expects($this->never()) + ->method('writeln'); + + $this->printer->writeln(Process::OUT, $host, ''); + } + + public function testWritelnDelegatesToParentForNonWorkflowStdout(): void + { + $host = $this->createMock(Host::class); + $host->method('__toString')->willReturn('test-host'); + + // Parent's writeln will be called which writes "[host] line" format + $this->output->expects($this->once()) + ->method('writeln') + ->with($this->stringContains('regular output')); + + $this->printer->writeln(Process::OUT, $host, 'regular output'); + } + + public function testWritelnDelegatesToParentForStderr(): void + { + $host = $this->createMock(Host::class); + $host->method('__toString')->willReturn('test-host'); + + // Even workflow commands on stderr should go through parent + $this->output->expects($this->once()) + ->method('writeln') + ->with($this->stringContains('::warning::')); + + $this->printer->writeln(Process::ERR, $host, '::warning::error stream message'); + } + + // Tests for callback() method + + public function testCallbackPrintsWhenForceOutputIsTrue(): void + { + $host = $this->createMock(Host::class); + $host->method('__toString')->willReturn('test-host'); + + $this->output->method('isVerbose')->willReturn(false); + $this->output->expects($this->once()) + ->method('writeln') + ->with($this->stringContains('forced output')); + + $callback = $this->printer->callback($host, true); + $callback(Process::OUT, 'forced output'); + } + + public function testCallbackPrintsWhenVerbose(): void + { + $host = $this->createMock(Host::class); + $host->method('__toString')->willReturn('test-host'); + + $this->output->method('isVerbose')->willReturn(true); + $this->output->expects($this->once()) + ->method('writeln') + ->with($this->stringContains('verbose output')); + + $callback = $this->printer->callback($host, false); + $callback(Process::OUT, 'verbose output'); + } + + public function testCallbackPrintsWorkflowCommandOnStdoutEvenWhenNotVerbose(): void + { + $host = $this->createMock(Host::class); + + $this->output->method('isVerbose')->willReturn(false); + $this->output->expects($this->once()) + ->method('writeln') + ->with('::warning::workflow command'); + + $callback = $this->printer->callback($host, false); + $callback(Process::OUT, '::warning::workflow command'); + } + + public function testCallbackDoesNotPrintRegularOutputWhenNotVerboseAndNotForced(): void + { + $host = $this->createMock(Host::class); + + $this->output->method('isVerbose')->willReturn(false); + $this->output->expects($this->never())->method('writeln'); + + $callback = $this->printer->callback($host, false); + $callback(Process::OUT, 'regular output that should be suppressed'); + } + + public function testCallbackDoesNotPrintWorkflowCommandOnStderrWhenNotVerbose(): void + { + $host = $this->createMock(Host::class); + + $this->output->method('isVerbose')->willReturn(false); + // Workflow command on stderr should NOT be printed when not verbose/forced + // because the condition checks $type == Process::OUT + $this->output->expects($this->never())->method('writeln'); + + $callback = $this->printer->callback($host, false); + $callback(Process::ERR, '::warning::workflow on stderr'); + } +} diff --git a/tests/Unit/Report/ReportLoaderTest.php b/tests/Unit/Report/ReportLoaderTest.php new file mode 100644 index 0000000..dbb7455 --- /dev/null +++ b/tests/Unit/Report/ReportLoaderTest.php @@ -0,0 +1,126 @@ +tempDir = sys_get_temp_dir() . '/hypernode-deploy-test-' . uniqid(); + mkdir($this->tempDir, 0777, true); + $this->reportPath = $this->tempDir . '/' . Report::REPORT_FILENAME; + } + + protected function tearDown(): void + { + if (file_exists($this->reportPath)) { + unlink($this->reportPath); + } + if (is_dir($this->tempDir)) { + rmdir($this->tempDir); + } + } + + public function testLoadReportReturnsNullWhenFileDoesNotExist(): void + { + $loader = new ReportLoader($this->reportPath); + + $result = $loader->loadReport(); + + $this->assertNull($result); + } + + public function testLoadReportReturnsReportWhenFileExists(): void + { + $data = [ + 'version' => 'v1', + 'stage' => 'production', + 'hostnames' => ['app.hypernode.io'], + 'brancher_hypernodes' => ['app-ephabc123.hypernode.io'], + ]; + file_put_contents($this->reportPath, json_encode($data)); + + $loader = new ReportLoader($this->reportPath); + + $result = $loader->loadReport(); + + $this->assertInstanceOf(Report::class, $result); + } + + public function testLoadReportParsesJsonCorrectly(): void + { + $data = [ + 'version' => 'v1', + 'stage' => 'staging', + 'hostnames' => ['staging.hypernode.io', 'staging2.hypernode.io'], + 'brancher_hypernodes' => ['staging-ephabc123.hypernode.io'], + ]; + file_put_contents($this->reportPath, json_encode($data)); + + $loader = new ReportLoader($this->reportPath); + + $result = $loader->loadReport(); + + $this->assertSame('v1', $result->getVersion()); + $this->assertSame('staging', $result->getStage()); + $this->assertSame(['staging.hypernode.io', 'staging2.hypernode.io'], $result->getHostnames()); + $this->assertSame(['staging-ephabc123.hypernode.io'], $result->getBrancherHypernodes()); + } + + public function testLoadReportThrowsExceptionForInvalidJson(): void + { + file_put_contents($this->reportPath, 'this is not valid json'); + + $loader = new ReportLoader($this->reportPath); + + $this->expectException(JsonException::class); + + $loader->loadReport(); + } + + public function testLoadReportHandlesEmptyArrays(): void + { + $data = [ + 'version' => 'v1', + 'stage' => 'test', + 'hostnames' => [], + 'brancher_hypernodes' => [], + ]; + file_put_contents($this->reportPath, json_encode($data)); + + $loader = new ReportLoader($this->reportPath); + + $result = $loader->loadReport(); + + $this->assertSame([], $result->getHostnames()); + $this->assertSame([], $result->getBrancherHypernodes()); + } + + public function testLoadReportRoundTripWithWriter(): void + { + $originalReport = new Report( + 'production', + ['app.hypernode.io', 'app2.hypernode.io'], + ['app-ephabc123.hypernode.io'], + 'v1' + ); + + file_put_contents($this->reportPath, json_encode($originalReport->toArray())); + + $loader = new ReportLoader($this->reportPath); + + $loadedReport = $loader->loadReport(); + + $this->assertSame($originalReport->toArray(), $loadedReport->toArray()); + } +} diff --git a/tests/Unit/Report/ReportTest.php b/tests/Unit/Report/ReportTest.php new file mode 100644 index 0000000..b7a44d6 --- /dev/null +++ b/tests/Unit/Report/ReportTest.php @@ -0,0 +1,88 @@ +toArray(); + + $this->assertSame('v1', $result['version']); + $this->assertSame('production', $result['stage']); + $this->assertSame(['app1.hypernode.io', 'app2.hypernode.io'], $result['hostnames']); + $this->assertSame(['app1-ephabc123.hypernode.io'], $result['brancher_hypernodes']); + } + + public function testFromArrayCreatesReportWithCorrectValues(): void + { + $data = [ + 'version' => 'v1', + 'stage' => 'staging', + 'hostnames' => ['staging.hypernode.io'], + 'brancher_hypernodes' => ['staging-ephabc123.hypernode.io'], + ]; + + $report = Report::fromArray($data); + + $this->assertSame('v1', $report->getVersion()); + $this->assertSame('staging', $report->getStage()); + $this->assertSame(['staging.hypernode.io'], $report->getHostnames()); + $this->assertSame(['staging-ephabc123.hypernode.io'], $report->getBrancherHypernodes()); + } + + public function testToArrayFromArrayRoundTripProducesEqualData(): void + { + $originalData = [ + 'version' => 'v1', + 'stage' => 'production', + 'hostnames' => ['app.hypernode.io'], + 'brancher_hypernodes' => ['app-ephabc123.hypernode.io'], + ]; + + $report = Report::fromArray($originalData); + $resultData = $report->toArray(); + + $this->assertSame($originalData, $resultData); + } + + public function testDefaultVersionIsV1(): void + { + $report = new Report( + 'production', + ['app.hypernode.io'], + ['app-ephabc123.hypernode.io'] + ); + + $this->assertSame(Report::REPORT_VERSION, $report->getVersion()); + $this->assertSame('v1', $report->getVersion()); + } + + public function testEmptyHostnamesArrayIsHandled(): void + { + $report = new Report('production', [], ['app-ephabc123.hypernode.io']); + + $this->assertSame([], $report->getHostnames()); + $this->assertSame([], $report->toArray()['hostnames']); + } + + public function testEmptyBrancherHypernodesArrayIsHandled(): void + { + $report = new Report('production', ['app.hypernode.io'], []); + + $this->assertSame([], $report->getBrancherHypernodes()); + $this->assertSame([], $report->toArray()['brancher_hypernodes']); + } +} diff --git a/tests/Unit/Report/ReportWriterTest.php b/tests/Unit/Report/ReportWriterTest.php new file mode 100644 index 0000000..b5e580e --- /dev/null +++ b/tests/Unit/Report/ReportWriterTest.php @@ -0,0 +1,107 @@ +tempDir = sys_get_temp_dir() . '/hypernode-deploy-test-' . uniqid(); + mkdir($this->tempDir, 0777, true); + $this->reportPath = $this->tempDir . '/' . Report::REPORT_FILENAME; + } + + protected function tearDown(): void + { + if (file_exists($this->reportPath)) { + unlink($this->reportPath); + } + if (is_dir($this->tempDir)) { + rmdir($this->tempDir); + } + } + + public function testWriteCreatesJsonFile(): void + { + $report = new Report('production', ['app.hypernode.io'], ['app-ephabc123.hypernode.io']); + + $writer = new ReportWriter($this->reportPath); + $writer->write($report); + + $this->assertFileExists($this->reportPath); + } + + public function testWriteCreatesValidJson(): void + { + $report = new Report('production', ['app.hypernode.io'], ['app-ephabc123.hypernode.io']); + + $writer = new ReportWriter($this->reportPath); + $writer->write($report); + + $contents = file_get_contents($this->reportPath); + $decoded = json_decode($contents, true); + + $this->assertNotNull($decoded); + $this->assertIsArray($decoded); + } + + public function testWriteContainsCorrectData(): void + { + $report = new Report( + 'staging', + ['staging.hypernode.io', 'staging2.hypernode.io'], + ['staging-ephabc123.hypernode.io'], + 'v1' + ); + + $writer = new ReportWriter($this->reportPath); + $writer->write($report); + + $contents = file_get_contents($this->reportPath); + $decoded = json_decode($contents, true); + + $this->assertSame('v1', $decoded['version']); + $this->assertSame('staging', $decoded['stage']); + $this->assertSame(['staging.hypernode.io', 'staging2.hypernode.io'], $decoded['hostnames']); + $this->assertSame(['staging-ephabc123.hypernode.io'], $decoded['brancher_hypernodes']); + } + + public function testWriteOverwritesExistingFile(): void + { + file_put_contents($this->reportPath, '{"old": "data"}'); + + $report = new Report('production', ['new.hypernode.io'], []); + + $writer = new ReportWriter($this->reportPath); + $writer->write($report); + + $contents = file_get_contents($this->reportPath); + $decoded = json_decode($contents, true); + + $this->assertSame('production', $decoded['stage']); + $this->assertArrayNotHasKey('old', $decoded); + } + + public function testWriteWithEmptyArrays(): void + { + $report = new Report('test', [], []); + + $writer = new ReportWriter($this->reportPath); + $writer->write($report); + + $contents = file_get_contents($this->reportPath); + $decoded = json_decode($contents, true); + + $this->assertSame([], $decoded['hostnames']); + $this->assertSame([], $decoded['brancher_hypernodes']); + } +} diff --git a/tests/Unit/Stdlib/RevisionFinderTest.php b/tests/Unit/Stdlib/RevisionFinderTest.php new file mode 100644 index 0000000..62bf52f --- /dev/null +++ b/tests/Unit/Stdlib/RevisionFinderTest.php @@ -0,0 +1,48 @@ +getRevision(); + + $this->assertSame('abc123def456', $result); + } + + public function testGetRevisionReturnsMasterWhenEnvVarIsNotSet(): void + { + putenv(self::ENV_VAR); + + $finder = new RevisionFinder(); + $result = $finder->getRevision(); + + $this->assertSame('master', $result); + } + + public function testGetRevisionReturnsMasterWhenEnvVarIsEmptyString(): void + { + putenv(self::ENV_VAR . '='); + + $finder = new RevisionFinder(); + $result = $finder->getRevision(); + + $this->assertSame('master', $result); + } +}