Repository: DirectoryTree/ImapEngine (php)

Issue:
MessageInterface lacks methods provided by Message
`FakeMessage` used for testing does not provide all methods available in `Message`, such as `isSeen()`. Since it only implements `MessageInterface`, which defines a limited subset. 

With this, writing tests is impossible since the functionality required by the component is not present in `MessageInterface`

`MessageInterface` and `FakeMessage` should provide all public methods of the `Message` class so that it can be used for Mocking and testing.


**Environment**  
- PHP: 8.4  
- ImapEngine: 1.14.0



Pull request description:
Move flags to flaggable interface
Closes #83 

Patch:
diff --git a/src/FileMessage.php b/src/FileMessage.php
index 570cd18..bd39ef8 100644
--- a/src/FileMessage.php
+++ b/src/FileMessage.php
@@ -6,7 +6,7 @@ use BadMethodCallException;
 
 class FileMessage implements MessageInterface
 {
-    use HasParsedMessage;
+    use HasFlags, HasParsedMessage;
 
     /**
      * Constructor.
@@ -23,6 +23,14 @@ class FileMessage implements MessageInterface
         throw new BadMethodCallException('FileMessage does not support a UID');
     }
 
+    /**
+     * {@inheritDoc}
+     */
+    public function flag(mixed $flag, string $operation, bool $expunge = false): void
+    {
+        throw new BadMethodCallException('FileMessage does not support flagging');
+    }
+
     /**
      * Get the string representation of the message.
      */
@@ -40,6 +48,14 @@ class FileMessage implements MessageInterface
             && $this->contents === $message->contents;
     }
 
+    /**
+     * Get the message flags.
+     */
+    public function flags(): array
+    {
+        return [];
+    }
+
     /**
      * Determine if the message is empty.
      */
