Repository: metricq/aiocouch

Issue:
Support for Database _changes
Hello,

Are there any plans to support CouchDB's [/db/_changes](https://docs.couchdb.org/en/stable/api/database/changes.html) API?

Discussion:
codecov[bot]: # [Codecov](https://codecov.io/gh/metricq/aiocouch/pull/41?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=metricq) Report
> Merging [#41](https://codecov.io/gh/metricq/aiocouch/pull/41?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=metricq) (aba6b6c) into [master](https://codecov.io/gh/metricq/aiocouch/commit/017296c350522f5586e5cc7c755bf2ed40a57c1d?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=metricq) (017296c) will **decrease** coverage by `0.61%`.
> The diff coverage is `90.36%`.

[![Impacted file tree graph](https://codecov.io/gh/metricq/aiocouch/pull/41/graphs/tree.svg?width=650&height=150&src=pr&token=KVNMvzg1oj&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=metricq)](https://codecov.io/gh/metricq/aiocouch/pull/41?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=metricq)

```diff
@@            Coverage Diff             @@
##           master      #41      +/-   ##
==========================================
- Coverage   96.85%   96.23%   -0.62%     
==========================================
  Files          11       13       +2     
  Lines         858      930      +72     
==========================================
+ Hits          831      895      +64     
- Misses         27       35       +8     
```


| [Impacted Files](https://codecov.io/gh/metricq/aiocouch/pull/41?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=metricq) | Coverage Δ | |
|---|---|---|
| [aiocouch/exception.py](https://codecov.io/gh/metricq/aiocouch/pull/41/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=metricq#diff-YWlvY291Y2gvZXhjZXB0aW9uLnB5) | `95.00% <78.57%> (-3.51%)` | :arrow_down: |
| [aiocouch/remote.py](https://codecov.io/gh/metricq/aiocouch/pull/41/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=metricq#diff-YWlvY291Y2gvcmVtb3RlLnB5) | `95.42% <82.60%> (-1.08%)` | :arrow_down: |
| [aiocouch/event.py](https://codecov.io/gh/metricq/aiocouch/pull/41/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=metricq#diff-YWlvY291Y2gvZXZlbnQucHk=) | `96.29% <96.29%> (ø)` | |
| [aiocouch/database.py](https://codecov.io/gh/metricq/aiocouch/pull/41/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=metricq#diff-YWlvY291Y2gvZGF0YWJhc2UucHk=) | `100.00% <100.00%> (ø)` | |
| [aiocouch/document.py](https://codecov.io/gh/metricq/aiocouch/pull/41/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=metricq#diff-YWlvY291Y2gvZG9jdW1lbnQucHk=) | `90.80% <100.00%> (+0.10%)` | :arrow_up: |
| [aiocouch/typing.py](https://codecov.io/gh/metricq/aiocouch/pull/41/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=metricq#diff-YWlvY291Y2gvdHlwaW5nLnB5) | `100.00% <100.00%> (ø)` | |

------

[Continue to review full report at Codecov](https://codecov.io/gh/metricq/aiocouch/pull/41?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=metricq).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=metricq)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/metricq/aiocouch/pull/41?src=pr&el=footer&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=metricq). Last update [017296c...aba6b6c](https://codecov.io/gh/metricq/aiocouch/pull/41?src=pr&el=lastupdated&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=metricq). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=metricq).


Patch:
diff --git a/aiocouch/database.py b/aiocouch/database.py
index 8313bb2..774861e 100644
--- a/aiocouch/database.py
+++ b/aiocouch/database.py
@@ -34,7 +34,6 @@ from typing import (
     AsyncContextManager,
     AsyncGenerator,
     Callable,
-    Dict,
     List,
     Optional,
     TypeVar,
@@ -44,13 +43,14 @@ from . import couchdb
 from .bulk import BulkCreateOperation, BulkUpdateOperation
 from .design_document import DesignDocument
 from .document import Document, SecurityDocument
+from .event import BaseChangeEvent, ChangedEvent, DeletedEvent
 from .exception import ConflictError, NotFoundError
 from .remote import RemoteDatabase
 from .request import FindRequest
+from .typing import JsonDict
 from .view import AllDocsView, View
 
 FuncT = TypeVar("FuncT", bound=Callable[..., Any])
-JsonDict = Dict[str, Any]
 
 
 def _returns_async_context_manager(f: FuncT) -> FuncT:
@@ -278,11 +278,15 @@ class Database(RemoteDatabase):
         """
         return await self.get(id)
 
-    async def get(self, id: str, default: Optional[JsonDict] = None) -> Document:
+    async def get(
+        self, id: str, default: Optional[JsonDict] = None, *, rev: Optional[str] = None
+    ) -> Document:
         """Returns the document with the given id
 
         :raises `~aiocouch.NotFoundError`: if the given document does not exist and
             `default` is `None`
+        :raises `~aiocouch.BadRequestError`: if the given rev of the document is
+            invalid or missing
 
 
         :param id: the name of the document
@@ -291,13 +295,15 @@ class Database(RemoteDatabase):
             `default` as its contents, is returned. To create the document on the
             server, :meth:`~aiocouch.document.Document.save` has to be called on the
             returned instance.
+        :param rev: The requested rev of the document. The requested rev might not
+            or not anymore exist on the connected server.
         :return: a local representation of the requested document
 
         """
         doc = Document(self, id, data=default)
 
         try:
-            await doc.fetch(discard_changes=True)
+            await doc.fetch(discard_changes=True, rev=rev)
         except NotFoundError as e:
             if default is None:
                 raise e
@@ -319,3 +325,27 @@ class Database(RemoteDatabase):
 
         """
         return await self._get()
+
+    async def changes(
+        self, last_event_id: Optional[str] = None, **params: Any
+    ) -> AsyncGenerator[BaseChangeEvent, None]:
+        """Listens for events made to documents of this database
+
+        This will return :class:`~aiocouch.event.DeletedEvent` and
+        :class:`~aiocouch.event.ChangedEvent` for deleted and modified
+        documents, respectively.
+
+        See also :ref:`/db/_changes<couchdb:api/db/changes>`.
+
+        For convinience, the ``last-event-id`` parameter can also be passed
+        as ``last_event_id``.
+
+        """
+        if last_event_id and "last-event-id" not in params:
+            params["last-event-id"] = last_event_id
+
+        async for json in self._changes(**params):
+            if "deleted" in json and json["deleted"] is True:
+                yield DeletedEvent(json=json)
+            else:
+                yield ChangedEvent(database=self, json=json)
diff --git a/aiocouch/document.py b/aiocouch/document.py
index ff079bf..ffc7cbe 100644
--- a/aiocouch/document.py
+++ b/aiocouch/document.py
@@ -31,17 +31,7 @@
 import json
 from contextlib import suppress
 from types import TracebackType
-from typing import (
-    Any,
-    Dict,
-    ItemsView,
-    KeysView,
-    List,
-    Optional,
-    Type,
-    ValuesView,
-    cast,
-)
+from typing import Any, ItemsView, KeysView, List, Optional, Type, ValuesView, cast
 
 from deprecated import deprecated
 
@@ -49,8 +39,7 @@ from . import database
 from .attachment import Attachment
 from .exception import ConflictError, ForbiddenError, NotFoundError, raises
 from .remote import RemoteDocument
-
-JsonDict = Dict[str, Any]
+from .typing import JsonDict
 
 
 class Document(RemoteDocument):
@@ -109,17 +98,22 @@ class Document(RemoteDocument):
             json.dumps(self._data, sort_keys=True)
         )
 
-    async def fetch(self, discard_changes: bool = False) -> None:
+    async def fetch(
+        self, discard_changes: bool = False, *, rev: Optional[str] = None
+    ) -> None:
         """Retrieves the document data from the server
 
         Fetching the document will retrieve the data from the server using a network
         request and update the local data.
 
         :raises ~aiocouch.ConflictError: if the local data has changed without saving
+        :raises ~aiocouch.BadRequestError: if the given rev is invalid or missing
 
         :param discard_changes: If set to `True`, the local data object will the
             overridden with the retrieved content. If the local data was changed, no
             exception will be raised.
+        :param rev: The requested rev of the document. The requested rev might not
+            or not anymore exist on the connected server.
 
         """
         if self._dirty_cache and not (discard_changes or self._fresh):
@@ -127,7 +121,11 @@ class Document(RemoteDocument):
                 f"Cannot fetch document '{self.id}' from server, "
                 "as the local cache has unsaved changes."
             )
-        self._update_cache(await self._get())
+
+        if rev:
+            self._update_cache(await self._get(rev=rev))
+        else:
+            self._update_cache(await self._get())
 
     async def save(self) -> None:
         """Saves the current state to the CouchDB server
diff --git a/aiocouch/event.py b/aiocouch/event.py
new file mode 100644
index 0000000..0c2936e
--- /dev/null
+++ b/aiocouch/event.py
@@ -0,0 +1,91 @@
+# Copyright (c) 2021, ZIH,
+# Technische Universitaet Dresden,
+# Federal Republic of Germany
+#
+# All rights reserved.
+#
+# Redistribution and use in source and binary forms, with or without modification,
+# are permitted provided that the following conditions are met:
+#
+#     * Redistributions of source code must retain the above copyright notice,
+#       this list of conditions and the following disclaimer.
+#     * Redistributions in binary form must reproduce the above copyright notice,
+#       this list of conditions and the following disclaimer in the documentation
+#       and/or other materials provided with the distribution.
+#     * Neither the name of metricq nor the names of its contributors
+#       may be used to endorse or promote products derived from this software
+#       without specific prior written permission.
+#
+# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
+# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
+# EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
+# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
+# PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
+# LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
+# NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+
+from typing import cast
+
+from attr import dataclass
+
+from . import database as db
+from . import document
+from .typing import JsonDict
+
+
+@dataclass
+class BaseChangeEvent:
+    """The base event for shared properties"""
+
+    json: JsonDict
+    """The raw data of the event as JSON"""
+
+    @property
+    def id(self) -> str:
+        """Returns the id of the document"""
+        return cast(str, self.json["id"])
+
+    @property
+    def rev(self) -> str:
+        """Returns the new rev of the document"""
+        return cast(str, self.json["changes"][0]["rev"])
+
+    @property
+    def sequence(self) -> str:
+        """Returns the sequence identifier of the event"""
+        return cast(str, self.json["seq"])
+
+
+class DeletedEvent(BaseChangeEvent):
+    """This event denotes that the document got deleted"""
+
+    pass
+
+
+@dataclass
+class ChangedEvent(BaseChangeEvent):
+    """This event denotes that the document got modified"""
+
+    database: "db.Database"
+    """The database for reference"""
+
+    async def doc(self) -> "document.Document":
+        """Returns the document after the change
+
+        If the ``include_docs`` was set, this will use the data provided in the received event.
+        Otherwise, the document is fetched from the server.
+        """
+        try:
+            # if in the request include_docs was given, we can create the
+            # document on the spot...
+            return document.Document(
+                self.database, self.json["doc"]["_id"], self.json["doc"]
+            )
+        except KeyError:
+            # ...otherwise, we fetch the document contents from the server
+            return await self.database.get(self.id, rev=self.rev)
diff --git a/aiocouch/exception.py b/aiocouch/exception.py
index 7aa52f6..e6947bb 100644
--- a/aiocouch/exception.py
+++ b/aiocouch/exception.py
@@ -30,7 +30,17 @@
 
 import functools
 from contextlib import suppress
-from typing import Any, Callable, Dict, NoReturn, Optional, Type, TypeVar, cast
+from typing import (
+    Any,
+    AsyncGenerator,
+    Callable,
+    Dict,
+    NoReturn,
+    Optional,
+    Type,
+    TypeVar,
+    cast,
+)
 
 import aiohttp
 from typing_extensions import Protocol
@@ -163,3 +173,24 @@ def raises(
         return cast(FuncT, wrapper)
 
     return decorator_raises
+
+
+def generator_raises(
+    status: int, message: str, exception_type: Optional[Type[Exception]] = None
+) -> Callable[[FuncT], FuncT]:
+    def decorator_raises(func: FuncT) -> FuncT:
+        @functools.wraps(func)
+        async def wrapper(
+            endpoint: Endpoint, *args: Any, **kwargs: Any
+        ) -> AsyncGenerator[Any, None]:
+            try:
+                async for data in func(endpoint, *args, **kwargs):
+                    yield data
+            except aiohttp.ClientResponseError as exception:
+                if status == exception.status:
+                    raise_for_endpoint(endpoint, message, exception, exception_type)
+                raise exception
+
+        return cast(FuncT, wrapper)
+
+    return decorator_raises
diff --git a/aiocouch/remote.py b/aiocouch/remote.py
index dead370..abb8a9e 100644
--- a/aiocouch/remote.py
+++ b/aiocouch/remote.py
@@ -29,15 +29,17 @@
 # SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 
 import asyncio
-from typing import Any, Dict, List, Optional, Tuple, Union, cast
+import json
+from contextlib import suppress
+from typing import Any, AsyncGenerator, List, Optional, Tuple, Union, cast
 from urllib.parse import quote
 
 import aiohttp
 
 from . import database, document
-from .exception import NotFoundError, raises
+from .exception import NotFoundError, generator_raises, raises
+from .typing import JsonDict
 
-JsonDict = Dict[str, Any]
 RequestResult = Tuple[JsonDict, Union[bytes, JsonDict]]
 
 
@@ -145,6 +147,26 @@ class RemoteServer:
                 await resp.json() if return_json else await resp.read(),
             )
 
+    async def _streamed_request(
+        self,
+        method: str,
+        path: str,
+        params: Optional[JsonDict] = None,
+        **kwargs: Any,
+    ) -> AsyncGenerator[JsonDict, None]:
+        kwargs["params"] = _stringify_params(params) if params else {}
+        kwargs.setdefault("timeout", aiohttp.ClientTimeout())
+
+        async with self._http_session.request(
+            method, url=f"{self._server}{path}", **kwargs
+        ) as resp:
+            resp.raise_for_status()
+
+            async for line in resp.content:
+                # this should only happen for empty lines
+                with suppress(json.JSONDecodeError):
+                    yield json.loads(line)
+
     @raises(401, "Invalid credentials")
     async def _all_dbs(self, **params: Any) -> List[str]:
         _, json = await self._get("/_all_dbs", params)
@@ -271,6 +293,22 @@ class RemoteDatabase:
         assert not isinstance(json, bytes)
         return json
 
+    @generator_raises(400, "Invalid request")
+    async def _changes(self, **params: Any) -> AsyncGenerator[JsonDict, None]:
+        if "feed" in params and params["feed"] == "continuous":
+            params.setdefault("heartbeat", True)
+            async for data in self._remote._streamed_request(
+                "GET", f"{self.endpoint}/_changes", params=params
+            ):
+                yield data
+        else:
+            _, json = await self._remote._get(
+                f"{self.endpoint}/_changes", params=params
+            )
+            assert not isinstance(json, bytes)
+            for result in json["results"]:
+                yield result
+
 
 class RemoteDocument:
     def __init__(self, database: "database.Database", id: str):
diff --git a/aiocouch/typing.py b/aiocouch/typing.py
new file mode 100644
index 0000000..b43fb55
--- /dev/null
+++ b/aiocouch/typing.py
@@ -0,0 +1,35 @@
+# Copyright (c) 2021, ZIH,
+# Technische Universitaet Dresden,
+# Federal Republic of Germany
+#
+# All rights reserved.
+#
+# Redistribution and use in source and binary forms, with or without modification,
+# are permitted provided that the following conditions are met:
+#
+#     * Redistributions of source code must retain the above copyright notice,
+#       this list of conditions and the following disclaimer.
+#     * Redistributions in binary form must reproduce the above copyright notice,
+#       this list of conditions and the following disclaimer in the documentation
+#       and/or other materials provided with the distribution.
+#     * Neither the name of metricq nor the names of its contributors
+#       may be used to endorse or promote products derived from this software
+#       without specific prior written permission.
+#
+# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
+# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
+# EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
+# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
+# PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
+# LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
+# NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+from typing import Any, Dict, List, Union
+
+JsonDict = Dict[str, Any]
+JsonList = List[Any]
+Json = Union[JsonDict, JsonList]
diff --git a/docs/database.rst b/docs/database.rst
index 769bd8f..5d21913 100644
--- a/docs/database.rst
+++ b/docs/database.rst
@@ -59,3 +59,12 @@ Reference
 .. autoclass:: aiocouch.database.Database
     :members:
     :special-members: __getitem__
+
+.. autoclass:: aiocouch.event.BaseChangeEvent
+    :members:
+
+.. autoclass:: aiocouch.event.ChangedEvent
+    :members:
+
+.. autoclass:: aiocouch.event.DeletedEvent
+    :members:
diff --git a/examples/change_events.py b/examples/change_events.py
new file mode 100644
index 0000000..281f0e7
--- /dev/null
+++ b/examples/change_events.py
@@ -0,0 +1,24 @@
+import asyncio
+import uuid
+
+from aiocouch import CouchDB
+from aiocouch.event import ChangedEvent, DeletedEvent
+
+
+async def main_with() -> None:
+
+    async with CouchDB(
+        "http://localhost:5984", user="admin", password="admin"
+    ) as couchdb:
+        database = await couchdb["test"]
+
+        async for event in database.changes(include_docs=True):
+            if isinstance(event, DeletedEvent):
+                print(f"The document {event.id} was deleted.")
+            elif isinstance(event, ChangedEvent):
+                print(f"The document {event.id} was saved as {event.rev}")
+
+
+if __name__ == "__main__":
+    loop = asyncio.get_event_loop()
+    loop.run_until_complete(main_with())


Test patch:
diff --git a/tests/conftest.py b/tests/conftest.py
index 135fbed..245021a 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -3,7 +3,7 @@ from typing import Any, AsyncGenerator, Optional
 
 import pytest
 
-from aiocouch import CouchDB, Database, Document
+from aiocouch import CouchDB, Database, Document, NotFoundError
 
 
 @pytest.fixture
diff --git a/tests/test_changes.py b/tests/test_changes.py
new file mode 100644
index 0000000..d258703
--- /dev/null
+++ b/tests/test_changes.py
@@ -0,0 +1,189 @@
+import asyncio
+from typing import Any, List, cast
+
+import pytest
+
+from aiocouch.database import Database
+from aiocouch.document import Document
+from aiocouch.event import BaseChangeEvent, ChangedEvent, DeletedEvent
+
+# All test coroutines will be treated as marked.
+pytestmark = pytest.mark.asyncio
+
+
+async def listen_for_first_change(database: Database, **kwargs: Any) -> BaseChangeEvent:
+    async for event in database.changes(**kwargs, feed="continuous", since="now"):
+        return event
+
+    assert False
+
+
+async def listen_for_changes(
+    database: Database, **kwargs: Any
+) -> List[BaseChangeEvent]:
+    return [event async for event in database.changes(**kwargs)]
+
+
+async def test_changed_event_for_new_document(database: Database) -> None:
+    async def create_doc(database: Database) -> Document:
+        await asyncio.sleep(0.1)
+        doc = await database.create("foo", data={"Zebras": "are pants"})
+        await doc.save()
+
+        return doc
+
+    results = await asyncio.gather(
+        listen_for_first_change(database), create_doc(database)
+    )
+
+    event = results[0]
+    doc = results[1]
+
+    assert isinstance(event, ChangedEvent)
+    assert event.id == "foo"
+    assert event.rev == doc.rev
+
+    doc2 = await event.doc()
+    assert doc2.id == "foo"
+    assert doc2.rev == event.rev
+    assert "Zebras" in doc2
+
+
+async def test_changed_event_for_existing_doc(filled_database: Database) -> None:
+    async def update_doc(doc: Document) -> None:
+        await asyncio.sleep(0.1)
+        doc["Zebras"] = "are black with white stripes"
+        await doc.save()
+
+    doc = await filled_database["foo"]
+
+    results = await asyncio.gather(
+        listen_for_first_change(filled_database), update_doc(doc)
+    )
+
+    event = results[0]
+
+    assert isinstance(event, ChangedEvent)
+    assert event.id == "foo"
+    assert event.rev == doc.rev
+
+    doc2 = await event.doc()
+    assert doc2.id == "foo"
+    assert doc2.rev == event.rev
+    assert "Zebras" in doc2
+
+
+async def test_polling(filled_database: Database) -> None:
+    old_events = await listen_for_changes(filled_database, feed="normal")
+    last_event_id = old_events[-1].sequence if len(old_events) else None
+
+    doc = await filled_database["foo"]
+    doc["Zebras"] = "are black with white stripes"
+    await doc.save()
+
+    events = await listen_for_changes(
+        filled_database, feed="normal", last_event_id=last_event_id
+    )
+
+    assert len(events) == 1
+    event = events[0]
+
+    assert isinstance(event, ChangedEvent)
+    assert event.id == "foo"
+    assert event.rev == doc.rev
+
+    doc2 = await event.doc()
+    assert doc2.id == "foo"
+    assert doc2.rev == event.rev
+    assert "Zebras" in doc2
+
+
+async def test_deleted_event(filled_database: Database) -> None:
+    async def delete_doc(doc: Document) -> None:
+        await asyncio.sleep(0.1)
+
+        await doc.delete()
+
+    doc = await filled_database["foo"]
+
+    results = await asyncio.gather(
+        listen_for_first_change(filled_database), delete_doc(doc)
+    )
+
+    event = results[0]
+
+    assert isinstance(event, DeletedEvent)
+    assert event.id == "foo"
+    assert event.rev.startswith("2-")
+    assert event.sequence is not None
+
+
+async def test_changed_event_not_include_docs(filled_database: Database) -> None:
+    async def update_doc(doc: Document) -> None:
+        await asyncio.sleep(0.1)
+        doc["Zebras"] = "are black with white stripes"
+        await doc.save()
+
+    doc = await filled_database["foo"]
+
+    results = await asyncio.gather(
+        listen_for_first_change(filled_database), update_doc(doc)
+    )
+
+    event = results[0]
+
+    assert isinstance(event, ChangedEvent)
+    assert event.id == "foo"
+    assert event.rev == doc.rev
+    assert event.sequence is not None
+
+    doc2 = await event.doc()
+
+    assert doc2.id == "foo"
+    assert doc2.rev == event.rev
+    assert "Zebras" in doc2
+    assert doc2["Zebras"] == "are black with white stripes"
+
+    # DO NOT DO THIS, EVIL INTERAL HACK DONE FOR TESTING ONLY
+    event.database = cast(Database, None)
+
+    # As we set the database to None, it either gets the Document instance
+    # from the local json response, or fails trying to gather it from the remote
+    with pytest.raises(AttributeError):
+        await event.doc()
+
+
+async def test_changed_event_include_docs(filled_database: Database) -> None:
+    async def update_doc(doc: Document) -> None:
+        await asyncio.sleep(0.1)
+        doc["Zebras"] = "are black with white stripes"
+        await doc.save()
+
+    doc = await filled_database["foo"]
+
+    results = await asyncio.gather(
+        listen_for_first_change(filled_database, include_docs=True), update_doc(doc)
+    )
+
+    event = results[0]
+
+    assert isinstance(event, ChangedEvent)
+    assert event.id == "foo"
+    assert event.rev == doc.rev
+    assert event.sequence is not None
+
+    assert "doc" in event.json
+
+    doc2 = await event.doc()
+
+    assert doc2.id == "foo"
+    assert doc2.rev == event.rev
+    assert "Zebras" in doc2
+    assert doc2["Zebras"] == "are black with white stripes"
+
+    # DO NOT DO THIS, EVIL INTERAL HACK DONE FOR TESTING ONLY
+    event.database = cast(Database, None)
+
+    # As we set the database to None, it either gets the Document instance
+    # from the local json response, or fails trying to gather it from the remote
+    await event.doc()
diff --git a/tests/test_database.py b/tests/test_database.py
index cac4871..48c5fb8 100644
--- a/tests/test_database.py
+++ b/tests/test_database.py
@@ -103,6 +103,13 @@ async def test_get_for_non_existing(database: Database) -> None:
         await database.get("foo")
 
 
+async def test_get_with_bad_rev(filled_database: Database) -> None:
+    from aiocouch.exception import BadRequestError
+
+    with pytest.raises(BadRequestError):
+        await filled_database.get("foo", rev="dgsahfgesogfesgfh")
+
+
 async def test_get_for_non_existing_with_empty_default(database: Database) -> None:
     doc = await database.get("foo", default={})
 
diff --git a/tests/test_document.py b/tests/test_document.py
index 3721100..ee480de 100644
--- a/tests/test_document.py
+++ b/tests/test_document.py
@@ -264,6 +264,21 @@ async def test_delete_dirty(filled_database: Database) -> None:
     with pytest.raises(ConflictError):
         await doc.delete()
 
+    assert doc.rev
+    assert doc.rev.startswith("1-")
+
+
+async def test_safe_deleted(filled_database: Database) -> None:
+    doc = await filled_database["foo"]
+
+    await doc.delete()
+
+    doc["Zebras"] = "are majestic"
+    await doc.save()
+
+    assert doc.rev
+    assert doc.rev.startswith("3-")
+
 
 async def test_copy(filled_database: Database) -> None:
     foo = await filled_database["foo"]
@@ -384,6 +399,24 @@ async def test_cache(doc: Document) -> None:
     assert doc._dirty_cache is False
 
 
+async def test_fetch_rev(filled_database: Database) -> None:
+    doc = await filled_database.get("foo")
+    old_rev = doc.rev
+
+    doc["Zebras"] = "are the best"
+    await doc.save()
+
+    assert doc.rev != old_rev
+
+    await doc.fetch(rev=old_rev)
+
+    assert doc.rev == old_rev
+    assert "Zebras" not in doc
+
+    doc2 = await filled_database.get("foo", rev=old_rev)
+    assert doc.data == doc2.data
+
+
 async def test_security_document_context_manager(database: Database) -> None:
     from aiocouch.document import SecurityDocument
 
diff --git a/tests/test_exception.py b/tests/test_exception.py
index 6b0b955..ee21d60 100644
--- a/tests/test_exception.py
+++ b/tests/test_exception.py
@@ -1,9 +1,10 @@
-from typing import NoReturn, cast
+from typing import AsyncGenerator, NoReturn, cast
 
 import pytest
 from aiohttp.client import RequestInfo
 from aiohttp.client_exceptions import ClientResponseError
 
+from aiocouch.couchdb import JsonDict
 from aiocouch.exception import (
     BadRequestError,
     ConflictError,
@@ -13,6 +14,7 @@ from aiocouch.exception import (
     PreconditionFailedError,
     UnauthorizedError,
     UnsupportedMediaTypeError,
+    generator_raises,
     raises,
 )
 
@@ -65,6 +67,11 @@ class DummyEndpoint:
     async def raise_custom(self) -> NoReturn:
         raise ClientResponseError(cast(RequestInfo, None), (), status=500)
 
+    @generator_raises(400, "bad thing")
+    async def raise_in_generator(self) -> AsyncGenerator[JsonDict, None]:
+        raise ClientResponseError(cast(RequestInfo, None), (), status=400)
+        yield None
+
 
 async def test_raises() -> None:
     dummy = DummyEndpoint()
@@ -94,3 +101,7 @@ async def test_raises() -> None:
 
     with pytest.raises(CustomError):
         await dummy.raise_custom()
+
+    with pytest.raises(BadRequestError):
+        async for _ in dummy.raise_in_generator():
+            pass
diff --git a/tests/test_remote.py b/tests/test_remote.py
index 9ab83d4..3184208 100644
--- a/tests/test_remote.py
+++ b/tests/test_remote.py
@@ -1,7 +1,7 @@
 def test_stringify_params() -> None:
     from aiocouch.remote import _stringify_params
 
-    assert _stringify_params(None) == None
+    assert _stringify_params(None) is None
     assert _stringify_params({}) == {}
 
     params = {"foo": "bar", "baz": True, "boo": False, "foz": None}