Skip to content

Commit

Permalink
Add charge title autocomplete endpoint (#146)
Browse files Browse the repository at this point in the history
  • Loading branch information
vokomarov authored Jan 22, 2024
2 parents b7f9558 + b5ad925 commit c45ec9a
Show file tree
Hide file tree
Showing 8 changed files with 238 additions and 2 deletions.
33 changes: 33 additions & 0 deletions app/src/Controller/Charges/SuggestionsController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?php

declare(strict_types=1);

namespace App\Controller\Charges;

use App\Controller\AuthAwareController;
use App\Repository\ChargeRepository;
use App\View\ChargeTitlesView;
use Psr\Http\Message\ResponseInterface;
use Spiral\Auth\AuthScope;
use Spiral\Http\ResponseWrapper;
use Spiral\Router\Annotation\Route;

final class SuggestionsController extends AuthAwareController
{
public function __construct(
AuthScope $auth,
protected ResponseWrapper $response,
private readonly ChargeRepository $chargeRepository,
private readonly ChargeTitlesView $chargeTitlesView,
) {
parent::__construct($auth);
}

#[Route(route: '/charges/title/suggestions/<query>', name: 'charges.title.suggestions', methods: 'GET', group: 'auth')]
public function suggestions(string $query = ''): ResponseInterface
{
return $this->chargeTitlesView->json(
$this->chargeRepository->searchTitle((int) $this->user->id, urldecode($query))
);
}
}
2 changes: 1 addition & 1 deletion app/src/Controller/Wallets/Charges/ChargesController.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
use Spiral\Router\Annotation\Route;
use Spiral\Translator\Traits\TranslatorTrait;

class ChargesController extends Controller
final class ChargesController extends Controller
{
use TranslatorTrait;

Expand Down
2 changes: 1 addition & 1 deletion app/src/Controller/Wallets/Charges/TagsController.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
use Spiral\Http\ResponseWrapper;
use Spiral\Router\Annotation\Route;

class TagsController extends Controller
final class TagsController extends Controller
{
public function __construct(
AuthScope $auth,
Expand Down
24 changes: 24 additions & 0 deletions app/src/Repository/ChargeRepository.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@
namespace App\Repository;

use App\Service\Filter\Filter;
use Cycle\Database\Injection\Expression;
use Cycle\Database\Injection\Fragment;
use Cycle\Database\Query\SelectQuery;
use Cycle\ORM\Select\AbstractLoader;
use Cycle\ORM\Select\Repository;
use Cycle\Database\Injection\Parameter;
Expand Down Expand Up @@ -230,4 +233,25 @@ public function countAllByUserPKByType(int $userID, string $type = null): int

return $query->count();
}

public function searchTitle(int $userID, string $query = '', int $limit = 10): array
{
$builder = $this->select()->getBuilder();
$titleCol = $builder->resolve('title');
$q = $builder->getQuery();

return $q?->columns([$titleCol, new Expression("count({$titleCol}) as count")])
->from('charges charge')
->rightJoin('user_wallets')
->on($builder->resolve('wallet_id'), '=', 'user_wallets.wallet_id')
->on('user_wallets.user_id', '=', new Parameter($userID))
->where($titleCol, 'like', new Fragment("concat('%', ?, '%')", $query, $query, $query, $query))
->groupBy($titleCol)
->orderBy(new Fragment("{$titleCol} like concat(?, '%')"), SelectQuery::SORT_DESC)
->orderBy(new Fragment("ifnull(nullif(instr({$titleCol}, concat(' ', ?)), 0), 99999)"))
->orderBy(new Expression("count({$titleCol})"), SelectQuery::SORT_DESC)
->orderBy(new Fragment("ifnull(nullif(instr({$titleCol}, ?), 0), 99999)"))
->limit($limit)
->fetchAll();
}
}
29 changes: 29 additions & 0 deletions app/src/View/ChargeTitleView.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<?php

declare(strict_types=1);

namespace App\View;

use Spiral\Core\Container\SingletonInterface;
use Spiral\Http\ResponseWrapper;

class ChargeTitleView implements SingletonInterface
{
public function __construct(
protected ResponseWrapper $response,
) {
}

public function map(?array $chargeTitle): ?array
{
if ($chargeTitle === null || ($chargeTitle['title'] ?? null) === null) {
return null;
}

return [
'type' => 'chargeTitle',
'title' => $chargeTitle['title'] ?? null,
'count' => $chargeTitle['count'] ?? 0,
];
}
}
30 changes: 30 additions & 0 deletions app/src/View/ChargeTitlesView.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<?php

declare(strict_types=1);

namespace App\View;

use Psr\Http\Message\ResponseInterface;
use Spiral\Core\Container\SingletonInterface;
use Spiral\Http\ResponseWrapper;

class ChargeTitlesView implements SingletonInterface
{
public function __construct(
protected ResponseWrapper $response,
protected ChargeTitleView $chargeTitleView,
) {
}

public function json(array $titles): ResponseInterface
{
return $this->response->json([
'data' => $this->map($titles),
]);
}

public function map(array $titles): array
{
return array_map([$this->chargeTitleView, 'map'], $titles);
}
}
102 changes: 102 additions & 0 deletions tests/Feature/Controller/Charges/SuggestionsControllerTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
<?php

declare(strict_types=1);

namespace Feature\Controller\Charges;

use Tests\DatabaseTransaction;
use Tests\Factories\ChargeFactory;
use Tests\Factories\TagFactory;
use Tests\Factories\UserFactory;
use Tests\Factories\WalletFactory;
use Tests\Fixtures;
use Tests\TestCase;

class SuggestionsControllerTest extends TestCase implements DatabaseTransaction
{
protected UserFactory $userFactory;

protected WalletFactory $walletFactory;

protected ChargeFactory $chargeFactory;

protected TagFactory $tagFactory;

protected function setUp(): void
{
parent::setUp();

$this->userFactory = $this->getContainer()->get(UserFactory::class);
$this->walletFactory = $this->getContainer()->get(WalletFactory::class);
$this->chargeFactory = $this->getContainer()->get(ChargeFactory::class);
}

public function testSuggestionsRequireAuth()
{
$query = Fixtures::string();

$response = $this->get("/charges/title/suggestions/{$query}");

$response->assertUnauthorized();
}

public function testSuggestionsReturnSuggestedTitles()
{
$auth = $this->makeAuth($user = $this->userFactory->create());
$sharedUser = $this->userFactory->create();
$otherUser = $this->userFactory->create();

$otherWallet = $this->walletFactory->forUser($otherUser)->create();

$wallet = WalletFactory::make();
$wallet->users->add($sharedUser);
$wallet = $this->walletFactory->forUser($user)->create($wallet);

$chargeUser1 = ChargeFactory::make();
$chargeUser2 = ChargeFactory::make();
$chargeUser3 = ChargeFactory::make();
$chargeUser4 = ChargeFactory::make();
$chargeUser5 = ChargeFactory::make();
$chargeUser6 = ChargeFactory::make();
$chargeSharedUser = ChargeFactory::make();
$chargeOtherUser = ChargeFactory::make();

$chargeUser1->title = 'Charge title 1';
$chargeUser2->title = 'Charge title 2';
$chargeUser3->title = 'Other charge title 3';
$chargeUser4->title = 'Other charge title 4';
$chargeUser5->title = 'Other charge title 5';
$chargeUser6->title = 'Other item outside 5';
$chargeSharedUser->title = 'Charge title shared 1';
$chargeOtherUser->title = 'Charge title other 1';
$query = 'charge';

$this->chargeFactory->forUser($user)->forWallet($wallet)->create($chargeUser1);
$this->chargeFactory->forUser($user)->forWallet($wallet)->create($chargeUser2);
$this->chargeFactory->forUser($user)->forWallet($wallet)->create($chargeUser3);
$this->chargeFactory->forUser($user)->forWallet($wallet)->create($chargeUser4);
$this->chargeFactory->forUser($user)->forWallet($wallet)->create($chargeUser5);
$this->chargeFactory->forUser($user)->forWallet($wallet)->create($chargeUser6);
$this->chargeFactory->forUser($sharedUser)->forWallet($wallet)->create($chargeSharedUser);
$this->chargeFactory->forUser($otherUser)->forWallet($otherWallet)->create($chargeOtherUser);

$response = $this->withAuth($auth)->get("/charges/title/suggestions/{$query}");

$response->assertOk();

$body = $this->getJsonResponseBody($response);

$this->assertIsArray($body);
$this->assertArrayHasKey('data', $body);

$this->assertCount(6, $body['data']);

foreach ([$chargeSharedUser, $chargeUser1, $chargeUser2, $chargeUser3, $chargeUser5, $chargeUser4] as $charge) {
$this->assertArrayContains($charge->title, $body, 'data.*.title');
}

foreach ([$chargeUser6, $chargeOtherUser] as $charge) {
$this->assertArrayNotContains($charge->title, $body, 'data.*.title');
}
}
}
18 changes: 18 additions & 0 deletions tests/Feature/View/ChargeTitleViewTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<?php

declare(strict_types=1);

namespace Feature\View;

use App\View\ChargeTitleView;
use Tests\TestCase;

class ChargeTitleViewTest extends TestCase
{
public function testMapEmpty(): void
{
$view = $this->getContainer()->get(ChargeTitleView::class);

$this->assertNull($view->map(null));
}
}

0 comments on commit c45ec9a

Please sign in to comment.