LocateVisitsCommandTest   A
last analyzed

Complexity

Total Complexity 16

Size/Duplication

Total Lines 235
Duplicated Lines 0 %

Importance

Changes 2
Bugs 0 Features 0
Metric Value
wmc 16
eloc 116
dl 0
loc 235
rs 10
c 2
b 0
f 0

13 Methods

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