diff --git a/src/FlaggableInterface.php b/src/FlaggableInterface.php
new file mode 100644
index 0000000..62a0d3c
--- /dev/null
+++ b/src/FlaggableInterface.php
@@ -0,0 +1,125 @@
+<?php
+
+namespace DirectoryTree\ImapEngine;
+
+use BackedEnum;
+
+interface FlaggableInterface
+{
+    /**
+     * Mark the message as read. Alias for markSeen.
+     */
+    public function markRead(): void;
+
+    /**
+     * Mark the message as unread. Alias for unmarkSeen.
+     */
+    public function markUnread(): void;
+
+    /**
+     * Mark the message as seen.
+     */
+    public function markSeen(): void;
+
+    /**
+     * Unmark the seen flag.
+     */
+    public function unmarkSeen(): void;
+
+    /**
+     * Mark the message as answered.
+     */
+    public function markAnswered(): void;
+
+    /**
+     * Unmark the answered flag.
+     */
+    public function unmarkAnswered(): void;
+
+    /**
+     * Mark the message as flagged.
+     */
+    public function markFlagged(): void;
+
+    /**
+     * Unmark the flagged flag.
+     */
+    public function unmarkFlagged(): void;
+
+    /**
+     * Mark the message as deleted.
+     */
+    public function markDeleted(bool $expunge = false): void;
+
+    /**
+     * Unmark the deleted flag.
+     */
+    public function unmarkDeleted(): void;
+
+    /**
+     * Mark the message as a draft.
+     */
+    public function markDraft(): void;
+
+    /**
+     * Unmark the draft flag.
+     */
+    public function unmarkDraft(): void;
+
+    /**
+     * Mark the message as recent.
+     */
+    public function markRecent(): void;
+
+    /**
+     * Unmark the recent flag.
+     */
+    public function unmarkRecent(): void;
+
+    /**
+     * Determine if the message is marked as seen.
+     */
+    public function isSeen(): bool;
+
+    /**
+     * Determine if the message is marked as answered.
+     */
+    public function isAnswered(): bool;
+
+    /**
+     * Determine if the message is flagged.
+     */
+    public function isFlagged(): bool;
+
+    /**
+     * Determine if the message is marked as deleted.
+     */
+    public function isDeleted(): bool;
+
+    /**
+     * Determine if the message is marked as a draft.
+     */
+    public function isDraft(): bool;
+
+    /**
+     * Determine if the message is marked as recent.
+     */
+    public function isRecent(): bool;
+
+    /**
+     * Get the message's flags.
+     *
+     * @return string[]
+     */
+    public function flags(): array;
+
+    /**
+     * Determine if the message has the given flag.
+     */
+    public function hasFlag(BackedEnum|string $flag): bool;
+
+    /**
+     * Add or remove a flag from the message.
+     */
+    public function flag(mixed $flag, string $operation, bool $expunge = false): void;
+}
diff --git a/src/HasFlags.php b/src/HasFlags.php
new file mode 100644
index 0000000..d6e3ee8
--- /dev/null
+++ b/src/HasFlags.php
@@ -0,0 +1,188 @@
+<?php
+
+namespace DirectoryTree\ImapEngine;
+
+use BackedEnum;
+use DirectoryTree\ImapEngine\Enums\ImapFlag;
+use DirectoryTree\ImapEngine\Support\Str;
+
+trait HasFlags
+{
+    /**
+     * {@inheritDoc}
+     */
+    public function markRead(): void
+    {
+        $this->markSeen();
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    public function markUnread(): void
+    {
+        $this->unmarkSeen();
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    public function markSeen(): void
+    {
+        $this->flag(ImapFlag::Seen, '+');
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    public function unmarkSeen(): void
+    {
+        $this->flag(ImapFlag::Seen, '-');
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    public function markAnswered(): void
+    {
+        $this->flag(ImapFlag::Answered, '+');
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    public function unmarkAnswered(): void
+    {
+        $this->flag(ImapFlag::Answered, '-');
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    public function markFlagged(): void
+    {
+        $this->flag(ImapFlag::Flagged, '+');
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    public function unmarkFlagged(): void
+    {
+        $this->flag(ImapFlag::Flagged, '-');
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    public function markDeleted(bool $expunge = false): void
+    {
+        $this->flag(ImapFlag::Deleted, '+', $expunge);
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    public function unmarkDeleted(): void
+    {
+        $this->flag(ImapFlag::Deleted, '-');
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    public function markDraft(): void
+    {
+        $this->flag(ImapFlag::Draft, '+');
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    public function unmarkDraft(): void
+    {
+        $this->flag(ImapFlag::Draft, '-');
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    public function markRecent(): void
+    {
+        $this->flag(ImapFlag::Recent, '+');
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    public function unmarkRecent(): void
+    {
+        $this->flag(ImapFlag::Recent, '-');
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    public function isSeen(): bool
+    {
+        return $this->hasFlag(ImapFlag::Seen);
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    public function isAnswered(): bool
+    {
+        return $this->hasFlag(ImapFlag::Answered);
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    public function isFlagged(): bool
+    {
+        return $this->hasFlag(ImapFlag::Flagged);
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    public function isDeleted(): bool
+    {
+        return $this->hasFlag(ImapFlag::Deleted);
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    public function isDraft(): bool
+    {
+        return $this->hasFlag(ImapFlag::Draft);
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    public function isRecent(): bool
+    {
+        return $this->hasFlag(ImapFlag::Recent);
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    public function hasFlag(BackedEnum|string $flag): bool
+    {
+        return in_array(Str::enum($flag), $this->flags());
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    abstract public function flags(): array;
+
+    /**
+     * {@inheritDoc}
+     */
+    abstract public function flag(mixed $flag, string $operation, bool $expunge = false): void;
+}
diff --git a/src/Message.php b/src/Message.php
index d40f09f..10f3b62 100644
--- a/src/Message.php
+++ b/src/Message.php
@@ -2,9 +2,7 @@
 
 namespace DirectoryTree\ImapEngine;
 
-use BackedEnum;
 use DirectoryTree\ImapEngine\Connection\Responses\MessageResponseParser;
-use DirectoryTree\ImapEngine\Enums\ImapFlag;
 use DirectoryTree\ImapEngine\Exceptions\ImapCapabilityException;
 use DirectoryTree\ImapEngine\Support\Str;
 use Illuminate\Contracts\Support\Arrayable;
@@ -12,7 +10,7 @@ use JsonSerializable;
 
 class Message implements Arrayable, JsonSerializable, MessageInterface
 {
-    use HasParsedMessage;
+    use HasFlags, HasParsedMessage;
 
     /**
      * Constructor.
@@ -102,174 +100,6 @@ class Message implements Arrayable, JsonSerializable, MessageInterface
             && $this->folder->is($message->folder);
     }
 
-    /**
-     * Determine if the message is marked as seen.
-     */
-    public function isSeen(): bool
-    {
-        return $this->hasFlag(ImapFlag::Seen);
-    }
-
-    /**
-     * Determine if the message is marked as answered.
-     */
-    public function isAnswered(): bool
-    {
-        return $this->hasFlag(ImapFlag::Answered);
-    }
-
-    /**
-     * Determine if the message is flagged.
-     */
-    public function isFlagged(): bool
-    {
-        return $this->hasFlag(ImapFlag::Flagged);
-    }
-
-    /**
-     * Determine if the message is marked as deleted.
-     */
-    public function isDeleted(): bool
-    {
-        return $this->hasFlag(ImapFlag::Deleted);
-    }
-
-    /**
-     * Determine if the message is marked as a draft.
-     */
-    public function isDraft(): bool
-    {
-        return $this->hasFlag(ImapFlag::Draft);
-    }
-
-    /**
-     * Determine if the message is marked as recent.
-     */
-    public function isRecent(): bool
-    {
-        return $this->hasFlag(ImapFlag::Recent);
-    }
-
-    /**
-     * Determine if the message has the given flag.
-     */
-    public function hasFlag(BackedEnum|string $flag): bool
-    {
-        return in_array(Str::enum($flag), $this->flags);
-    }
-
-    /**
-     * Mark the message as read. Alias for markSeen.
-     */
-    public function markRead(): void
-    {
-        $this->markSeen();
-    }
-
-    /**
-     * Mark the message as unread. Alias for unmarkSeen.
-     */
-    public function markUnread(): void
-    {
-        $this->unmarkSeen();
-    }
-
-    /**
-     * Mark the message as seen.
-     */
-    public function markSeen(): void
-    {
-        $this->flag(ImapFlag::Seen, '+');
-    }
-
-    /**
-     * Unmark the seen flag.
-     */
-    public function unmarkSeen(): void
-    {
-        $this->flag(ImapFlag::Seen, '-');
-    }
-
-    /**
-     * Mark the message as answered.
-     */
-    public function markAnswered(): void
-    {
-        $this->flag(ImapFlag::Answered, '+');
-    }
-
-    /**
-     * Unmark the answered flag.
-     */
-    public function unmarkAnswered(): void
-    {
-        $this->flag(ImapFlag::Answered, '-');
-    }
-
-    /**
-     * Mark the message as flagged.
-     */
-    public function markFlagged(): void
-    {
-        $this->flag(ImapFlag::Flagged, '+');
-    }
-
-    /**
-     * Unmark the flagged flag.
-     */
-    public function unmarkFlagged(): void
-    {
-        $this->flag(ImapFlag::Flagged, '-');
-    }
-
-    /**
-     * Mark the message as deleted.
-     */
-    public function markDeleted(bool $expunge = false): void
-    {
-        $this->flag(ImapFlag::Deleted, '+', $expunge);
-    }
-
-    /**
-     * Unmark the deleted flag.
-     */
-    public function unmarkDeleted(): void
-    {
-        $this->flag(ImapFlag::Deleted, '-');
-    }
-
-    /**
-     * Mark the message as a draft.
-     */
-    public function markDraft(): void
-    {
-        $this->flag(ImapFlag::Draft, '+');
-    }
-
-    /**
-     * Unmark the draft flag.
-     */
-    public function unmarkDraft(): void
-    {
-        $this->flag(ImapFlag::Draft, '-');
-    }
-
-    /**
-     * Mark the message as recent.
-     */
-    public function markRecent(): void
-    {
-        $this->flag(ImapFlag::Recent, '+');
-    }
-
-    /**
-     * Unmark the recent flag.
-     */
-    public function unmarkRecent(): void
-    {
-        $this->flag(ImapFlag::Recent, '-');
-    }
-
     /**
      * Add or remove a flag from the message.
      */
diff --git a/src/MessageInterface.php b/src/MessageInterface.php
index 610828f..75b42ba 100644
--- a/src/MessageInterface.php
+++ b/src/MessageInterface.php
@@ -7,7 +7,7 @@ use Stringable;
 use ZBateson\MailMimeParser\Header\IHeader;
 use ZBateson\MailMimeParser\Message as MailMimeMessage;
 
-interface MessageInterface extends Stringable
+interface MessageInterface extends FlaggableInterface, Stringable
 {
     /**
      * Get the message's identifier.


Test patch:
diff --git a/src/Testing/FakeMessage.php b/src/Testing/FakeMessage.php
index 564897b..a08e447 100644
--- a/src/Testing/FakeMessage.php
+++ b/src/Testing/FakeMessage.php
@@ -2,12 +2,14 @@
 
 namespace DirectoryTree\ImapEngine\Testing;
 
+use DirectoryTree\ImapEngine\HasFlags;
 use DirectoryTree\ImapEngine\HasParsedMessage;
 use DirectoryTree\ImapEngine\MessageInterface;
+use DirectoryTree\ImapEngine\Support\Str;
 
 class FakeMessage implements MessageInterface
 {
-    use HasParsedMessage;
+    use HasFlags, HasParsedMessage;
 
     /**
      * Constructor.
@@ -37,6 +39,28 @@ class FakeMessage implements MessageInterface
             && $this->contents === $message->contents;
     }
 
+    /**
+     * {@inheritDoc}
+     */
+    public function flag(mixed $flag, string $operation, bool $expunge = false): void
+    {
+        $flag = Str::enum($flag);
+
+        if ($operation === '+') {
+            $this->flags = array_unique([...$this->flags, $flag]);
+        } else {
+            $this->flags = array_filter($this->flags, fn (string $value) => $value !== $flag);
+        }
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    public function flags(): array
+    {
+        return $this->flags;
+    }
+
     /**
      * {@inheritDoc}
      */
diff --git a/tests/Unit/FileMessageTest.php b/tests/Unit/FileMessageTest.php
index e81db11..c1b1890 100644
--- a/tests/Unit/FileMessageTest.php
+++ b/tests/Unit/FileMessageTest.php
@@ -1,6 +1,7 @@
 <?php
 
 use DirectoryTree\ImapEngine\Attachment;
+use DirectoryTree\ImapEngine\Enums\ImapFlag;
 use DirectoryTree\ImapEngine\Exceptions\RuntimeException;
 use DirectoryTree\ImapEngine\FileMessage;
 
@@ -10,6 +11,24 @@ test('it throws an exception if the message is empty', function () {
     $message->parse();
 })->throws(RuntimeException::class);
 
+test('it throws exception when uid is called', function () {
+    $message = new FileMessage('test');
+
+    $message->uid();
+})->throws(BadMethodCallException::class);
+
+test('it throws exception when flag is called', function () {
+    $message = new FileMessage('test');
+
+    $message->flag(ImapFlag::Seen, '+');
+})->throws(BadMethodCallException::class);
+
+test('it returns empty flags', function () {
+    $message = new FileMessage('test');
+
+    expect($message->flags())->toBe([]);
+});
+
 test('it can parse a standard EML message and read basic headers', function () {
     $contents = <<<'EOT'
     From: "John Doe" <john@example.com>
diff --git a/tests/Unit/Testing/FakeMessageTest.php b/tests/Unit/Testing/FakeMessageTest.php
index 33c0e53..84981cc 100644
--- a/tests/Unit/Testing/FakeMessageTest.php
+++ b/tests/Unit/Testing/FakeMessageTest.php
@@ -1,5 +1,6 @@
 <?php
 
+use DirectoryTree\ImapEngine\Enums\ImapFlag;
 use DirectoryTree\ImapEngine\Testing\FakeMessage;
 
 test('it can be created with basic properties', function () {
@@ -73,3 +74,47 @@ test('it can determine if two messages are the same', function () {
     // Different content
     expect($message1->is($message5))->toBeFalse();
 });
+
+test('it can add flags using flag method', function () {
+    $message = new FakeMessage(1, [], 'Test content');
+
+    expect($message->flags())->toBe([]);
+    expect($message->isSeen())->toBeFalse();
+    expect($message->isFlagged())->toBeFalse();
+
+    // Add Seen flag
+    $message->flag('\\Seen', '+');
+    expect($message->flags())->toContain('\\Seen');
+    expect($message->hasFlag(ImapFlag::Seen))->toBeTrue();
+    expect($message->isSeen())->toBeTrue();
+
+    // Add Flagged flag
+    $message->flag('\\Flagged', '+');
+    expect($message->flags())->toContain('\\Flagged');
+    expect($message->hasFlag(ImapFlag::Flagged))->toBeTrue();
+    expect($message->isFlagged())->toBeTrue();
+    expect($message->flags())->toHaveCount(2);
+});
+
+test('it can remove flags using flag method', function () {
+    $message = new FakeMessage(1, ['\\Seen', '\\Flagged'], 'Test content');
+
+    expect($message->flags())->toContain('\\Seen');
+    expect($message->flags())->toContain('\\Flagged');
+    expect($message->hasFlag(ImapFlag::Seen))->toBeTrue();
+    expect($message->hasFlag(ImapFlag::Flagged))->toBeTrue();
+    expect($message->isSeen())->toBeTrue();
+    expect($message->isFlagged())->toBeTrue();
+
+    // Remove Seen flag
+    $message->flag('\\Seen', '-');
+    expect($message->flags())->not->toContain('\\Seen');
+    expect($message->isSeen())->toBeFalse();
+    expect($message->isFlagged())->toBeTrue();
+
+    // Remove Flagged flag
+    $message->flag('\\Flagged', '-');
+    expect($message->flags())->not->toContain('\\Flagged');
+    expect($message->isFlagged())->toBeFalse();
+    expect($message->flags())->toBeEmpty();
+});