Compare commits

..

28 commits

Author SHA1 Message Date
Alexey Skobkin 49d4097a47 DTO\Api\Post phpDoc cleaning. 2019-02-28 03:46:32 +03:00
Alexey Skobkin 3f31705536 WS processing IdentityMap cleaning. 2019-02-28 03:44:59 +03:00
Alexey Skobkin 6b5a60b2a5 WS 'comment' processing. WIP. 2019-02-28 03:43:40 +03:00
Alexey Skobkin b0a6fbfb7f Comment DB unique constraint (post_id, number). New migration. 2019-02-28 02:44:51 +03:00
Alexey Skobkin a12bf9d9a2 WS 'post' processing draft. 2019-02-28 02:26:01 +03:00
Alexey Skobkin dbc1c060f8 Fixing WS message processing conditions in ProcessWebsocketUpdatesCommand. 2019-02-25 22:10:17 +03:00
Alexey Skobkin b1d047941c New WS\Message types added: post_edited, comment_edited. Validation slightly refactored. 2019-02-25 20:02:26 +03:00
Alexey Skobkin 4d7549bddd Fixing crash on unexpected message type or deserialization error. Adding sentry logging to WS processing. 2019-02-25 20:01:47 +03:00
Alexey Skobkin ac0d905ce4 Implementing 'rec' type validation in WebSocket\Message. 2019-02-25 19:12:52 +03:00
Alexey Skobkin 16489b509e Message::$postAuthorId added. 2019-02-25 19:00:36 +03:00
Alexey Skobkin 728464005e Small style changes in ProcessWebsocketUpdatesCommand. 2019-02-25 17:56:46 +03:00
Alexey Skobkin 4ce5fe0ccb WebSocketMessageProcessor todo comments. 2019-02-24 02:08:15 +03:00
Alexey Skobkin 300dbcb466 PostFactory::findOrCreateFromWebsocketDto() draft. UserFactory::DATE_FORMAT moved to AbstractFactory. 2019-02-24 02:07:17 +03:00
Alexey Skobkin d528b45436 DTO\WebSocket\Message::$authorId for upcoming point update (https://github.com/artss/point/pull/13). Serialization config changed. Setter methods removed. 2019-02-24 01:40:49 +03:00
Alexey Skobkin f2b423ad8c Fixing missing Message::$rcid mapping for recommendations without comment. 2019-02-24 01:38:47 +03:00
Alexey Skobkin 4707b41f27 --keep-jobs option added for 'point:update:websocket-messages' command. 2019-02-24 01:37:30 +03:00
Alexey Skobkin 05aaa1d4e1 Fixing circular dependency in UserFactory. 2019-02-23 21:11:11 +03:00
Alexey Skobkin 26ee4522fc Unfinished work on WS comment retrieval, 2019-02-23 20:51:42 +03:00
Alexey Skobkin 63b27dc312 Post::addComment() bug fix. 2019-02-23 20:49:48 +03:00
Alexey Skobkin f5a4e5b896 #44 composer require leezy/pheanstalk-bundle (bundle configuration as well). 2019-02-23 20:49:48 +03:00
Alexey Skobkin 4eb7b418db #44 Database migration for new schema. 2019-02-23 20:46:15 +03:00
Alexey Skobkin 5897555302 #44 Comment mapping fix. 2019-02-23 20:46:15 +03:00
Alexey Skobkin 7902f9b3ea #44 Post mapping fix. 2019-02-23 20:46:15 +03:00
Alexey Skobkin f8dfe4e103 Fix #45. Doctrine partial index recreation on new migration generation fixed. 2019-02-23 20:46:15 +03:00
Alexey Skobkin 46b327b455 WS updates handling draft. Updates processing implementation needed. 2019-02-23 20:46:15 +03:00
Alexey Skobkin 31d49eb270 Comment::$toNumber type fixed. LoadCommentsData fixed according to new Comment constructor. 2019-02-23 20:46:15 +03:00
Alexey Skobkin 5d2ce0fe42 'point:update:websocket-messages' command draft. 2019-02-23 20:46:15 +03:00
Alexey Skobkin e82ff0d2a1 Comment schema changes, entity refactored. Post::$subscribed added. Migration needed. 2019-02-23 20:46:15 +03:00
38 changed files with 1818 additions and 1522 deletions

View file

@ -19,6 +19,7 @@ class AppKernel extends Kernel
new Symfony\Bundle\WebServerBundle\WebServerBundle(), new Symfony\Bundle\WebServerBundle\WebServerBundle(),
new JMS\SerializerBundle\JMSSerializerBundle(), new JMS\SerializerBundle\JMSSerializerBundle(),
new Csa\Bundle\GuzzleBundle\CsaGuzzleBundle(), new Csa\Bundle\GuzzleBundle\CsaGuzzleBundle(),
new Leezy\PheanstalkBundle\LeezyPheanstalkBundle(),
new Ob\HighchartsBundle\ObHighchartsBundle(), new Ob\HighchartsBundle\ObHighchartsBundle(),
new Knp\Bundle\MarkdownBundle\KnpMarkdownBundle(), new Knp\Bundle\MarkdownBundle\KnpMarkdownBundle(),
new Knp\Bundle\PaginatorBundle\KnpPaginatorBundle(), new Knp\Bundle\PaginatorBundle\KnpPaginatorBundle(),

View file

@ -0,0 +1,51 @@
<?php
namespace Application\Migrations;
use Doctrine\DBAL\Migrations\AbstractMigration;
use Doctrine\DBAL\Schema\Schema;
/**
* Issue #44 - Post and Comment schema refactoring.
* - Post subscription status added.
* - Comments parent-child relations removed.
* - User login index
* - Other adjustments
*/
class Version20180427143940 extends AbstractMigration
{
public function up(Schema $schema)
{
// this up() migration is auto-generated, please modify it to your needs
$this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'postgresql', 'Migration can only be executed safely on \'postgresql\'.');
$this->addSql('ALTER TABLE posts.posts ADD is_subscribed BOOLEAN DEFAULT FALSE NOT NULL');
// Removing parent_id constraint and index
$this->addSql('ALTER TABLE posts.comments DROP CONSTRAINT fk_62899975727aca70');
$this->addSql('DROP INDEX posts.idx_62899975727aca70');
$this->addSql('ALTER TABLE posts.comments DROP parent_id');
$this->addSql('ALTER TABLE posts.comments ADD to_number INT');
$this->addSql('ALTER TABLE posts.comments ALTER number TYPE INT');
$this->addSql('ALTER TABLE posts.comments ALTER number DROP DEFAULT');
$this->addSql('CREATE UNIQUE INDEX UNIQ_6289997596901F54 ON posts.comments (number)');
$this->addSql('CREATE INDEX idx_user_login ON users.users (login)');
}
public function down(Schema $schema)
{
// this down() migration is auto-generated, please modify it to your needs
$this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'postgresql', 'Migration can only be executed safely on \'postgresql\'.');
$this->addSql('ALTER TABLE posts.posts DROP is_subscribed');
$this->addSql('DROP INDEX posts.UNIQ_6289997596901F54');
$this->addSql('ALTER TABLE posts.comments ADD parent_id INT DEFAULT NULL');
$this->addSql('ALTER TABLE posts.comments DROP to_number');
$this->addSql('ALTER TABLE posts.comments ALTER number TYPE SMALLINT');
$this->addSql('ALTER TABLE posts.comments ALTER number DROP DEFAULT');
$this->addSql('ALTER TABLE posts.comments ADD CONSTRAINT fk_62899975727aca70 FOREIGN KEY (parent_id) REFERENCES posts.comments (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('CREATE INDEX idx_62899975727aca70 ON posts.comments (parent_id)');
$this->addSql('DROP INDEX users.idx_user_login');
}
}

View file

@ -0,0 +1,28 @@
<?php declare(strict_types=1);
namespace Application\Migrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20190227233244 extends AbstractMigration
{
public function up(Schema $schema) : void
{
// this up() migration is auto-generated, please modify it to your needs
$this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'postgresql', 'Migration can only be executed safely on \'postgresql\'.');
$this->addSql('CREATE UNIQUE INDEX unique_post_id_comment_number ON posts.comments (post_id, number)');
}
public function down(Schema $schema) : void
{
// this down() migration is auto-generated, please modify it to your needs
$this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'postgresql', 'Migration can only be executed safely on \'postgresql\'.');
$this->addSql('DROP INDEX posts.unique_post_id_comment_number');
}
}

View file

@ -71,6 +71,13 @@ doctrine_migrations:
table_name: migration_versions table_name: migration_versions
name: Application Migrations name: Application Migrations
leezy_pheanstalk:
pheanstalks:
primary:
server: "%beanstalkd_host%"
port: "%beanstalkd_port%"
default: true
# Swiftmailer Configuration # Swiftmailer Configuration
swiftmailer: swiftmailer:
transport: "%mailer_transport%" transport: "%mailer_transport%"

