Passed
Pull Request — master (#695)
by Alejandro
05:41
created

allPendingVisitsAreProcessed()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 24
Code Lines 15

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
eloc 15
c 1
b 0
f 0
dl 0
loc 24
rs 9.7666
cc 1
nc 1
nop 0
1
<?php
2
3
declare(strict_types=1);
4
5
namespace ShlinkioTest\Shlink\CLI\Command\Visit;
6
7
use PHPUnit\Framework\TestCase;
8
use Prophecy\Argument;
9
use Prophecy\Prophecy\ObjectProphecy;
10
use Shlinkio\Shlink\CLI\Command\Visit\LocateVisitsCommand;
11
use Shlinkio\Shlink\CLI\Exception\GeolocationDbUpdateFailedException;
12
use Shlinkio\Shlink\CLI\Util\GeolocationDbUpdaterInterface;
13
use Shlinkio\Shlink\Common\Util\IpAddress;
14
use Shlinkio\Shlink\Core\Entity\ShortUrl;
15
use Shlinkio\Shlink\Core\Entity\Visit;
16
use Shlinkio\Shlink\Core\Entity\VisitLocation;
17
use Shlinkio\Shlink\Core\Model\Visitor;
18
use Shlinkio\Shlink\Core\Visit\VisitGeolocationHelperInterface;
19
use Shlinkio\Shlink\Core\Visit\VisitLocator;
20
use Shlinkio\Shlink\IpGeolocation\Exception\WrongIpException;
21
use Shlinkio\Shlink\IpGeolocation\Model\Location;
22
use Shlinkio\Shlink\IpGeolocation\Resolver\IpLocationResolverInterface;
23
use Symfony\Component\Console\Application;
24
use Symfony\Component\Console\Exception\RuntimeException;
25
use Symfony\Component\Console\Output\OutputInterface;
26
use Symfony\Component\Console\Tester\CommandTester;
27
use Symfony\Component\Lock;
28
29
use function sprintf;
30
31
use const PHP_EOL;
32
33
class LocateVisitsCommandTest extends TestCase
34
{
35
    private CommandTester $commandTester;
36
    private ObjectProphecy $visitService;
37
    private ObjectProphecy $ipResolver;
38
    private ObjectProphecy $locker;
39
    private ObjectProphecy $lock;
40
    private ObjectProphecy $dbUpdater;
41
42
    public function setUp(): void
43
    {
44
        $this->visitService = $this->prophesize(VisitLocator::class);
45
        $this->ipResolver = $this->prophesize(IpLocationResolverInterface::class);
46
        $this->dbUpdater = $this->prophesize(GeolocationDbUpdaterInterface::class);
47
48
        $this->locker = $this->prophesize(Lock\LockFactory::class);
49
        $this->lock = $this->prophesize(Lock\LockInterface::class);
50
        $this->lock->acquire(false)->willReturn(true);
51
        $this->lock->release()->will(function (): void {
52
        });
53
        $this->locker->createLock(Argument::type('string'), 90.0, false)->willReturn($this->lock->reveal());
54
55
        $command = new LocateVisitsCommand(
56
            $this->visitService->reveal(),
57
            $this->ipResolver->reveal(),
58
            $this->locker->reveal(),
59
            $this->dbUpdater->reveal(),
60
        );
61
        $app = new Application();
62
        $app->add($command);
63
64
        $this->commandTester = new CommandTester($command);
65
    }
66
67
    /**
68
     * @test
69
     * @dataProvider provideArgs
70
     */
71
    public function expectedSetOfVisitsIsProcessedBasedOnArgs(
72
        int $expectedUnlocatedCalls,
73
        int $expectedEmptyCalls,
74
        int $expectedAllCalls,
75
        bool $expectWarningPrint,
76
        array $args
77
    ): void {
78
        $visit = new Visit(new ShortUrl(''), new Visitor('', '', '1.2.3.4'));
79
        $location = new VisitLocation(Location::emptyInstance());
80
        $mockMethodBehavior = $this->invokeHelperMethods($visit, $location);
81
82
        $locateVisits = $this->visitService->locateUnlocatedVisits(Argument::cetera())->will($mockMethodBehavior);
83
        $locateEmptyVisits = $this->visitService->locateVisitsWithEmptyLocation(Argument::cetera())->will(
84
            $mockMethodBehavior,
85
        );
86
        $locateAllVisits = $this->visitService->locateAllVisits(Argument::cetera())->will($mockMethodBehavior);
87
        $resolveIpLocation = $this->ipResolver->resolveIpLocation(Argument::any())->willReturn(
88
            Location::emptyInstance(),
89
        );
90
91
        $this->commandTester->setInputs(['y']);
92
        $this->commandTester->execute($args);
93
        $output = $this->commandTester->getDisplay();
94
95
        $this->assertStringContainsString('Processing IP 1.2.3.0', $output);
96
        if ($expectWarningPrint) {
97
            $this->assertStringContainsString('Continue at your own risk', $output);
98
        } else {
99
            $this->assertStringNotContainsString('Continue at your own risk', $output);
100
        }
101
        $locateVisits->shouldHaveBeenCalledTimes($expectedUnlocatedCalls);
102
        $locateEmptyVisits->shouldHaveBeenCalledTimes($expectedEmptyCalls);
103
        $locateAllVisits->shouldHaveBeenCalledTimes($expectedAllCalls);
104
        $resolveIpLocation->shouldHaveBeenCalledTimes(
105
            $expectedUnlocatedCalls + $expectedEmptyCalls + $expectedAllCalls,
106
        );
107
    }
108
109
    public function provideArgs(): iterable
110
    {
111
        yield 'no args' => [1, 0, 0, false, []];
112
        yield 'retry' => [1, 1, 0, false, ['--retry' => true]];
113
        yield 'all' => [0, 0, 1, true, ['--retry' => true, '--all' => true]];
114
    }
115
116
    /**
117
     * @test
118
     * @dataProvider provideIgnoredAddresses
119
     */
120
    public function localhostAndEmptyAddressesAreIgnored(?string $address, string $message): void
121
    {
122
        $visit = new Visit(new ShortUrl(''), new Visitor('', '', $address));
123
        $location = new VisitLocation(Location::emptyInstance());
124
125
        $locateVisits = $this->visitService->locateUnlocatedVisits(Argument::cetera())->will(
126
            $this->invokeHelperMethods($visit, $location),
127
        );
128
        $resolveIpLocation = $this->ipResolver->resolveIpLocation(Argument::any())->willReturn(
129
            Location::emptyInstance(),
130
        );
131
132
        $this->commandTester->execute([], ['verbosity' => OutputInterface::VERBOSITY_VERBOSE]);
133
134
        $output = $this->commandTester->getDisplay();
135
        $this->assertStringContainsString($message, $output);
136
        if (empty($address)) {
137
            $this->assertStringNotContainsString('Processing IP', $output);
138
        } else {
139
            $this->assertStringContainsString('Processing IP', $output);
140
        }
141
        $locateVisits->shouldHaveBeenCalledOnce();
142
        $resolveIpLocation->shouldNotHaveBeenCalled();
143
    }
144
145
    public function provideIgnoredAddresses(): iterable
146
    {
147
        yield 'with empty address' => ['', 'Ignored visit with no IP address'];
148
        yield 'with null address' => [null, 'Ignored visit with no IP address'];
149
        yield 'with localhost address' => [IpAddress::LOCALHOST, 'Ignored localhost address'];
150
    }
151
152
    /** @test */
153
    public function errorWhileLocatingIpIsDisplayed(): void
154
    {
155
        $visit = new Visit(new ShortUrl(''), new Visitor('', '', '1.2.3.4'));
156
        $location = new VisitLocation(Location::emptyInstance());
157
158
        $locateVisits = $this->visitService->locateUnlocatedVisits(Argument::cetera())->will(
159
            $this->invokeHelperMethods($visit, $location),
160
        );
161
        $resolveIpLocation = $this->ipResolver->resolveIpLocation(Argument::any())->willThrow(WrongIpException::class);
162
163
        $this->commandTester->execute([], ['verbosity' => OutputInterface::VERBOSITY_VERBOSE]);
164
165
        $output = $this->commandTester->getDisplay();
166
167
        $this->assertStringContainsString('An error occurred while locating IP. Skipped', $output);
168
        $locateVisits->shouldHaveBeenCalledOnce();
169
        $resolveIpLocation->shouldHaveBeenCalledOnce();
170
    }
171
172
    private function invokeHelperMethods(Visit $visit, VisitLocation $location): callable
173
    {
174
        return function (array $args) use ($visit, $location): void {
175
            /** @var VisitGeolocationHelperInterface $helper */
176
            [$helper] = $args;
177
178
            $helper->geolocateVisit($visit);
179
            $helper->onVisitLocated($location, $visit);
180
        };
181
    }
182
183
    /** @test */
184
    public function noActionIsPerformedIfLockIsAcquired(): void
185
    {
186
        $this->lock->acquire(false)->willReturn(false);
187
188
        $locateVisits = $this->visitService->locateUnlocatedVisits(Argument::cetera())->will(function (): void {
189
        });
190
        $resolveIpLocation = $this->ipResolver->resolveIpLocation(Argument::any())->willReturn([]);
191
192
        $this->commandTester->execute([], ['verbosity' => OutputInterface::VERBOSITY_VERBOSE]);
193
        $output = $this->commandTester->getDisplay();
194
195
        $this->assertStringContainsString(
196
            sprintf('Command "%s" is already in progress. Skipping.', LocateVisitsCommand::NAME),
197
            $output,
198
        );
199
        $locateVisits->shouldNotHaveBeenCalled();
200
        $resolveIpLocation->shouldNotHaveBeenCalled();
201
    }
202
203
    /**
204
     * @test
205
     * @dataProvider provideParams
206
     */
207
    public function showsProperMessageWhenGeoLiteUpdateFails(bool $olderDbExists, string $expectedMessage): void
208
    {
209
        $locateVisits = $this->visitService->locateUnlocatedVisits(Argument::cetera())->will(function (): void {
210
        });
211
        $checkDbUpdate = $this->dbUpdater->checkDbUpdate(Argument::cetera())->will(
212
            function (array $args) use ($olderDbExists): void {
213
                [$mustBeUpdated, $handleProgress] = $args;
214
215
                $mustBeUpdated($olderDbExists);
216
                $handleProgress(100, 50);
217
218
                throw GeolocationDbUpdateFailedException::create($olderDbExists);
219
            },
220
        );
221
222
        $this->commandTester->execute([]);
223
        $output = $this->commandTester->getDisplay();
224
225
        $this->assertStringContainsString(
226
            sprintf('%s GeoLite2 database...', $olderDbExists ? 'Updating' : 'Downloading'),
227
            $output,
228
        );
229
        $this->assertStringContainsString($expectedMessage, $output);
230
        $locateVisits->shouldHaveBeenCalledTimes((int) $olderDbExists);
231
        $checkDbUpdate->shouldHaveBeenCalledOnce();
232
    }
233
234
    public function provideParams(): iterable
235
    {
236
        yield [true, '[Warning] GeoLite2 database update failed. Proceeding with old version.'];
237
        yield [false, 'GeoLite2 database download failed. It is not possible to locate visits.'];
238
    }
239
240
    /** @test */
241
    public function providingAllFlagOnItsOwnDisplaysNotice(): void
242
    {
243
        $this->commandTester->execute(['--all' => true]);
244
        $output = $this->commandTester->getDisplay();
245
246
        $this->assertStringContainsString('The --all flag has no effect on its own', $output);
247
    }
248
249
    /**
250
     * @test
251
     * @dataProvider provideAbortInputs
252
     */
253
    public function processingAllCancelsCommandIfUserDoesNotActivelyAgreeToConfirmation(array $inputs): void
254
    {
255
        $this->expectException(RuntimeException::class);
256
        $this->expectExceptionMessage('Execution aborted');
257
258
        $this->commandTester->setInputs($inputs);
259
        $this->commandTester->execute(['--all' => true, '--retry' => true]);
260
    }
261
262
    public function provideAbortInputs(): iterable
263
    {
264
        yield 'n' => [['n']];
265
        yield 'no' => [['no']];
266
        yield 'default' => [[PHP_EOL]];
267
    }
268
}
269