<file_sep><?php

namespace App\DataTransformer;

use App\Entity\Recipe;

/**
 * Class RecipeTransformer
 *
 * @package App\DataTransformer
 */
class RecipeTransformer
{
    /**
     * @param Recipe $recipe
     *
     * @return array
     */
    public static function transform(Recipe $recipe)
    {
        return [
            'title'       => $recipe->getTitle(),
            'ingredients' => $recipe->getIngredients()
        ];
    }

    /**
     * @param Recipe[] $recipes
     *
     * @return array
     */
    public static function transformList(array $recipes)
    {
        return array_map(
            function (Recipe $recipe) {
                return self::transform($recipe);
            },
            $recipes
        );
    }
}
<file_sep><?php

require_once __DIR__.'/vendor/autoload.php';

$app = new Silex\Application();
require_once __DIR__.'/config/dev.php';
require_once __DIR__.'/src/app.php';
<file_sep># Tech task

This is Silex application created for test proposed.

## Usage instructions

### Configure environment with Docker

As a precondition you have to install [Docker](https://docs.docker.com/engine/installation/) and [Docker Compose](https://docs.docker.com/compose/install/).

Download docker configuration:

`git clone <EMAIL>:dpcat237/pasishnyi-denys-docker-techtask-20170209.git`

Enter to `pasishnyi-denys-docker-techtask-20170209` and create `web` folder.
Inside of new folder download current repository `git clone <EMAIL>:dpcat237/pasishnyi-denys-techtask-20170209.git`.

```
mkdir pasishnyi-denys-docker-techtask-20170209/web && cd pasishnyi-denys-docker-techtask-20170209/web
git clone <EMAIL>:dpcat237/pasishnyi-denys-techtask-20170209.git
```

From the root of the docker configuration launch the container. This process will take some minutes to download configure the environment.

```
docker-compose up
```

Access to a container and download the required dependencies. For this, copy container ID running `docker ps`.

```
docker exec -it [container ID] bash
su www-data
cd pasishnyi-denys-techtask-20170209/ && composer install
```

Copy container's IP (you can use `ifconfig`) and add "`[copied IP] fridge.local`" at the end of the local hosts configuration `/etc/hosts`.

You can open in your browser: `http://fridge.local/api/v1/lunch`.

### Additional information

Application data is located in `pasishnyi-denys-docker-techtask-20170209/web/pasishnyi-denys-techtask-20170209/data/`.

### Steps to send a tweet

Add twitter credentials in `pasishnyi-denys-docker-techtask-20170209/web/pasishnyi-denys-techtask-20170209/config/prod.php`.

Example of the request:

```
POST: http://fridge.local/api/v1/twitter/tweet
Body: {"text":"Hotdog"} 
```
<file_sep><?php

namespace App\Loader;

use App\Repository\IngredientRepository;
use App\Repository\RecipeRepository;
use App\Service\FridgeService;
use App\Service\TwitterService;
use Silex\Application;

/**
 * Class ServicesLoader
 *
 * @package App\Loader
 */
class ServicesLoader
{
    /** @var Application */
    protected $app;

    /**
     * ServiceLoader constructor.
     *
     * @param Application $app
     */
    public function __construct(Application $app)
    {
        $this->app = $app;
    }

    public function bindServicesIntoContainer()
    {
        /** Repositories **/
        $this->app['ingredient.repository'] = function () {
            return new IngredientRepository($this->app["data.folder"]);
        };
        $this->app['recipe.repository'] = function () {
            return new RecipeRepository($this->app["data.folder"]);
        };

        /** Services **/
        $this->app['fridge.service'] = function () {
            return new FridgeService($this->app['ingredient.repository'], $this->app['recipe.repository']);
        };
        $this->app['twitter.service'] = function () {
            return new TwitterService($this->app['twitter.config']);
        };
    }
}
<file_sep><?php

namespace App\Controller;

use App\DataTransformer\RecipeTransformer;
use App\Service\FridgeService;
use Symfony\Component\HttpFoundation\JsonResponse;

/**
 * Class RecipeController
 *
 * @package App\Controller
 */
class RecipeController
{
    /** @var FridgeService */
    protected $fridgeService;

    /**
     * RecipeController constructor.
     *
     * @param FridgeService $fridgeService
     */
    public function __construct(FridgeService $fridgeService)
    {
        $this->fridgeService = $fridgeService;
    }

    /**
     * List of recipes
     *
     * @return JsonResponse
     */
    public function listAction()
    {
        $recipes = $this->fridgeService->getValidRecipes();

        return new JsonResponse(RecipeTransformer::transformList($recipes));
    }
}
<file_sep><?php

//API
$app['api.version'] = "v1";
$app['api.endpoint'] = "/api";

$app['data.folder'] = "data";

//Twitter
$app['twitter.config'] = [
    'consumer_key' => '',
    'consumer_secret' => '',
    'token' => '',
    'token_secret' => '',
];
<file_sep><?php

namespace App\DataTransformer;

use App\Entity\Recipe;
use Mockery;
use Mockery\Mock;
use PHPUnit_Framework_TestCase;

/**
 * Class RecipeTransformerTest
 *
 * @package App\DataTransformer
 */
class RecipeTransformerTest extends PHPUnit_Framework_TestCase
{
    /**
     * Tests single transform
     *
     * @param string $title
     * @param array  $ingredients
     * @param array  $expects
     *
     * @dataProvider singleTransformDataProvider
     */
    public function testSingleTransform($title, $ingredients, $expects)
    {
        /** @var Recipe|Mock $recipe */
        $recipe = Mockery::mock(Recipe::class);
        $recipe->shouldReceive('getTitle')->andReturn($title);
        $recipe->shouldReceive('getIngredients')->andReturn($ingredients);

        $this->assertEquals($expects, RecipeTransformer::transform($recipe));
    }

    /**
     * Data provider for testSingleTransform
     *
     * @return array
     */
    public function singleTransformDataProvider()
    {
        return [
            ['Salad', ['Lettuce', 'Beetroot'], ['title' => 'Salad', 'ingredients' => ['Lettuce', 'Beetroot']]],
            ['Fry-up', ['Mushrooms', 'Bacon'], ['title' => 'Fry-up', 'ingredients' => ['Mushrooms', 'Bacon']]]
        ];
    }
}
<file_sep><?php

namespace App\Service;

use Abraham\TwitterOAuth\TwitterOAuth;

/**
 * Class TwitterService
 *
 * @package App\Service
 */
class TwitterService
{
    /** @var TwitterOAuth */
    private $twitterClient;

    /**
     * TwitterService constructor.
     *
     * @param array $twitterConfiguration
     */
    public function __construct($twitterConfiguration)
    {
        if (!$twitterConfiguration['consumer_key'] || !$twitterConfiguration['consumer_secret']
            || !$twitterConfiguration['token'] || !$twitterConfiguration['token_secret']
        ) {
            return;
        }

        $this->twitterClient = new TwitterOAuth(
            $twitterConfiguration['consumer_key'],
            $twitterConfiguration['consumer_secret'],
            $twitterConfiguration['token'],
            $twitterConfiguration['token_secret']
        );
    }

    /**
     * @param string $text
     */
    public function sendTweet($text)
    {
        $text = (strlen($text) <= 140) ? $text : substr($text, 0, 140);
        $this->twitterClient->post('statuses/update', ['status' => $text]);
    }
}
<file_sep><?php

namespace App\Controller;

use Silex\Application;
use Silex\WebTestCase;

/**
 * Class RecipeControllerTest
 *
 * @package App\Controller
 */
class RecipeControllerTest extends WebTestCase
{
    public function testLunchEndpointWorks()
    {
        $client = $this->createClient();
        $client->followRedirects(true);
        $client->request('GET', '/api/v1/lunch');

        $this->assertTrue($client->getResponse()->isOk());
    }

    public function createApplication()
    {
        $basePath = __DIR__.'/../../../../';
        require_once $basePath.'vendor/autoload.php';

        $app = new Application();
        require $basePath.'config/dev.php';
        require $basePath.'src/app.php';

        return $app;
    }
}
<file_sep><?php

namespace App\Repository;

/**
 * Class BaseRepository
 *
 * @package App\Repository
 */
abstract class BaseRepository
{
    /** @var string */
    protected $basePath;

    public function __construct($dataFolder)
    {
        $this->basePath = __DIR__.'/../../../'.$dataFolder;
    }

    /**
     * @return array
     */
    protected function getData()
    {
        if (!file_exists($this->getDataPath())) {
            return [];
        }

        return json_decode(file_get_contents($this->getDataPath()), true)[$this->getDataName()];
    }

    /**
     * @return string
     */
    private function getDataPath()
    {
        return $this->basePath.'/'.$this->getDataName().'.json';
    }

    /**
     * @return string
     */
    abstract protected function getDataName();
}
<file_sep><?php

namespace App\Entity;

/**
 * Class Recipe
 *
 * @package App\Entity
 */
class Recipe
{
    /** @var string */
    private $title;
    /** @var array */
    private $ingredients;

    /**
     * Recipe constructor.
     *
     * @param array $data
     */
    public function __construct($data)
    {
        $this
            ->setTitle($data['title'])
            ->setIngredients($data['ingredients'])
        ;
    }

    /**
     * @return string
     */
    public function getTitle()
    {
        return $this->title;
    }

    /**
     * @param string $title
     *
     * @return $this
     */
    public function setTitle($title)
    {
        $this->title = $title;

        return $this;
    }

    /**
     * @return array
     */
    public function getIngredients()
    {
        return $this->ingredients;
    }

    /**
     * @param array $ingredients
     *
     * @return $this
     */
    public function setIngredients($ingredients)
    {
        $this->ingredients = $ingredients;

        return $this;
    }
}
<file_sep><?php

namespace App\Controller;

use App\Service\TwitterService;
use InvalidArgumentException;
use LogicException;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\HttpException;

/**
 * Class TwitterController
 *
 * @package App\Controller
 */
class TwitterController
{
    /** @var TwitterService */
    protected $twitterService;

    /**
     * TwitterController constructor.
     *
     * @param TwitterService $twitterService
     */
    public function __construct(TwitterService $twitterService)
    {
        $this->twitterService = $twitterService;
    }

    /**
     * @param Request $request
     *
     * @return Response
     * @throws HttpException
     * @throws LogicException
     * @throws InvalidArgumentException
     */
    public function tweetAction(Request $request)
    {
        $json = json_decode($request->getContent(), true);
        if (!isset($json['text']) || !$json['text']) {
            throw new HttpException(Response::HTTP_PRECONDITION_FAILED);
        }

        $this->twitterService->sendTweet($json['text']);

        return new Response('', Response::HTTP_NO_CONTENT);
    }
}
<file_sep><?php

namespace App\Service;

use App\Entity\Ingredient;
use App\Entity\Recipe;
use App\Repository\IngredientRepository;
use App\Repository\RecipeRepository;
use DateInterval;
use DateTime;
use Mockery;
use Mockery\Mock;
use PHPUnit_Framework_TestCase;

/**
 * Class FridgeServiceTest
 *
 * @package App\Service
 */
class FridgeServiceTest extends PHPUnit_Framework_TestCase
{
    /**
     * Tests different data to check valid recipes
     *
     * @param array $ingredients
     * @param array $recipes
     * @param int   $expectsCount
     *
     * @dataProvider testGetValidRecipesCountProvider
     */
    public function testGetValidRecipesCount($ingredients, $recipes, $expectsCount)
    {
        $fridgeService = $this->getFridgeService($ingredients, $recipes);
        $this->assertCount($expectsCount, $fridgeService->getValidRecipes());
    }

    /**
     * Data provider for testGetValidRecipesCount
     *
     * @return array
     */
    public function testGetValidRecipesCountProvider()
    {
        $bestBefore = new DateTime();
        $bestBefore->add(DateInterval::createfromdatestring('+1 day'));
        $useBy = new DateTime();
        $useBy->add(DateInterval::createfromdatestring('+4 day'));
        $bestBeforePast = new DateTime();
        $bestBeforePast->add(DateInterval::createfromdatestring('-4 day'));
        $useByPast = new DateTime();
        $useByPast->add(DateInterval::createfromdatestring('-2 day'));

        $return[] = [[], [], 0];
        $return[] = [
            [
                new Ingredient(['title' => 'Ham', 'best-before' => $bestBefore->format('Y-m-d'), 'use-by' => $useBy->format('Y-m-d')]),
                new Ingredient(['title' => 'Cheese', 'best-before' => $bestBefore->format('Y-m-d'), 'use-by' => $useBy->format('Y-m-d')]),
                new Ingredient(['title' => 'Bread', 'best-before' => $bestBefore->format('Y-m-d'), 'use-by' => $useBy->format('Y-m-d')]),
                new Ingredient(['title' => 'Lettuce', 'best-before' => $bestBefore->format('Y-m-d'), 'use-by' => $useBy->format('Y-m-d')]),
                new Ingredient(['title' => 'Tomato', 'best-before' => $bestBefore->format('Y-m-d'), 'use-by' => $useBy->format('Y-m-d')]),
                new Ingredient(['title' => 'Beetroot', 'best-before' => $bestBefore->format('Y-m-d'), 'use-by' => $useBy->format('Y-m-d')]),
                new Ingredient(['title' => 'Tuna', 'best-before' => $bestBeforePast->format('Y-m-d'), 'use-by' => $useByPast->format('Y-m-d')]),
            ],
            [
                new Recipe(['title' => 'Ham and Cheese Toastie', 'ingredients' => ['Ham', 'Cheese', 'Bread']]),
                new Recipe(['title' => 'Salad with tuna', 'ingredients' => ['Lettuce', 'Tomato', 'Beetroot', 'Tuna']]),
                new Recipe(['title' => 'Salad', 'ingredients' => ['Lettuce', 'Tomato', 'Beetroot']]),
                new Recipe(['title' => 'Fry-up', 'ingredients' => ['Bacon', 'Eggs', 'Mushrooms']]),
            ],
            2
        ];

        return $return;
    }

    /**
     * Test order of recipes with products with past best-before date
     */
    public function testGetValidRecipesOrder()
    {
        $bestBefore = new DateTime();
        $intervalOne = DateInterval::createfromdatestring('+1 day');
        $bestBefore->add($intervalOne);
        $useBy = new DateTime();
        $intervalTwo = DateInterval::createfromdatestring('+4 day');
        $useBy->add($intervalTwo);
        $bestBeforePast = new DateTime();
        $bestBeforePast->add(DateInterval::createfromdatestring('-2 day'));

        $ingredients = [
            new Ingredient(['title' => 'Ham', 'best-before' => $bestBefore->format('Y-m-d'), 'use-by' => $useBy->format('Y-m-d')]),
            new Ingredient(['title' => 'Cheese', 'best-before' => $bestBeforePast->format('Y-m-d'), 'use-by' => $useBy->format('Y-m-d')]),
            new Ingredient(['title' => 'Bread', 'best-before' => $bestBefore->format('Y-m-d'), 'use-by' => $useBy->format('Y-m-d')]),
            new Ingredient(['title' => 'Lettuce', 'best-before' => $bestBefore->format('Y-m-d'), 'use-by' => $useBy->format('Y-m-d')]),
            new Ingredient(['title' => 'Tomato', 'best-before' => $bestBefore->format('Y-m-d'), 'use-by' => $useBy->format('Y-m-d')]),
            new Ingredient(['title' => 'Beetroot', 'best-before' => $bestBefore->format('Y-m-d'), 'use-by' => $useBy->format('Y-m-d')]),
        ];
        $recipes = [
            new Recipe(['title' => 'Ham and Cheese Toastie', 'ingredients' => ['Ham', 'Cheese', 'Bread']]),
            new Recipe(['title' => 'Salad', 'ingredients' => ['Lettuce', 'Tomato', 'Beetroot']]),
        ];

        $fridgeService = $this->getFridgeService($ingredients, $recipes);
        $this->assertEquals('Salad', end($recipes)->getTitle());
        $validRecipes = $fridgeService->getValidRecipes();
        $this->assertCount(2, $validRecipes);
        $this->assertEquals('Ham and Cheese Toastie', end($validRecipes)->getTitle());
    }

    /**
     * @param array $ingredients
     * @param array $recipes
     *
     * @return FridgeService
     */
    private function getFridgeService($ingredients, $recipes)
    {
        /** @var IngredientRepository|Mock $ingredientRepository */
        $ingredientRepository = Mockery::mock(IngredientRepository::class);
        $ingredientRepository->shouldReceive('getAll')->andReturn($ingredients);
        /** @var RecipeRepository|Mock $recipeRepository */
        $recipeRepository = Mockery::mock(RecipeRepository::class);
        $recipeRepository->shouldReceive('getAll')->andReturn($recipes);

        return new FridgeService($ingredientRepository, $recipeRepository);
    }
}
<file_sep><?php

namespace App\Loader;

use App\Controller\RecipeController;
use App\Controller\TwitterController;
use Silex\Application;
use Silex\ControllerCollection;

/**
 * Class RoutesLoader
 *
 * @package App
 */
class RoutesLoader
{
    /** @var Application */
    private $app;

    /**
     * RoutesLoader constructor.
     *
     * @param Application $app
     */
    public function __construct(Application $app)
    {
        $this->app = $app;
        $this->instantiateControllers();
    }

    public function bindRoutesToControllers()
    {
        /** @var ControllerCollection $api */
        $api = $this->app["controllers_factory"];
        $api->get('/lunch', "recipe.controller:listAction");
        $api->post('/twitter/tweet', "twitter.controller:tweetAction");
        $this->app->mount($this->app["api.endpoint"].'/'.$this->app["api.version"], $api);
    }

    private function instantiateControllers()
    {
        $this->app['recipe.controller'] = function () {
            return new RecipeController($this->app['fridge.service']);
        };
        $this->app['twitter.controller'] = function () {
            return new TwitterController($this->app['twitter.service']);
        };
    }
}
<file_sep><?php

use App\Loader\RoutesLoader;
use App\Loader\ServicesLoader;
use Silex\Provider\AssetServiceProvider;
use Silex\Provider\ServiceControllerServiceProvider;
use Silex\Provider\HttpFragmentServiceProvider;

$app->register(new ServiceControllerServiceProvider());
$app->register(new AssetServiceProvider());
$app->register(new HttpFragmentServiceProvider());

//load services
$servicesLoader = new ServicesLoader($app);
$servicesLoader->bindServicesIntoContainer();

//load routes
$routesLoader = new RoutesLoader($app);
$routesLoader->bindRoutesToControllers();

return $app;
<file_sep><?php

namespace App\Entity;

use DateTime;

/**
 * Class Ingredient
 *
 * @package App\Entity
 */
class Ingredient
{
    /** @var string */
    private $title;
    /** @var DateTime */
    private $bestBefore;
    /** @var DateTime */
    private $useBy;

    /**
     * Ingredient constructor.
     *
     * @param array $data
     */
    public function __construct($data)
    {
        $this
            ->setTitle($data['title'])
            ->setBestBefore(new DateTime($data['best-before']))
            ->setUseBy(new DateTime($data['use-by']))
        ;
    }

    /**
     * @return string
     */
    public function getTitle()
    {
        return $this->title;
    }

    /**
     * @param string $title
     *
     * @return $this
     */
    public function setTitle($title)
    {
        $this->title = $title;

        return $this;
    }

    /**
     * @return DateTime
     */
    public function getBestBefore()
    {
        return $this->bestBefore;
    }

    /**
     * @param DateTime $bestBefore
     *
     * @return $this
     */
    public function setBestBefore(DateTime $bestBefore)
    {
        $this->bestBefore = $bestBefore;

        return $this;
    }

    /**
     * @return DateTime
     */
    public function getUseBy()
    {
        return $this->useBy;
    }

    /**
     * @param DateTime $useBy
     *
     * @return $this
     */
    public function setUseBy(DateTime $useBy)
    {
        $this->useBy = $useBy;

        return $this;
    }
}
<file_sep><?php

namespace App\Repository;

use App\Entity\Ingredient;

/**
 * Class IngredientRepository
 *
 * @package App\Repository
 */
class IngredientRepository extends BaseRepository
{
    const DATA_NAME = 'ingredients';

    /**
     * @return Ingredient[]
     */
    public function getAll()
    {
        $collection = [];
        $dataCollection = $this->getData();
        if (!$dataCollection) {
            return $collection;
        }

        foreach ($dataCollection as $itemData) {
            $collection[] = new Ingredient($itemData);
        }

        return $collection;
    }

    /**
     * @inheritdoc
     */
    protected function getDataName()
    {
        return self::DATA_NAME;
    }
}
<file_sep><?php

namespace App\Service;

use App\Entity\Ingredient;
use App\Entity\Recipe;
use App\Repository\IngredientRepository;
use App\Repository\RecipeRepository;
use DateTime;

/**
 * Class FridgeService
 *
 * @package App\Service
 */
class FridgeService
{
    const STATE_GOOD = 1;
    const STATE_PAST_BY = 2;
    const STATE_EXPIRED = 3;

    /** @var IngredientRepository */
    private $ingredientRepository;
    /** @var RecipeRepository */
    private $recipeRepository;

    /**
     * FridgeService constructor.
     *
     * @param IngredientRepository $ingredientRepository
     * @param RecipeRepository     $recipeRepository
     */
    public function __construct(IngredientRepository $ingredientRepository, RecipeRepository $recipeRepository)
    {
        $this->ingredientRepository = $ingredientRepository;
        $this->recipeRepository = $recipeRepository;
    }

    /**
     * Returns recipes if fridge has not expired required products,
     * sorting recipes with products which arrived at "best-before" date to the bottom
     *
     * @return Recipe[]
     */
    public function getValidRecipes()
    {
        $ingredients = $this->ingredientRepository->getAll();
        $recipes = $this->recipeRepository->getAll();
        if (!$ingredients || !$recipes) {
            return [];
        }

        return $this->filterRecipesByIngredientsState($recipes, $ingredients);
    }

    /**
     * @param Recipe $recipe
     * @param array  $goodIngredients
     * @param array  $pastIngredients
     *
     * @return int
     */
    private function checkRecipeIngredients(Recipe $recipe, $goodIngredients, $pastIngredients)
    {
        $pastBy = false;
        foreach ($recipe->getIngredients() as $ingredientTitle) {
            if (in_array($ingredientTitle, $pastIngredients)) {
                $pastBy = true;
                continue;
            }
            if (in_array($ingredientTitle, $goodIngredients)) {
                continue;
            }

            return self::STATE_EXPIRED;
        }

        return ($pastBy) ? self::STATE_PAST_BY : self::STATE_GOOD;
    }

    /**
     * @param Recipe[]     $recipes
     * @param Ingredient[] $ingredients
     *
     * @return Recipe[]
     */
    private function filterRecipesByIngredientsState($recipes, $ingredients)
    {
        $goodRecipies = [];
        $pastByRecipies = [];
        list($goodIngredients, $pastIngredients) = $this->sortIngredientsByState($ingredients);
        foreach ($recipes as $recipe) {
            switch ($this->checkRecipeIngredients($recipe, $goodIngredients, $pastIngredients)) {
                case self::STATE_GOOD:
                    $goodRecipies[] = $recipe;
                    break;
                case self::STATE_PAST_BY:
                    $pastByRecipies[] = $recipe;
                    break;
            }
        }

        return array_merge($goodRecipies, $pastByRecipies);
    }

    /**
     * @param Ingredient[] $ingredients
     *
     * @return array
     */
    private function sortIngredientsByState($ingredients)
    {
        $goodIngredients = [];
        $pastIngredients = [];
        $nowDate = new DateTime();
        foreach ($ingredients as $ingredient) {
            if ($nowDate < $ingredient->getBestBefore()) {
                $goodIngredients[] = $ingredient->getTitle();
            } elseif ($nowDate < $ingredient->getUseBy()) {
                $pastIngredients[] = $ingredient->getTitle();
            }
        }

        return [$goodIngredients, $pastIngredients];
    }
}