View file

@ -6,6 +6,11 @@ parameters:
database_user: point database_user: point
database_password: ~ database_password: ~
# Message Queue settings
beanstalkd_host: 'localhost'
beanstalkd_port: 11300
beanstalkd_ws_updates_tube: 'point-websocket-updates'
mailer_transport: smtp mailer_transport: smtp
mailer_host: 127.0.0.1 mailer_host: 127.0.0.1
mailer_user: ~ mailer_user: ~

View file

@ -9,4 +9,4 @@ security:
security: false security: false
default: default:
anonymous: true anonymous: ~

View file

@ -101,6 +101,13 @@ services:
# Send message # Send message
Skobkin\Bundle\PointToolsBundle\Command\TelegramSendMessageCommand: Skobkin\Bundle\PointToolsBundle\Command\TelegramSendMessageCommand:
tags: [{ name: console.command }] tags: [{ name: console.command }]
# WebSocket MQ processing
Skobkin\Bundle\PointToolsBundle\Command\ProcessWebsocketUpdatesCommand:
arguments:
$bsClient: '@leezy.pheanstalk.primary'
$bsTubeName: '%beanstalkd_ws_updates_tube%'
tags:
- { name: console.command }
# Entity repositories as services # Entity repositories as services

View file

@ -13,7 +13,6 @@
}, },
"require": { "require": {
"php": ">=7.1.0", "php": ">=7.1.0",
"ext-json": "*",
"symfony/symfony": "^3.4", "symfony/symfony": "^3.4",
"doctrine/orm": "^2.5", "doctrine/orm": "^2.5",
"doctrine/annotations": "^1.3.0", "doctrine/annotations": "^1.3.0",
@ -34,7 +33,8 @@
"unreal4u/telegram-api": "^2.2", "unreal4u/telegram-api": "^2.2",
"csa/guzzle-bundle": "^3", "csa/guzzle-bundle": "^3",
"symfony/web-server-bundle": "^3.3", "symfony/web-server-bundle": "^3.3",
"sentry/sentry-symfony": "^2.2" "sentry/sentry-symfony": "^2.2",
"leezy/pheanstalk-bundle": "^3.3"
}, },
"require-dev": { "require-dev": {
"symfony/phpunit-bridge": "^3.0", "symfony/phpunit-bridge": "^3.0",

1906
composer.lock generated

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,114 @@
<?php
namespace Skobkin\Bundle\PointToolsBundle\Command;
use Doctrine\ORM\EntityManagerInterface;
use JMS\Serializer\Serializer;
use Leezy\PheanstalkBundle\Proxy\PheanstalkProxy;
use Pheanstalk\Job;
use Skobkin\Bundle\PointToolsBundle\DTO\Api\WebSocket\Message;
use Skobkin\Bundle\PointToolsBundle\Exception\WebSocket\UnsupportedTypeException;
use Skobkin\Bundle\PointToolsBundle\Service\WebSocket\WebSocketMessageProcessor;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\{InputInterface, InputOption};
use Symfony\Component\Console\Output\OutputInterface;
/**
* This command processes WebSocket updates MQ and stores new content in the DB
*/
class ProcessWebsocketUpdatesCommand extends Command
{
/** @var EntityManagerInterface */
private $em;
/** @var PheanstalkProxy */
private $bsClient;
/** @var string */
private $bsTubeName;
/** @var Serializer */
private $serializer;
/** @var WebSocketMessageProcessor */
private $messageProcessor;
/** @var \Raven_Client */
private $sentryClient;
public function __construct(
EntityManagerInterface $em,
\Raven_Client $raven,
PheanstalkProxy $bsClient,
string $bsTubeName,
Serializer $serializer,
WebSocketMessageProcessor $processor
) {
$this->em = $em;
$this->sentryClient = $raven;
$this->serializer = $serializer;
$this->messageProcessor = $processor;
$this->bsClient = $bsClient;
$this->bsTubeName = $bsTubeName;
parent::__construct();
}
/** {@inheritdoc} */
protected function configure()
{
$this
->setName('point:update:websocket-messages')
->setDescription('Reads and processes updates from Beanstalkd queue pipe')
->addOption('keep-jobs', 'k', InputOption::VALUE_NONE, 'Don\'t delete jobs from queue after processing')
;
}
/** {@inheritdoc} */
protected function execute(InputInterface $input, OutputInterface $output)
{
$keepJobs = (bool) $input->getOption('keep-jobs');
/** @var Job $job */
while ($job = $this->bsClient->reserveFromTube($this->bsTubeName, 0)) {
try {
/** @var Message $message */
$message = $this->serializer->deserialize($job->getData(), Message::class, 'json');
} catch (\Exception $e) {
$output->writeln(sprintf(
'Error while deserializing #%d data: \'%s\'',
$job->getId(),
$job->getData()
));
$this->sentryClient->captureException($e);
continue;
}
$output->writeln('Processing job #'.$job->getId().' ('.$message->getA().')');
try {
if ($this->messageProcessor->processMessage($message)) {
$this->em->flush();
if (!$keepJobs) {
$this->bsClient->delete($job);
}
$this->em->clear();
}
} catch (UnsupportedTypeException $e) {
$output->writeln(' Unsupported message type: '.$message->getA());
$this->sentryClient->captureException($e);
continue;
} catch (\Exception $e) {
$output->writeln(' Message processing error: '.$e->getMessage());
$this->sentryClient->captureException($e);
continue;
}
}
}
}

View file

@ -12,19 +12,11 @@ class PostController extends AbstractController
{ {
/** /**
* @ParamConverter("post", class="SkobkinPointToolsBundle:Blogs\Post") * @ParamConverter("post", class="SkobkinPointToolsBundle:Blogs\Post")
*
* @return Response
*/ */
public function showAction(Post $post, PostRepository $postRepository): Response public function showAction(Post $post, PostRepository $postRepository): Response
{ {
if ((!$post->getAuthor()->isPublic()) || $post->getAuthor()->isWhitelistOnly()) {
/**
* Throwing 404 instead of 403 because of
* @see \Symfony\Component\Security\Http\Firewall\ExceptionListener::handleAccessDeniedException()
* starts to replace 403 by 401 exceptions for anonymous users and tries to authenticate them.
*/
throw $this->createNotFoundException('Author\'s blog is private.');
//throw $this->createAccessDeniedException('Author\'s blog is private.');
}
return $this->render('SkobkinPointToolsBundle:Post:show.html.twig', [ return $this->render('SkobkinPointToolsBundle:Post:show.html.twig', [
'post' => $postRepository->getPostWithComments($post->getId()), 'post' => $postRepository->getPostWithComments($post->getId()),
]); ]);

View file

@ -4,39 +4,22 @@ namespace Skobkin\Bundle\PointToolsBundle\DTO\Api;
class Comment implements ValidableInterface class Comment implements ValidableInterface
{ {
/** /** @var int|null */
* @var string|null
*/
private $postId;
/**
* @var int|null
*/
private $number; private $number;
/** /** @var int|null */
* @var int|null
*/
private $toCommentId; private $toCommentId;
/** /** @var string|null */
* @var string|null
*/
private $created; private $created;
/** /** @var string|null */
* @var string|null
*/
private $text; private $text;
/** /** @var User|null */
* @var User|null
*/
private $author; private $author;
/** /** @var bool|null */
* @var bool|null
*/
private $isRec; private $isRec;

View file

@ -4,27 +4,17 @@ namespace Skobkin\Bundle\PointToolsBundle\DTO\Api;
class MetaPost implements ValidableInterface class MetaPost implements ValidableInterface
{ {
/** /** @var Post|null */
* @var Post|null
*/
private $post; private $post;
/** /** @var Comment[]|null */
* @var Comment[]|null
*/
private $comments; private $comments;
public function getPost(): ?Post public function getPost(): ?Post
{ {
return $this->post; return $this->post;
} }
public function setPost(?Post $post): void
{
$this->post = $post;
}
/** /**
* @return Comment[]|null * @return Comment[]|null
*/ */
@ -33,20 +23,8 @@ class MetaPost implements ValidableInterface
return $this->comments; return $this->comments;
} }
/**
* @param Comment[]|null $comments
*/
public function setComments(?array $comments): void
{
$this->comments = $comments;
}
public function isValid(): bool public function isValid(): bool
{ {
if (null !== $this->post && $this->post->isValid()) { return (null !== $this->post && $this->post->isValid());
return true;
}
return false;
} }
} }

View file

@ -4,44 +4,28 @@ namespace Skobkin\Bundle\PointToolsBundle\DTO\Api;
class Post implements ValidableInterface class Post implements ValidableInterface
{ {
/** /** @var string|null */
* @var string|null
*/
private $id; private $id;
/** /** @var string[]|null */
* @var string[]|null
*/
private $tags; private $tags;
/** /** @var string[]|null */
* @var string[]|null
*/
private $files; private $files;
/** /** @var User|null */
* @var User|null
*/
private $author; private $author;
/** /** @var string|null */
* @var string|null
*/
private $text; private $text;
/** /** @var string|null */
* @var string|null
*/
private $created; private $created;
/** /** @var string|null */
* @var string|null
*/
private $type; private $type;
/** /** @var bool|null */
* @var bool|null
*/
private $private; private $private;

View file

@ -0,0 +1,330 @@
<?php
namespace Skobkin\Bundle\PointToolsBundle\DTO\Api\WebSocket;
use Skobkin\Bundle\PointToolsBundle\DTO\Api\ValidableInterface;
use Skobkin\Bundle\PointToolsBundle\Exception\WebSocket\UnsupportedTypeException;
/**
* WebSocket update message
*/
class Message implements ValidableInterface
{
public const TYPE_COMMENT = 'comment';
public const TYPE_COMMENT_EDITED = 'comment_edited';
public const TYPE_POST = 'post';
public const TYPE_POST_EDITED = 'post_edited';
public const TYPE_POST_COMMENT_RECOMMENDATION = 'rec';
public const TYPE_RECOMMENDATION_WITH_COMMENT = 'ok';
/**
* Event type. @see Message::TYPE_* constants
*
* @var string
*/
private $a;
/**
* Login of the user
*
* @var string
*/
private $author;
/**
* @var int
*/
private $authorId;
/** @var string|null */
private $authorName;
/**
* Number of the comment in the thread
*
* @var int|null
*/
private $commentId;
/**
* ???
*
* @var bool|null
*/
private $cut;
/**
* Array of file paths
*
* @var string[]|null
*/
private $files;
/** @var string|null */
private $html;
/**
* @deprecated Link in the Post::type=feed posts
*
* @var string|null
*/
private $link;
/**
* @var int|null
*/
private $postAuthorId;
/** @var string */
private $postId;
/** @var bool|null */
private $private;
/**
* Number of the comment in the thread for recommendation with text
*
* @var int|null
*/
private $rcid;
/**
* Array of tags
*
* @var string[]|null
*/
private $tags;
/** @var string */
private $text;
/**
* @deprecated ???
*
* @var string|null
*/
private $title;
/**
* Number of the comment to which this comment is answering
*
* @var string|null
*/
private $toCommentId;
/**
* Text quotation of the comment to which this comment is answering
*
* @var string|null
*/
private $toText;
/**
* Array of logins of users to which post is addressed
*
* @var string[]|null
*/
private $toUsers;
public function isPost(): bool
{
return self::TYPE_POST === $this->a;
}
public function isComment(): bool
{
return self::TYPE_COMMENT === $this->a;
}
public function isCommentRecommendation(): bool
{
return self::TYPE_RECOMMENDATION_WITH_COMMENT === $this->a;
}
public function isPostRecommendation(): bool
{
return self::TYPE_POST_COMMENT_RECOMMENDATION === $this->a;
}
/**
* @throws \RuntimeException
* @throws UnsupportedTypeException
*/
public function isValid(): bool
{
switch ($this->a) {
case self::TYPE_POST:
return $this->isValidPost();
break;
case self::TYPE_POST_EDITED:
return $this->isValidPostEdited();
break;
case self::TYPE_COMMENT;
return $this->isValidComment();
break;
case self::TYPE_COMMENT_EDITED;
return $this->isValidCommentEdited();
break;
case self::TYPE_RECOMMENDATION_WITH_COMMENT;
return $this->isValidRecommendationWithComment();
break;
case self::TYPE_POST_COMMENT_RECOMMENDATION;
return $this->isValidPostCommentRecommendation();
break;
case null:
throw new \RuntimeException('Message has NULL type.');
break;
default:
throw new UnsupportedTypeException(sprintf('Type \'%s\' is not supported.', $this->a));
}
}
public function getA(): string
{
return $this->a;
}
public function getAuthor(): string
{
return $this->author;
}
public function getAuthorId(): int
{
return $this->authorId;
}
public function getAuthorName(): ?string
{
return $this->authorName;
}
public function getCommentId(): ?int
{
return $this->commentId;
}
public function getCut(): ?bool
{
return $this->cut;
}
public function getFiles(): ?array
{
return $this->files;
}
public function getHtml(): ?string
{
return $this->html;
}
public function getLink(): ?string
{
return $this->link;
}
public function getPostId(): string
{
return $this->postId;
}
public function getPostAuthorId(): ?int
{
return $this->postAuthorId;
}
public function getPrivate(): ?bool
{
return $this->private;
}
public function getRcid(): ?int
{
return $this->rcid;
}
public function getTags(): ?array
{
return $this->tags;
}
public function getText(): string
{
return $this->text;
}
public function getTitle(): ?string
{
return $this->title;
}
public function getToCommentId(): ?string
{
return $this->toCommentId;
}
public function getToText(): ?string
{
return $this->toText;
}
public function getToUsers(): ?array
{
return $this->toUsers;
}
private function hasCommonMandatoryData(): bool
{
return (
null !== $this->author &&
null !== $this->authorId &&
null !== $this->postId
);
}
private function isValidPost(): bool
{
return $this->hasCommonMandatoryData() && (
// Text can be empty ("") though
null !== $this->text &&
null !== $this->tags
);
}
private function isValidPostEdited(): bool
{
return $this->isValidPost();
}
private function isValidComment(): bool
{
return $this->hasCommonMandatoryData() && (
null !== $this->commentId &&
null !== $this->html &&
null !== $this->text
);
}
private function isValidCommentEdited(): bool
{
return $this->hasCommonMandatoryData() && (null !== $this->commentId);
}
private function isValidRecommendationWithComment(): bool
{
return $this->hasCommonMandatoryData();
}
private function isValidPostCommentRecommendation(): bool
{
return $this->hasCommonMandatoryData() && (null !== $this->postAuthorId);
}
}

View file

@ -11,7 +11,7 @@ use Skobkin\Bundle\PointToolsBundle\Entity\User;
class LoadCommentsData extends AbstractFixture implements OrderedFixtureInterface class LoadCommentsData extends AbstractFixture implements OrderedFixtureInterface
{ {
public function load(ObjectManager $om) public function load(ObjectManager $om): void
{ {
/** @var Post $post */ /** @var Post $post */
$post = $this->getReference('test_post_longpost'); $post = $this->getReference('test_post_longpost');
@ -25,29 +25,28 @@ class LoadCommentsData extends AbstractFixture implements OrderedFixtureInterfac
$this->getReference('test_user_99995'), $this->getReference('test_user_99995'),
]; ];
$comments = []; $text = 'Some text with [link to @skobkin-ru site](https://skobk.in/) and `code block`'.PHP_EOL.
foreach (range(1, 10000) as $num) {
$comment = (new Comment())
->setNumber($num)
->setDeleted(mt_rand(0, 15) ? false : true)
->setCreatedAt(new \DateTime())
->setAuthor($users[array_rand($users)])
->setRec(false)
->setText(
'Some text with [link to @skobkin-ru site](https://skobk.in/) and `code block`'.PHP_EOL.
'and some quotation:'.PHP_EOL. 'and some quotation:'.PHP_EOL.
'> test test quote'.PHP_EOL. '> test test quote'.PHP_EOL.
'and some text after' 'and some text after';
)
;
if (count($comments) > 0 && mt_rand(0, 1)) { foreach (range(1, 10000) as $num) {
$comment->setParent($comments[mt_rand(0, count($comments) - 1)]); $comment = new Comment(
$text,
new \DateTime(),
false,
$post,
$num,
($num > 1 && !random_int(0, 4)) ? random_int(1, $num - 1) : null,
$users[array_rand($users)],
[]
);
if (!random_int(0, 15)) {
$comment->delete();
} }
$post->addComment($comment); $post->addComment($comment);
$comments[] = $comment;
$om->persist($comment); $om->persist($comment);
} }

View file

@ -2,64 +2,33 @@
namespace Skobkin\Bundle\PointToolsBundle\DataFixtures\ORM; namespace Skobkin\Bundle\PointToolsBundle\DataFixtures\ORM;
use Doctrine\Common\DataFixtures\{AbstractFixture, OrderedFixtureInterface}; use Doctrine\Common\DataFixtures\AbstractFixture;
use Doctrine\Common\DataFixtures\OrderedFixtureInterface;
use Doctrine\Common\Persistence\ObjectManager; use Doctrine\Common\Persistence\ObjectManager;
use Skobkin\Bundle\PointToolsBundle\Entity\{Blogs\Post, User}; use Skobkin\Bundle\PointToolsBundle\Entity\Blogs\Post;
use Skobkin\Bundle\PointToolsBundle\Entity\User;
class LoadPostData extends AbstractFixture implements OrderedFixtureInterface class LoadPostData extends AbstractFixture implements OrderedFixtureInterface
{ {
public const POST_ID_LONG = 'longpost';
public const POST_ID_SHORT = 'shortpost';
public const POST_ID_PR_USER = 'prusrpst';
public const POST_ID_WL_USER = 'wlusrpst';
public const POST_ID_PR_WL_USER = 'prwlusrpst';
public function load(ObjectManager $om) public function load(ObjectManager $om)
{ {
/** @var User $mainUser */ /** @var User $testUser */
$mainUser = $this->getReference('test_user_'.LoadUserData::USER_MAIN_ID); $testUser = $this->getReference('test_user_99999');
/** @var User $privateUser */
$privateUser = $this->getReference('test_user_'.LoadUserData::USER_PRIV_ID);
/** @var User $wlUser */
$wlUser = $this->getReference('test_user_'.LoadUserData::USER_WLON_ID);
/** @var User $prWlUser */
$prWlUser = $this->getReference('test_user_'.LoadUserData::USER_PRWL_ID);
$longPost = (new Post(self::POST_ID_LONG, $mainUser, new \DateTime(), Post::TYPE_POST)) $longPost = (new Post('longpost', $testUser, new \DateTime(), Post::TYPE_POST))
->setText('Test post with many comments') ->setText('Test post with many comments')
->setPrivate(false) ->setPrivate(false)
->setDeleted(false) ->setDeleted(false)
; ;
$shortPost = (new Post(self::POST_ID_SHORT, $mainUser, new \DateTime(), Post::TYPE_POST)) $shortPost = (new Post('shortpost', $testUser, new \DateTime(), Post::TYPE_POST))
->setText('Test short post') ->setText('Test short post')
->setPrivate(false) ->setPrivate(false)
->setDeleted(false) ->setDeleted(false)
; ;
$privateUserPost = (new Post(self::POST_ID_PR_USER, $privateUser, new \DateTime(), Post::TYPE_POST))
->setText('Post from private user. Should not be visible in the public feed.')
->setPrivate(false)
->setDeleted(false)
;
$wlUserPost = (new Post(self::POST_ID_WL_USER, $wlUser, new \DateTime(), Post::TYPE_POST))
->setText('Post from whitelist-only user. Should only be visible for whitelisted users.')
->setPrivate(false)
->setDeleted(false)
;
$privateWlUserPost = (new Post(self::POST_ID_PR_WL_USER, $prWlUser, new \DateTime(), Post::TYPE_POST))
->setText('Post from private AND whitelist-only user. Should not be visible in the public feed.')
->setPrivate(false)
->setDeleted(false)
;
$om->persist($longPost); $om->persist($longPost);
$om->persist($shortPost); $om->persist($shortPost);
$om->persist($privateUserPost);
$om->persist($wlUserPost);
$om->persist($privateWlUserPost);
$om->flush(); $om->flush();
$this->addReference('test_post_longpost', $longPost); $this->addReference('test_post_longpost', $longPost);

View file

@ -9,27 +9,25 @@ use Skobkin\Bundle\PointToolsBundle\Entity\User;
class LoadUserData extends AbstractFixture implements OrderedFixtureInterface class LoadUserData extends AbstractFixture implements OrderedFixtureInterface
{ {
public const USER_MAIN_ID = 99999;
public const USER_SCND_ID = 99998;
public const USER_PRIV_ID = 99997;
public const USER_WLON_ID = 99996;
public const USER_PRWL_ID = 99995;
public const USER_UNNM_ID = 99994;
private $users = [ private $users = [
['id' => self::USER_MAIN_ID, 'login' => 'testuser', 'name' => 'Test User 1', 'private' => false, 'whitelist-only' => false], // 99999
['id' => self::USER_SCND_ID, 'login' => 'testuser2', 'name' => 'Test User 2 for autocomplete test', 'private' => false, 'whitelist-only' => false], ['login' => 'testuser', 'name' => 'Test User 1'],
['id' => self::USER_PRIV_ID, 'login' => 'private_user', 'name' => 'Test User 3', 'private' => true, 'whitelist-only' => false], // 99998
['id' => self::USER_WLON_ID, 'login' => 'whitelist_only_user', 'name' => 'Test User 4', 'private' => false, 'whitelist-only' => true], ['login' => 'testuser2', 'name' => 'Test User 2'],
['id' => self::USER_PRWL_ID, 'login' => 'private_whitelist_only_user', 'name' => 'Test User 4', 'private' => false, 'whitelist-only' => true], // 99997
['id' => self::USER_UNNM_ID, 'login' => 'unnamed_user', 'name' => null, 'private' => false, 'whitelist-only' => false], ['login' => 'testuser3', 'name' => 'Test User 3'],
// 99996
['login' => 'testuser4', 'name' => 'Test User 4'],
//99995
['login' => 'testuser5', 'name' => null],
]; ];
public function load(ObjectManager $om) public function load(ObjectManager $om)
{ {
$userId = 99999;
foreach ($this->users as $userData) { foreach ($this->users as $userData) {
$user = new User($userData['id'], new \DateTime(), $userData['login'], $userData['name']); $user = new User($userId--, new \DateTime(), $userData['login'], $userData['name']);
$user->updatePrivacy(!$userData['private'], $userData['whitelist-only']);
$om->persist($user); $om->persist($user);

View file

@ -9,6 +9,8 @@ use Skobkin\Bundle\PointToolsBundle\Entity\User;
/** /**
* @ORM\Table(name="comments", schema="posts", indexes={ * @ORM\Table(name="comments", schema="posts", indexes={
* @ORM\Index(name="idx_comment_created_at", columns={"created_at"}) * @ORM\Index(name="idx_comment_created_at", columns={"created_at"})
* }, uniqueConstraints={
* @ORM\UniqueConstraint(name="unique_post_id_comment_number", columns={"post_id", "number"})
* }) * })
* @ORM\Entity(repositoryClass="Skobkin\Bundle\PointToolsBundle\Repository\Blogs\CommentRepository") * @ORM\Entity(repositoryClass="Skobkin\Bundle\PointToolsBundle\Repository\Blogs\CommentRepository")
*/ */
@ -62,10 +64,17 @@ class Comment
/** /**
* @var int * @var int
* *
* @ORM\Column(name="number", type="smallint") * @ORM\Column(name="number", type="integer", unique=true)
*/ */
private $number; private $number;
/**
* @var int|null
*
* @ORM\Column(name="to_number", type="integer", nullable=true)
*/
private $toNumber;
/** /**
* @var User * @var User
* *
@ -85,26 +94,36 @@ class Comment
*/ */
private $files; private $files;
/**
* @var Comment|null
*
* @ORM\ManyToOne(targetEntity="Skobkin\Bundle\PointToolsBundle\Entity\Blogs\Comment", inversedBy="children")
* @ORM\JoinColumn(name="parent_id", nullable=true)
*/
private $parent;
/** public function __construct(
* @var Comment[]|ArrayCollection string $text,
* \DateTime $createdAt,
* @ORM\OneToMany(targetEntity="Skobkin\Bundle\PointToolsBundle\Entity\Blogs\Comment", fetch="EXTRA_LAZY", mappedBy="parent") bool $rec,
*/ Post $post,
private $children; int $number,
?int $toNumber,
User $author,
array $files
) {
$this->text = $text;
$this->createdAt = $createdAt;
$this->rec = $rec;
$this->post = $post;
$this->number = $number;
$this->toNumber = $toNumber;
$this->author = $author;
public function __construct()
{
$this->files = new ArrayCollection(); $this->files = new ArrayCollection();
$this->children = new ArrayCollection(); foreach ($files as $file) {
if (!($file instanceof File)) {
throw new \RuntimeException(sprintf(
'$files array must contain only \'%s\' objects. %s given.',
\is_object($file) ? \get_class($file) : \gettype($file)
));
}
$this->files->add($file);
}
} }
public function getId(): int public function getId(): int
@ -112,95 +131,41 @@ class Comment
return $this->id; return $this->id;
} }
public function setCreatedAt(\DateTime $createdAt): self
{
$this->createdAt = $createdAt;
return $this;
}
public function getCreatedAt(): \DateTime public function getCreatedAt(): \DateTime
{ {
return $this->createdAt; return $this->createdAt;
} }
public function setText(string $text): self
{
$this->text = $text;
return $this;
}
public function getText(): string public function getText(): string
{ {
return $this->text; return $this->text;
} }
public function setRec(bool $rec): self
{
$this->rec = $rec;
return $this;
}
public function isRec(): bool public function isRec(): bool
{ {
return $this->rec; return $this->rec;
} }
public function getRec(): bool
{
return $this->rec;
}
public function getPost(): Post public function getPost(): Post
{ {
return $this->post; return $this->post;
} }
public function setPost(Post $post): self
{
$this->post = $post;
return $this;
}
public function setNumber(int $number): self
{
$this->number = $number;
return $this;
}
public function getNumber(): int public function getNumber(): int
{ {
return $this->number; return $this->number;
} }
public function getToNumber(): ?int
{
return $this->toNumber;
}
public function getAuthor(): User public function getAuthor(): User
{ {
return $this->author; return $this->author;
} }
public function setAuthor(User $author): self
{
$this->author = $author;
return $this;
}
public function addFile(File $files): self
{
$this->files[] = $files;
return $this;
}
public function removeFile(File $files): void
{
$this->files->removeElement($files);
}
/** /**
* @return File[]|ArrayCollection * @return File[]|ArrayCollection
*/ */
@ -209,52 +174,22 @@ class Comment
return $this->files; return $this->files;
} }
public function getParent(): ?Comment public function delete(): self
{ {
return $this->parent; $this->deleted = true;
}
public function setParent(Comment $parent): self
{
$this->parent = $parent;
return $this; return $this;
} }
public function setDeleted(bool $deleted): self public function restore(): self
{ {
$this->deleted = $deleted; $this->deleted = false;
return $this; return $this;
} }
public function getDeleted(): bool
{
return $this->deleted;
}
public function isDeleted(): bool public function isDeleted(): bool
{ {
return $this->deleted; return $this->deleted;
} }
public function addChild(Comment $children): self
{
$this->children[] = $children;
return $this;
}
public function removeChild(Comment $children): void
{
$this->children->removeElement($children);
}
/**
* @return Comment[]|ArrayCollection
*/
public function getChildren(): iterable
{
return $this->children;
}
} }

View file

@ -69,6 +69,13 @@ class Post
*/ */
private $deleted = false; private $deleted = false;
/**
* @var bool Status of point-tools subscription to the post (to receive WS updates)
*
* @ORM\Column(name="is_subscribed", type="boolean", options={"default": false})
*/
private $subscribed = false;
/** /**
* @var User * @var User
* *
@ -103,7 +110,7 @@ class Post
private $comments; private $comments;
public function __construct(string $id, User $author, \DateTime $createdAt, string $type) public function __construct(string $id, User $author, \DateTime $createdAt, string $type = self::TYPE_POST)
{ {
$this->id = $id; $this->id = $id;
$this->author = $author; $this->author = $author;
@ -217,6 +224,25 @@ class Post
return $this->deleted; return $this->deleted;
} }
public function isSubscribed(): bool
{
return $this->subscribed;
}
public function subscribe(): self
{
$this->subscribed = true;
return $this;
}
public function unsubscribe(): self
{
$this->subscribed = false;
return $this;
}
public function setPrivate(bool $private): self public function setPrivate(bool $private): self
{ {
$this->private = $private; $this->private = $private;
@ -237,7 +263,6 @@ class Post
public function addComment(Comment $comment): self public function addComment(Comment $comment): self
{ {
$this->comments[] = $comment; $this->comments[] = $comment;
$comment->setPost($this);
return $this; return $this;
} }

View file

@ -7,8 +7,8 @@ use Skobkin\Bundle\PointToolsBundle\Entity\User;
/** /**
* @ORM\Table(name="telegram_accounts", schema="users", indexes={ * @ORM\Table(name="telegram_accounts", schema="users", indexes={
* @ORM\Index(name="subscriber_notification_idx", columns={"subscriber_notification"}, options={"where": "subscriber_notification = TRUE"}), * @ORM\Index(name="subscriber_notification_idx", columns={"subscriber_notification"}, options={"where": "(subscriber_notification = true)"}),
* @ORM\Index(name="rename_notification_idx", columns={"rename_notification"}, options={"where": "rename_notification = TRUE"}), * @ORM\Index(name="rename_notification_idx", columns={"rename_notification"}, options={"where": "(rename_notification = true)"}),
* }) * })
* @ORM\Entity(repositoryClass="Skobkin\Bundle\PointToolsBundle\Repository\Telegram\AccountRepository") * @ORM\Entity(repositoryClass="Skobkin\Bundle\PointToolsBundle\Repository\Telegram\AccountRepository")
* @ORM\HasLifecycleCallbacks() * @ORM\HasLifecycleCallbacks()

View file

@ -7,6 +7,7 @@ use Doctrine\ORM\Mapping as ORM;
/** /**
* @ORM\Table(name="users", schema="users", indexes={ * @ORM\Table(name="users", schema="users", indexes={
* @ORM\Index(name="idx_user_login", columns={"login"}),
* @ORM\Index(name="idx_user_public", columns={"public"}), * @ORM\Index(name="idx_user_public", columns={"public"}),
* @ORM\Index(name="idx_user_removed", columns={"is_removed"}) * @ORM\Index(name="idx_user_removed", columns={"is_removed"})
* }) * })
@ -190,15 +191,24 @@ class User
return $this->createdAt; return $this->createdAt;
} }
public function updateCreatedAt(\DateTime $date): self
{
$this->createdAt = $date;
return $this;
}
public function getUpdatedAt(): ?\DateTime public function getUpdatedAt(): ?\DateTime
{ {
return $this->updatedAt; return $this->updatedAt;
} }
public function updatePrivacy(?bool $public, ?bool $whitelistOnly): void public function updatePrivacy(bool $public, bool $whitelistOnly): self
{ {
$this->public = $public; $this->public = $public;
$this->whitelistOnly = $whitelistOnly; $this->whitelistOnly = $whitelistOnly;
return $this;
} }
public function isPublic(): ?bool public function isPublic(): ?bool

View file

@ -0,0 +1,20 @@
<?php
namespace Skobkin\Bundle\PointToolsBundle\Exception\Api;
class PostNotFoundException extends NotFoundException
{
/** @var string */
private $id;
public function __construct(string $id, \Exception $previous)
{
parent::__construct('Post not found', 0, $previous);
$this->id = $id;
}
public function getId(): string
{
return $this->id;
}
}

View file

@ -4,16 +4,11 @@ namespace Skobkin\Bundle\PointToolsBundle\Exception\Api;
class UserNotFoundException extends NotFoundException class UserNotFoundException extends NotFoundException
{ {
/** /** @var int */
* @var int private $userId;
*/
protected $userId;
/**
* @var string
*/
protected $login;
/** @var string */
private $login;
/** /**
* {@inheritdoc} * {@inheritdoc}

View file

@ -0,0 +1,9 @@
<?php
namespace Skobkin\Bundle\PointToolsBundle\Exception\WebSocket;
use RuntimeException;
class UnsupportedTypeException extends RuntimeException
{
}

View file

@ -2,10 +2,6 @@ Skobkin\Bundle\PointToolsBundle\DTO\Api\Comment:
exclusion_policy: none exclusion_policy: none
access_type: public_method access_type: public_method
properties: properties:
postId:
serialized_name: 'post_id'
type: 'Skobkin\Bundle\PointToolsBundle\DTO\Api\Post'
max_depth: 2
number: number:
serialized_name: 'id' serialized_name: 'id'
type: integer type: integer

View file

@ -1,10 +1,9 @@
Skobkin\Bundle\PointToolsBundle\DTO\Api\MetaPost: Skobkin\Bundle\PointToolsBundle\DTO\Api\MetaPost:
exclusion_policy: none exclusion_policy: none
access_type: public_method
properties: properties:
post: post:
serialized_name: 'post'
type: 'Skobkin\Bundle\PointToolsBundle\DTO\Api\Post' type: 'Skobkin\Bundle\PointToolsBundle\DTO\Api\Post'
max_depth: 2
comments: comments:
serialized_name: 'comments'
type: 'array<Skobkin\Bundle\PointToolsBundle\DTO\Api\Comment>' type: 'array<Skobkin\Bundle\PointToolsBundle\DTO\Api\Comment>'
max_depth: 2

View file

@ -0,0 +1,60 @@
Skobkin\Bundle\PointToolsBundle\DTO\Api\WebSocket\Message:
exclusion_policy: none
properties:
a:
type: string
serialized_name: 'a'
author:
type: string
serialized_name: 'author'
authorId:
type: int
serialized_name: 'author_id'
authorName:
type: string
serialized_name: 'author_name'
commentId:
type: int
serialized_name: 'comment_id'
cut:
type: bool
serialized_name: 'cut'
files:
type: array<string>
serialized_name: 'files'
html:
type: string
serialized_name: 'html'
link:
type: string
serialized_name: 'link'
postId:
type: string
serialized_name: 'post_id'
postAuthorId:
type: int
serialized_name: 'post_author_id'
private:
type: bool
serialized_name: 'private'
rcid:
type: int
serialized_name: 'rcid'
tags:
type: array<string>
serialized_name: 'tags'
text:
type: string
serialized_name: 'text'
title:
type: string
serialized_name: 'title'
toCommentId:
type: string
serialized_name: 'to_comment_id'
toText:
type: string
serialized_name: 'to_text'
toUsers:
type: array<string>
serialized_name: 'to_users'

View file

@ -5,6 +5,9 @@ namespace Skobkin\Bundle\PointToolsBundle\Service\Api;
use GuzzleHttp\ClientInterface; use GuzzleHttp\ClientInterface;
use JMS\Serializer\SerializerInterface; use JMS\Serializer\SerializerInterface;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
use Skobkin\Bundle\PointToolsBundle\DTO\Api\MetaPost;
use Skobkin\Bundle\PointToolsBundle\Entity\Blogs\Post;
use Skobkin\Bundle\PointToolsBundle\Exception\Api\{NotFoundException, PostNotFoundException};
use Skobkin\Bundle\PointToolsBundle\Service\Factory\Blogs\PostFactory; use Skobkin\Bundle\PointToolsBundle\Service\Factory\Blogs\PostFactory;
/** /**
@ -12,6 +15,8 @@ use Skobkin\Bundle\PointToolsBundle\Service\Factory\Blogs\PostFactory;
*/ */
class PostApi extends AbstractApi class PostApi extends AbstractApi
{ {
private const PREFIX = '/api/post/';
/** /**
* @var PostFactory * @var PostFactory
*/ */
@ -24,4 +29,24 @@ class PostApi extends AbstractApi
$this->postFactory = $postFactory; $this->postFactory = $postFactory;
} }
/**
* @throws PostNotFoundException
*/
public function getById(string $id): Post
{
try {
$postData = $this->getGetJsonData(
self::PREFIX.$id,
[],
MetaPost::class
);
} catch (NotFoundException $e) {
throw new PostNotFoundException($id, $e);
}
// Not catching ForbiddenException right now
return $this->postFactory->findOrCreateFromDtoWithContent($postData);
}
} }

View file

@ -3,9 +3,7 @@
namespace Skobkin\Bundle\PointToolsBundle\Service\Api; namespace Skobkin\Bundle\PointToolsBundle\Service\Api;
use GuzzleHttp\ClientInterface; use GuzzleHttp\ClientInterface;
use JMS\Serializer\{ use JMS\Serializer\{DeserializationContext, SerializerInterface};
DeserializationContext, SerializerInterface
};
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
use Skobkin\Bundle\PointToolsBundle\DTO\Api\{Auth, User as UserDTO}; use Skobkin\Bundle\PointToolsBundle\DTO\Api\{Auth, User as UserDTO};
use Skobkin\Bundle\PointToolsBundle\Entity\User; use Skobkin\Bundle\PointToolsBundle\Entity\User;

View file

@ -6,6 +6,8 @@ use Psr\Log\LoggerInterface;
abstract class AbstractFactory abstract class AbstractFactory
{ {
public const DATE_FORMAT = 'Y-m-d_H:i:s';
/** @var LoggerInterface */ /** @var LoggerInterface */
protected $logger; protected $logger;

View file

@ -3,7 +3,10 @@
namespace Skobkin\Bundle\PointToolsBundle\Service\Factory\Blogs; namespace Skobkin\Bundle\PointToolsBundle\Service\Factory\Blogs;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
use Skobkin\Bundle\PointToolsBundle\DTO\Api\WebSocket\Message;
use Skobkin\Bundle\PointToolsBundle\Entity\Blogs\Comment;
use Skobkin\Bundle\PointToolsBundle\Repository\Blogs\{CommentRepository, PostRepository}; use Skobkin\Bundle\PointToolsBundle\Repository\Blogs\{CommentRepository, PostRepository};
use Skobkin\Bundle\PointToolsBundle\Service\Api\PostApi;
use Skobkin\Bundle\PointToolsBundle\Service\Factory\{AbstractFactory, UserFactory}; use Skobkin\Bundle\PointToolsBundle\Service\Factory\{AbstractFactory, UserFactory};
class CommentFactory extends AbstractFactory class CommentFactory extends AbstractFactory
@ -17,12 +20,45 @@ class CommentFactory extends AbstractFactory
/** @var UserFactory */ /** @var UserFactory */
private $userFactory; private $userFactory;
/** @var PostApi */
private $postApi;
public function __construct(LoggerInterface $logger, CommentRepository $commentRepository, PostRepository $postRepository, UserFactory $userFactory)
public function __construct(LoggerInterface $logger, CommentRepository $commentRepository, PostRepository $postRepository, UserFactory $userFactory, PostApi $postApi)
{ {
parent::__construct($logger); parent::__construct($logger);
$this->userFactory = $userFactory; $this->userFactory = $userFactory;
$this->commentRepository = $commentRepository; $this->commentRepository = $commentRepository;
$this->postRepository = $postRepository; $this->postRepository = $postRepository;
$this->postApi = $postApi;
}
public function findOrCreateFromWebsocketMessage(Message $message): Comment
{
if ($message->isValid()) {
throw new \InvalidArgumentException('Comment is invalid');
}
if ($message->isComment()) {
throw new \InvalidArgumentException(sprintf(
'Invalid Message object provided. %s expected, %s given',
Message::TYPE_COMMENT,
$message->getA()
));
}
if (null === $comment = $this->commentRepository->findOneBy(['post' => $post, 'number' => $message->getCommentId()])) {
$author = $this->userFactory->findOrCreateFromIdLoginAndName(
$message->getAuthorId(),
$message->getAuthor(),
$message->getAuthorName()
);
if (null === $post = $this->postRepository->find($message->getPostId())) {
$post = $this->postApi->getById($message->getPostId());
}
// TODO
//$comment = new Comment()
}
} }
} }

View file

@ -4,10 +4,11 @@ namespace Skobkin\Bundle\PointToolsBundle\Service\Factory\Blogs;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
use Skobkin\Bundle\PointToolsBundle\DTO\Api\{MetaPost, Post as PostDTO, PostsPage}; use Skobkin\Bundle\PointToolsBundle\DTO\Api\{MetaPost, Post as ApiPost, PostsPage};
use Skobkin\Bundle\PointToolsBundle\DTO\Api\WebSocket\Message as WebsocketMessage;
use Skobkin\Bundle\PointToolsBundle\Entity\{Blogs\Post, Blogs\PostTag, User}; use Skobkin\Bundle\PointToolsBundle\Entity\{Blogs\Post, Blogs\PostTag, User};
use Skobkin\Bundle\PointToolsBundle\Exception\{Api\InvalidResponseException, Factory\Blog\InvalidDataException}; use Skobkin\Bundle\PointToolsBundle\Exception\{Api\InvalidResponseException, Factory\Blog\InvalidDataException};
use Skobkin\Bundle\PointToolsBundle\Repository\Blogs\PostRepository; use Skobkin\Bundle\PointToolsBundle\Repository\{Blogs\PostRepository, UserRepository};
use Skobkin\Bundle\PointToolsBundle\Service\Factory\{AbstractFactory, UserFactory}; use Skobkin\Bundle\PointToolsBundle\Service\Factory\{AbstractFactory, UserFactory};
class PostFactory extends AbstractFactory class PostFactory extends AbstractFactory
@ -18,6 +19,9 @@ class PostFactory extends AbstractFactory
/** @var PostRepository */ /** @var PostRepository */
private $postRepository; private $postRepository;
/** @var UserRepository */
private $userRepository;
/** @var UserFactory */ /** @var UserFactory */
private $userFactory; private $userFactory;
@ -35,6 +39,7 @@ class PostFactory extends AbstractFactory
LoggerInterface $logger, LoggerInterface $logger,
EntityManagerInterface $em, EntityManagerInterface $em,
PostRepository $postRepository, PostRepository $postRepository,
UserRepository $userRepository,
UserFactory $userFactory, UserFactory $userFactory,
FileFactory $fileFactory, FileFactory $fileFactory,
CommentFactory $commentFactory, CommentFactory $commentFactory,
@ -43,6 +48,7 @@ class PostFactory extends AbstractFactory
parent::__construct($logger); parent::__construct($logger);
$this->em = $em; $this->em = $em;
$this->postRepository = $postRepository; $this->postRepository = $postRepository;
$this->userRepository = $userRepository;
$this->userFactory = $userFactory; $this->userFactory = $userFactory;
$this->fileFactory = $fileFactory; $this->fileFactory = $fileFactory;
$this->commentFactory = $commentFactory; $this->commentFactory = $commentFactory;
@ -92,8 +98,6 @@ class PostFactory extends AbstractFactory
/** /**
* Create full post with tags, files and comments * Create full post with tags, files and comments
* *
* @todo Implement comments
*
* @throws InvalidDataException * @throws InvalidDataException
*/ */
public function findOrCreateFromDtoWithContent(MetaPost $metaPost): Post public function findOrCreateFromDtoWithContent(MetaPost $metaPost): Post
@ -111,7 +115,7 @@ class PostFactory extends AbstractFactory
throw $e; throw $e;
} }
$post = $this->findOrCreateFromDto($postData, $author); $post = $this->findOrCreateFromApiDto($postData, $author);
try { try {
$this->updatePostTags($post, $postData->getTags() ?: []); $this->updatePostTags($post, $postData->getTags() ?: []);
@ -127,10 +131,62 @@ class PostFactory extends AbstractFactory
throw $e; throw $e;
} }
// @TODO implement comments
return $post; return $post;
} }
private function findOrCreateFromDto(PostDTO $postData, User $author): Post public function findOrCreateFromWebsocketDto(WebsocketMessage $message): Post
{
if (!$message->isValid()) {
throw new InvalidDataException('Invalid post data');
}
if (!$message->isPost()) {
throw new \LogicException(sprintf(
'Incorrect message type received. \'post\' expected \'%s\' given',
$message->getA()
));
}
if (null === $post = $this->postRepository->find($message->getPostId())) {
$author = $this->userFactory->findOrCreateFromIdLoginAndName(
$message->getAuthorId(),
$message->getAuthor(),
$message->getAuthorName()
);
$post = new Post(
$message->getPostId(),
$author,
new \DateTime(),
Post::TYPE_POST
);
$this->postRepository->add($post);
}
$post
->setText($message->getText())
->setPrivate((bool) $message->getPrivate())
;
try {
$this->updatePostTags($post, $message->getTags() ?: []);
} catch (\Exception $e) {
$this->logger->error('Error while updating post tags');
throw $e;
}
try {
$this->updatePostFiles($post, $message->getFiles() ?: []);
} catch (\Exception $e) {
$this->logger->error('Error while updating post files');
throw $e;
}
return $post;
}
private function findOrCreateFromApiDto(ApiPost $postData, User $author): Post
{ {
if (null === ($post = $this->postRepository->find($postData->getId()))) { if (null === ($post = $this->postRepository->find($postData->getId()))) {
// Creating new post // Creating new post
@ -145,7 +201,7 @@ class PostFactory extends AbstractFactory
$post $post
->setText($postData->getText()) ->setText($postData->getText())
->setPrivate($postData->getPrivate()) ->setPrivate((bool) $postData->getPrivate())
; ;
return $post; return $post;

View file

@ -10,12 +10,9 @@ use Skobkin\Bundle\PointToolsBundle\Exception\Factory\InvalidUserDataException;
class UserFactory extends AbstractFactory class UserFactory extends AbstractFactory
{ {
public const DATE_FORMAT = 'Y-m-d_H:i:s';
/** @var UserRepository */ /** @var UserRepository */
private $userRepository; private $userRepository;
public function __construct(LoggerInterface $logger, UserRepository $userRepository) public function __construct(LoggerInterface $logger, UserRepository $userRepository)
{ {
parent::__construct($logger); parent::__construct($logger);
@ -23,27 +20,25 @@ class UserFactory extends AbstractFactory
} }
/** /**
* @param UserDTO $userData
*
* @return User
*
* @throws InvalidUserDataException * @throws InvalidUserDataException
*/ */
public function findOrCreateFromDTO(UserDTO $userData): User public function findOrCreateFromDTO(UserDTO $userData): User
{ {
// @todo LOG
if (!$userData->isValid()) { if (!$userData->isValid()) {
throw new InvalidUserDataException('Invalid user data', $userData); throw new InvalidUserDataException('Invalid user data', $userData);
} }
$createdAt = \DateTime::createFromFormat(self::DATE_FORMAT, $userData->getCreated()) ?: new \DateTime();
/** @var User $user */ /** @var User $user */
if (null === ($user = $this->userRepository->find($userData->getId()))) { if (null === ($user = $this->userRepository->find($userData->getId()))) {
$user = new User( $user = new User(
$userData->getId(), $userData->getId(),
\DateTime::createFromFormat('Y-m-d_H:i:s', $userData->getCreated()) ?: new \DateTime() $createdAt
); );
$this->userRepository->add($user); $this->userRepository->add($user);
} else {
$user->updateCreatedAt($createdAt);
} }
$user->updateLoginAndName($userData->getLogin(), $userData->getName()); $user->updateLoginAndName($userData->getLogin(), $userData->getName());
@ -55,6 +50,21 @@ class UserFactory extends AbstractFactory
return $user; return $user;
} }
public function findOrCreateFromIdLoginAndName(int $id, string $login, ?string $name): User
{
/** @var User $user */
if (null === $user = $this->userRepository->find($id)) {
// We're using current date now but next time when we'll be updating user from API it'll be fixed
$user = new User($id, new \DateTime(), $login, $name);
$this->userRepository->add($user);
} else {
// @todo update login?
// Probably don't because no name in the WS message (or maybe after PR to point?)
}
return $user;
}
/** /**
* @return User[] * @return User[]
*/ */

View file

@ -0,0 +1,69 @@
<?php
namespace Skobkin\Bundle\PointToolsBundle\Service\WebSocket;
use Skobkin\Bundle\PointToolsBundle\DTO\Api\WebSocket\Message;
use Skobkin\Bundle\PointToolsBundle\Service\Factory\Blogs\{CommentFactory, PostFactory};
class WebSocketMessageProcessor
{
/** @var PostFactory */
private $postFactory;
/** @var CommentFactory */
private $commentFactory;
public function __construct(PostFactory $postFactory, CommentFactory $commentFactory)
{
$this->postFactory = $postFactory;
$this->commentFactory = $commentFactory;
}
/**
* Returns true on success (all data saved)
*/
public function processMessage(Message $message): bool
{
if (!$message->isValid()) {
return false;
}
switch (true) {
case $message->isPost():
return $this->processPost($message);
break;
case $message->isComment():
return $this->processComment($message);
break;
case $message->isCommentRecommendation():
return $this->processRecommendation($message);
break;
}
return false;
}
private function processPost(Message $postData): bool
{
$this->postFactory->findOrCreateFromWebsocketDto($postData);
return true;
}
private function processComment(Message $commentData): bool
{
// Not done yet
return false;
$this->commentFactory->findOrCreateFromWebsocketMessage($commentData);
return true;
}
private function processRecommendation(Message $recommendData): bool
{
return false;
}
}

View file

@ -2,12 +2,11 @@
namespace Tests\Skobkin\PointToolsBundle\Controller; namespace Tests\Skobkin\PointToolsBundle\Controller;
use Symfony\Bundle\FrameworkBundle\Client;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
class MainControllerTest extends WebTestCase class MainControllerTest extends WebTestCase
{ {
public function testUserSearch(): void public function testUserSearch()
{ {
$client = static::createClient(); $client = static::createClient();
$crawler = $client->request('GET', '/'); $crawler = $client->request('GET', '/');
@ -20,7 +19,7 @@ class MainControllerTest extends WebTestCase
$this->assertTrue($client->getResponse()->isRedirect('/user/testuser'), 'Redirect to testuser\'s page didn\'t happen'); $this->assertTrue($client->getResponse()->isRedirect('/user/testuser'), 'Redirect to testuser\'s page didn\'t happen');
} }
public function testNonExistingUserSearch(): void public function testNonExistingUserSearch()
{ {
$client = static::createClient(); $client = static::createClient();
$crawler = $client->request('GET', '/'); $crawler = $client->request('GET', '/');
@ -50,7 +49,7 @@ class MainControllerTest extends WebTestCase
$this->assertEquals(' Login not found', $firstError->text(), 'Incorrect error text'); $this->assertEquals(' Login not found', $firstError->text(), 'Incorrect error text');
} }
public function testUserStats(): void public function testUserStats()
{ {
$client = static::createClient(); $client = static::createClient();
$crawler = $client->request('GET', '/'); $crawler = $client->request('GET', '/');
@ -76,10 +75,14 @@ class MainControllerTest extends WebTestCase
/** /**
* Tests AJAX user search autocomplete and returns JSON response string * Tests AJAX user search autocomplete and returns JSON response string
*
* @return string
*/ */
public function testAjaxUserAutoComplete(): string public function testAjaxUserAutoComplete()
{ {
$client = $this->createClientForAjaxUserSearchByLogin('testuser'); $client = static::createClient();
// We need to search all test user with 'testuser5' included which will test the code against null-string problem in User#getName()
$client->request('GET', '/ajax/users/search/testuser');
$this->assertTrue($client->getResponse()->headers->contains('Content-Type', 'application/json'), 'Response has "Content-Type" = "application/json"'); $this->assertTrue($client->getResponse()->headers->contains('Content-Type', 'application/json'), 'Response has "Content-Type" = "application/json"');
@ -88,22 +91,26 @@ class MainControllerTest extends WebTestCase
/** /**
* @depends testAjaxUserAutoComplete * @depends testAjaxUserAutoComplete
*
* @param $json
*/ */
public function testAjaxUserAutoCompleteHasOptions(string $json): array public function testAjaxUserAutoCompleteHasOptions($json)
{ {
$data = json_decode($json, true); $data = json_decode($json);
$this->assertNotNull($data, 'JSON data successfully decoded and not empty'); $this->assertNotNull($data, 'JSON data successfully decoded and not empty');
$this->assertTrue(is_array($data), 'JSON data is array'); $this->assertTrue(is_array($data), 'JSON data is array');
$this->assertCount(2, $data, 'Array has 2 elements'); $this->assertCount(5, $data, 'Array has 5 elements');
return $data; return $data;
} }
/** /**
* @depends testAjaxUserAutoCompleteHasOptions * @depends testAjaxUserAutoCompleteHasOptions
*
* @param array $users
*/ */
public function testAjaxUserAutoCompleteHasValidUserObjects(array $users): void public function testAjaxUserAutoCompleteHasValidUserObjects(array $users)
{ {
foreach ($users as $key => $user) { foreach ($users as $key => $user) {
$this->assertTrue(array_key_exists('login', $user), sprintf('%d row of result has \'login\' field', $key)); $this->assertTrue(array_key_exists('login', $user), sprintf('%d row of result has \'login\' field', $key));
@ -111,43 +118,7 @@ class MainControllerTest extends WebTestCase
} }
} }
/** public function testAjaxUserAutoCompleteForNonExistingUser()
* Tests AJAX user search autocomplete for unnamed user and returns JSON response string
*/
public function testAjaxUserAutoCompleteForUnnamedUser(): string
{
$client = $this->createClientForAjaxUserSearchByLogin('unnamed_user');
$this->assertTrue($client->getResponse()->headers->contains('Content-Type', 'application/json'), 'Response has "Content-Type" = "application/json"');
return $client->getResponse()->getContent();
}
/**
* @depends testAjaxUserAutoCompleteForUnnamedUser
*/
public function testAjaxUserAutoCompleteHasOptionsForUnnamedUser(string $json): array
{
$data = json_decode($json, true);
$this->assertNotNull($data, 'JSON data successfully decoded and not empty');
$this->assertInternalType('array', $data, 'JSON data is array');
$this->assertCount(1, $data, 'Array has 1 elements');
return reset($data);
}
/**
* @depends testAjaxUserAutoCompleteHasOptionsForUnnamedUser
*/
public function testAjaxUserAutoCompleteHasValidUserObjectsForUnnamedUser(array $user): void
{
$this->assertTrue(array_key_exists('login', $user), 'Result has \'login\' field');
$this->assertTrue(array_key_exists('name', $user), 'Result has \'name\' field');
$this->assertEquals(true, ('' === $user['name'] || null === $user['name']), 'User name is empty string or null');
}
public function testAjaxUserAutoCompleteIsEmptyForNonExistingUser(): void
{ {
$client = static::createClient(); $client = static::createClient();
$client->request('GET', '/ajax/users/search/aksdjhaskdjhqwhdgqkjwhdgkjah'); $client->request('GET', '/ajax/users/search/aksdjhaskdjhqwhdgqkjwhdgkjah');
@ -157,15 +128,7 @@ class MainControllerTest extends WebTestCase
$data = json_decode($client->getResponse()->getContent()); $data = json_decode($client->getResponse()->getContent());
$this->assertNotNull($data, 'JSON data successfully decoded and not empty'); $this->assertNotNull($data, 'JSON data successfully decoded and not empty');
$this->assertInternalType('array', $data, 'JSON data is array'); $this->assertTrue(is_array($data), 'JSON data is array');
$this->assertCount(0, $data, 'Array has no elements'); $this->assertEquals(0, count($data), 'Array has no elements');
}
private function createClientForAjaxUserSearchByLogin(string $login): Client
{
$client = static::createClient();
$client->request('GET', '/ajax/users/search/'.$login);
return $client;
} }
} }

View file

@ -2,15 +2,15 @@
namespace Tests\Skobkin\PointToolsBundle\Controller; namespace Tests\Skobkin\PointToolsBundle\Controller;
use Skobkin\Bundle\PointToolsBundle\DataFixtures\ORM\LoadPostData; use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Bundle\FrameworkBundle\{Client, Test\WebTestCase};
use Symfony\Component\DomCrawler\Crawler; use Symfony\Component\DomCrawler\Crawler;
class PostControllerTest extends WebTestCase class PostControllerTest extends WebTestCase
{ {
public function testNonExistingPostPage() public function testNonExistingPostPage()
{ {
$client = $this->createClientForPostId('nonexistingpost'); $client = static::createClient();
$client->request('GET', '/nonexistingpost');
$this->assertTrue($client->getResponse()->isNotFound(), '404 response code for non-existing post'); $this->assertTrue($client->getResponse()->isNotFound(), '404 response code for non-existing post');
} }
@ -20,11 +20,12 @@ class PostControllerTest extends WebTestCase
*/ */
public function testShortPostPageIsOk() public function testShortPostPageIsOk()
{ {
$client = $this->createClientForPostId(LoadPostData::POST_ID_SHORT); $client = static::createClient();
$crawler = $client->request('GET', '/shortpost');
$this->assertTrue($client->getResponse()->isOk(), '200 response code for existing post'); $this->assertTrue($client->getResponse()->isOk(), '200 response code for existing post');
return $client->getCrawler(); return $crawler;
} }
/** /**
@ -57,33 +58,4 @@ class PostControllerTest extends WebTestCase
$this->assertEquals(1, $p->count(), '.post-text has zero or more than one paragraphs'); $this->assertEquals(1, $p->count(), '.post-text has zero or more than one paragraphs');
$this->assertEquals('Test short post', $p->text(), '.post-text has no correct post text'); $this->assertEquals('Test short post', $p->text(), '.post-text has no correct post text');
} }
public function testPrivateUserPostForbidden()
{
$client = $this->createClientForPostId(LoadPostData::POST_ID_PR_USER);
$this->assertTrue($client->getResponse()->isNotFound(), '404 response code for private user\'s post');
}
public function testWhitelistOnlyUserPostForbidden()
{
$client = $this->createClientForPostId(LoadPostData::POST_ID_WL_USER);
$this->assertTrue($client->getResponse()->isNotFound(), '404 response code for whitelist-only user\'s post');
}
public function testPrivateWhitelistOnlyUserPostForbidden()
{
$client = $this->createClientForPostId(LoadPostData::POST_ID_PR_WL_USER);
$this->assertTrue($client->getResponse()->isNotFound(), '404 response code for private whitelist-only user\'s post');
}
private function createClientForPostId(string $id): Client
{
$client = static::createClient();
$client->request('GET', '/'.$id);
return $client;
}
} }

View file

@ -32,7 +32,7 @@ class UserRepositoryTest extends KernelTestCase
{ {
$users = $this->userRepo->findAll(); $users = $this->userRepo->findAll();
$this->assertCount(6, $users, 'Not exactly 6 users in the databas'); $this->assertCount(5, $users, 'Not exactly 5 users in the databas');
} }
public function testFindOneByLogin() public function testFindOneByLogin()
@ -58,14 +58,14 @@ class UserRepositoryTest extends KernelTestCase
// Searching LIKE %stus% (testuserX) // Searching LIKE %stus% (testuserX)
$users = $this->userRepo->findUsersLikeLogin('stus'); $users = $this->userRepo->findUsersLikeLogin('stus');
$this->assertCount(2, $users, 'Repository found not exactly 5 users'); $this->assertCount(5, $users, 'Repository found not exactly 5 users');
} }
public function testGetUsersCount() public function testGetUsersCount()
{ {
$count = $this->userRepo->getUsersCount(); $count = $this->userRepo->getUsersCount();
$this->assertEquals(6, $count, 'Counted not exactly 5 users'); $this->assertEquals(5, $count, 'Counted not exactly 5 users');
} }
public function testFindUserSubscribersById() public function testFindUserSubscribersById()