server/general: add entity versions

This commit is contained in:
rr- 2016-08-06 21:16:39 +02:00
parent f4ea0d84ad
commit 8d04df38fd
37 changed files with 313 additions and 67 deletions

115
API.md
View File

@ -11,6 +11,7 @@
- [File uploads](#file-uploads)
- [Error handling](#error-handling)
- [Field selecting](#field-selecting)
- [Versioning](#versioning)
2. [API reference](#api-reference)
@ -140,6 +141,29 @@ should send a `GET` query like this:
GET /posts/?fields=id,tags
```
## Versioning
To prevent problems with concurrent resource modification, szurubooru
implements optimistic locks using resource versions. Each modifiable resource
has its `version` returned to the client with `GET` requests. At the same time,
each `PUT` and `DELETE` request sent by the client must present the same
`version` field to the server with value as it was given in `GET`.
For example, given `GET /post/1`, the server responds like this:
```
{
...,
"version": 2
}
```
This means the client must then send `{"version": 2}` back too. If the client
fails to do so, the server will reject the request notifying about missing
parameter. If someone has edited the post in the mean time, the server will
reject the request as well, in which case the client is encouraged to notify
the user about the situation.
# API reference
@ -211,6 +235,7 @@ data.
```json5
{
"version": <version>,
"name": <name>, // optional
"color": <color>, // optional
}
@ -222,6 +247,7 @@ data.
- **Errors**
- the version is outdated
- the tag category does not exist
- the name is used by an existing tag category (names are case insensitive)
- the name is invalid
@ -231,8 +257,9 @@ data.
- **Description**
Updates an existing tag category using specified parameters. Name must
match `tag_category_name_regex` from server's configuration. All fields are
optional - update concerns only provided fields.
match `tag_category_name_regex` from server's configuration. All fields
except the [`version`](#versioning) are optional - update concerns only
provided fields.
## Getting tag category
- **Request**
@ -257,6 +284,14 @@ data.
`DELETE /tag-category/<name>`
- **Input**
```json5
{
"version": <version>
}
```
- **Output**
```json5
@ -265,6 +300,7 @@ data.
- **Errors**
- the version is outdated
- the tag category does not exist
- the tag category is used by some tags
- the tag category is the last tag category available
@ -436,6 +472,7 @@ data.
- **Errors**
- the version is outdated
- the tag does not exist
- any name is used by an existing tag (names are case insensitive)
- any name, implication or suggestion name is invalid
@ -452,8 +489,9 @@ data.
[`<tag-category>` resource](#tag-category). If specified implied tags or
suggested tags do not exist yet, they will be automatically created. Tags
created automatically have no implications, no suggestions, one name and
their category is set to the first tag category found. All fields are
optional - update concerns only provided fields.
their category is set to the first tag category found. All fields except
the [`version`](#versioning) are optional - update concerns only provided
fields.
## Getting tag
- **Request**
@ -478,6 +516,14 @@ data.
`DELETE /tag/<name>`
- **Input**
```json5
{
"version": <version>
}
```
- **Output**
```json5
@ -486,6 +532,7 @@ data.
- **Errors**
- the version is outdated
- the tag does not exist
- privileges are too low
@ -502,7 +549,9 @@ data.
```json5
{
"removeVersion": <source-tag-version>,
"remove": <source-tag-name>,
"mergeToVersion": <target-tag-version>,
"mergeTo": <target-tag-name>
}
```
@ -513,6 +562,7 @@ data.
- **Errors**
- the version of either tag is outdated
- the source or target tag does not exist
- the source tag is the same as the target tag
- privileges are too low
@ -713,9 +763,9 @@ data.
post to use default thumbnail. If `anonymous` is set to truthy value, the
uploader name won't be recorded (privilege verification still applies; it's
possible to disallow anonymous uploads completely from config.) All fields
are optional - update concerns only provided fields. For details how to
pass `content` and `thumbnail`, see [file uploads](#file-uploads) for
details.
except the [`version`](#versioning) are optional - update concerns only
provided fields. For details how to pass `content` and `thumbnail`, see
[file uploads](#file-uploads) for details.
## Updating post
- **Request**
@ -726,6 +776,7 @@ data.
```json5
{
"version": <version>,
"tags": [<tag1>, <tag2>, <tag3>], // optional
"safety": <safety>, // optional
"source": <source>, // optional
@ -746,6 +797,7 @@ data.
- **Errors**
- the version is outdated
- tags have invalid names
- safety, notes or flags are invalid
- relations refer to non-existing posts
@ -760,7 +812,9 @@ data.
must contain valid post IDs. `<flag>` currently can be only `"loop"` to
enable looping for video posts. Sending empty `thumbnail` will reset the
post thumbnail to default. For details how to pass `content` and
`thumbnail`, see [file uploads](#file-uploads) for details.
`thumbnail`, see [file uploads](#file-uploads) for details. All fields
except the [`version`](#versioning) are optional - update concerns only
provided fields.
## Getting post
- **Request**
@ -785,6 +839,14 @@ data.
`DELETE /post/<id>`
- **Input**
```json5
{
"version": <version>
}
```
- **Output**
```json5
@ -793,6 +855,7 @@ data.
- **Errors**
- the version is outdated
- the post does not exist
- privileges are too low
@ -1005,6 +1068,7 @@ data.
```json5
{
"version": <version>,
"text": <new-text> // mandatory
}
```
@ -1015,6 +1079,7 @@ data.
- **Errors**
- the version is outdated
- the comment does not exist
- new comment text is empty
- privileges are too low
@ -1046,6 +1111,14 @@ data.
`DELETE /comment/<id>`
- **Input**
```json5
{
"version": <version>
}
```
- **Output**
```json5
@ -1054,6 +1127,7 @@ data.
- **Errors**
- the version is outdated
- the comment does not exist
- privileges are too low
@ -1194,6 +1268,7 @@ data.
```json5
{
"version": <version>,
"name": <user-name>, // optional
"password": <user-password>, // optional
"email": <email>, // optional
@ -1212,6 +1287,7 @@ data.
- **Errors**
- the version is outdated
- the user does not exist
- a user with new name already exists (names are case insensitive)
- either user name, password, email or rank are invalid
@ -1228,7 +1304,9 @@ data.
provided fields. To update last login time, see
[authentication](#authentication). Avatar style can be either `gravatar` or
`manual`. `manual` avatar style requires client to pass also `avatar`
file - see [file uploads](#file-uploads) for details.
file - see [file uploads](#file-uploads) for details. All fields except the
[`version`](#versioning) are optional - update concerns only provided
fields.
## Getting user
- **Request**
@ -1253,6 +1331,14 @@ data.
`DELETE /user/<name>`
- **Input**
```json5
{
"version": <version>
}
```
- **Output**
```json5
@ -1261,6 +1347,7 @@ data.
- **Errors**
- the version is outdated
- the user does not exist
- privileges are too low
@ -1413,6 +1500,7 @@ A single user.
```json5
{
"version": <version>,
"name": <name>,
"email": <email>,
"rank": <rank>,
@ -1429,6 +1517,7 @@ A single user.
```
**Field meaning**
- `<version>`: resource version. See [versioning](#versioning).
- `<name>`: the user name.
- `<email>`: the user email. It is available only if the request is
authenticated by the same user, or the authenticated user can change the
@ -1480,6 +1569,7 @@ experience.
```json5
{
"version": <version>,
"name": <name>,
"color": <color>,
"usages": <usages>
@ -1494,6 +1584,7 @@ experience.
**Field meaning**
- `<version>`: resource version. See [versioning](#versioning).
- `<name>`: the category name.
- `<color>`: the category color.
- `<usages>`: how many tags is the given category used with.
@ -1510,6 +1601,7 @@ A single tag. Tags are used to let users search for posts.
```json5
{
"version": <version>,
"names": <names>,
"category": <category>,
"implications": <implications>,
@ -1528,6 +1620,7 @@ A single tag. Tags are used to let users search for posts.
**Field meaning**
- `<version>`: resource version. See [versioning](#versioning).
- `<names>`: a list of tag names (aliases). Tagging a post with any name will
automatically assign the first name from this list.
- `<category>`: the name of the category the given tag belongs to.
@ -1552,6 +1645,7 @@ One file together with its metadata posted to the site.
```json5
{
"version": <version>,
"id": <id>,
"creationTime": <creation-time>,
"lastEditTime": <last-edit-time>,
@ -1596,6 +1690,7 @@ One file together with its metadata posted to the site.
**Field meaning**
- `<version>`: resource version. See [versioning](#versioning).
- `<id>`: the post identifier.
- `<creation-time>`: time the tag was created, formatted as per RFC 3339.
- `<last-edit-time>`: time the tag was edited, formatted as per RFC 3339.
@ -1689,6 +1784,7 @@ A comment under a post.
```json5
{
"version": <version>,
"id": <id>,
"postId": <post-id>,
"user": <author>
@ -1701,6 +1797,7 @@ A comment under a post.
```
**Field meaning**
- `<version>`: resource version. See [versioning](#versioning).
- `<id>`: the comment identifier.
- `<post-id>`: an id of the post the comment is for.
- `<text>`: the comment content. The client should render is as Markdown.

View File

@ -39,16 +39,19 @@ class CommentDetailApi(BaseApi):
def put(self, ctx, comment_id):
comment = comments.get_comment_by_id(comment_id)
util.verify_version(comment, ctx)
infix = 'own' if ctx.user.user_id == comment.user_id else 'any'
text = ctx.get_param_as_string('text', required=True)
auth.verify_privilege(ctx.user, 'comments:edit:%s' % infix)
comment.last_edit_time = datetime.datetime.utcnow()
comments.update_comment_text(comment, text)
util.bump_version(comment)
comment.last_edit_time = datetime.datetime.utcnow()
ctx.session.commit()
return _serialize(ctx, comment)
def delete(self, ctx, comment_id):
comment = comments.get_comment_by_id(comment_id)
util.verify_version(comment, ctx)
infix = 'own' if ctx.user.user_id == comment.user_id else 'any'
auth.verify_privilege(ctx.user, 'comments:delete:%s' % infix)
ctx.session.delete(comment)

View File

@ -1,6 +1,6 @@
from szurubooru import config, errors
from szurubooru.api.base_api import BaseApi
from szurubooru.func import auth, mailer, users
from szurubooru.func import auth, mailer, users, util
MAIL_SUBJECT = 'Password reset for {name}'
MAIL_BODY = \
@ -34,5 +34,6 @@ class PasswordResetApi(BaseApi):
if token != good_token:
raise errors.ValidationError('Invalid password reset token.')
new_password = users.reset_user_password(user)
util.bump_version(user)
ctx.session.commit()
return {'password': new_password}

View File

@ -62,6 +62,7 @@ class PostDetailApi(BaseApi):
def put(self, ctx, post_id):
post = posts.get_post_by_id(post_id)
util.verify_version(post, ctx)
if ctx.has_file('content'):
auth.verify_privilege(ctx.user, 'posts:edit:content')
posts.update_post_content(post, ctx.get_file('content'))
@ -90,6 +91,7 @@ class PostDetailApi(BaseApi):
if ctx.has_file('thumbnail'):
auth.verify_privilege(ctx.user, 'posts:edit:thumbnail')
posts.update_post_thumbnail(post, ctx.get_file('thumbnail'))
util.bump_version(post)
post.last_edit_time = datetime.datetime.utcnow()
ctx.session.flush()
snapshots.save_entity_modification(post, ctx.user)
@ -100,6 +102,7 @@ class PostDetailApi(BaseApi):
def delete(self, ctx, post_id):
auth.verify_privilege(ctx.user, 'posts:delete')
post = posts.get_post_by_id(post_id)
util.verify_version(post, ctx)
snapshots.save_entity_deletion(post, ctx.user)
posts.delete(post)
ctx.session.commit()

View File

@ -60,6 +60,7 @@ class TagDetailApi(BaseApi):
def put(self, ctx, tag_name):
tag = tags.get_tag_by_name(tag_name)
util.verify_version(tag, ctx)
if ctx.has_param('names'):
auth.verify_privilege(ctx.user, 'tags:edit:names')
tags.update_tag_names(tag, ctx.get_param_as_list('names'))
@ -81,6 +82,7 @@ class TagDetailApi(BaseApi):
implications = ctx.get_param_as_list('implications')
_create_if_needed(implications, ctx.user)
tags.update_tag_implications(tag, implications)
util.bump_version(tag)
tag.last_edit_time = datetime.datetime.utcnow()
ctx.session.flush()
snapshots.save_entity_modification(tag, ctx.user)
@ -90,6 +92,7 @@ class TagDetailApi(BaseApi):
def delete(self, ctx, tag_name):
tag = tags.get_tag_by_name(tag_name)
util.verify_version(tag, ctx)
auth.verify_privilege(ctx.user, 'tags:delete')
snapshots.save_entity_deletion(tag, ctx.user)
tags.delete(tag)
@ -103,11 +106,14 @@ class TagMergeApi(BaseApi):
target_tag_name = ctx.get_param_as_string('mergeTo', required=True) or ''
source_tag = tags.get_tag_by_name(source_tag_name)
target_tag = tags.get_tag_by_name(target_tag_name)
util.verify_version(source_tag, ctx, 'removeVersion')
util.verify_version(target_tag, ctx, 'mergeToVersion')
if source_tag.tag_id == target_tag.tag_id:
raise tags.InvalidTagRelationError('Cannot merge tag with itself.')
auth.verify_privilege(ctx.user, 'tags:merge')
snapshots.save_entity_deletion(source_tag, ctx.user)
tags.merge_tags(source_tag, target_tag)
util.bump_version(target_tag)
ctx.session.commit()
tags.export_to_json()
return _serialize(ctx, target_tag)

View File

@ -33,6 +33,7 @@ class TagCategoryDetailApi(BaseApi):
def put(self, ctx, category_name):
category = tag_categories.get_category_by_name(category_name)
util.verify_version(category, ctx)
if ctx.has_param('name'):
auth.verify_privilege(ctx.user, 'tag_categories:edit:name')
tag_categories.update_category_name(
@ -41,6 +42,7 @@ class TagCategoryDetailApi(BaseApi):
auth.verify_privilege(ctx.user, 'tag_categories:edit:color')
tag_categories.update_category_color(
category, ctx.get_param_as_string('color'))
util.bump_version(category)
ctx.session.flush()
snapshots.save_entity_modification(category, ctx.user)
ctx.session.commit()
@ -49,6 +51,7 @@ class TagCategoryDetailApi(BaseApi):
def delete(self, ctx, category_name):
category = tag_categories.get_category_by_name(category_name)
util.verify_version(category, ctx)
auth.verify_privilege(ctx.user, 'tag_categories:delete')
if len(tag_categories.get_all_category_names()) == 1:
raise tag_categories.TagCategoryIsInUseError(

View File

@ -47,6 +47,7 @@ class UserDetailApi(BaseApi):
def put(self, ctx, user_name):
user = users.get_user_by_name(user_name)
util.verify_version(user, ctx)
infix = 'self' if ctx.user.user_id == user.user_id else 'any'
if ctx.has_param('name'):
auth.verify_privilege(ctx.user, 'users:edit:%s:name' % infix)
@ -68,11 +69,13 @@ class UserDetailApi(BaseApi):
user,
ctx.get_param_as_string('avatarStyle'),
ctx.get_file('avatar'))
util.bump_version(user)
ctx.session.commit()
return _serialize(ctx, user)
def delete(self, ctx, user_name):
user = users.get_user_by_name(user_name)
util.verify_version(user, ctx)
infix = 'self' if ctx.user.user_id == user.user_id else 'any'
auth.verify_privilege(ctx.user, 'users:delete:%s' % infix)
ctx.session.delete(user)

View File

@ -26,6 +26,7 @@ class Comment(Base):
'post_id', Integer, ForeignKey('post.id'), index=True, nullable=False)
user_id = Column(
'user_id', Integer, ForeignKey('user.id'), index=True)
version = Column('version', Integer, default=1, nullable=False)
creation_time = Column('creation_time', DateTime, nullable=False)
last_edit_time = Column('last_edit_time', DateTime)
text = Column('text', UnicodeText, default=None)

View File

@ -101,6 +101,7 @@ class Post(Base):
# basic meta
post_id = Column('id', Integer, primary_key=True)
user_id = Column('user_id', Integer, ForeignKey('user.id'), index=True)
version = Column('version', Integer, default=1, nullable=False)
creation_time = Column('creation_time', DateTime, nullable=False)
last_edit_time = Column('last_edit_time', DateTime)
safety = Column('safety', Unicode(32), nullable=False)

View File

@ -44,6 +44,7 @@ class Tag(Base):
tag_id = Column('id', Integer, primary_key=True)
category_id = Column(
'category_id', Integer, ForeignKey('tag_category.id'), nullable=False, index=True)
version = Column('version', Integer, default=1, nullable=False)
creation_time = Column('creation_time', DateTime, nullable=False)
last_edit_time = Column('last_edit_time', DateTime)
description = Column('description', UnicodeText, default=None)

View File

@ -8,6 +8,7 @@ class TagCategory(Base):
__tablename__ = 'tag_category'
tag_category_id = Column('id', Integer, primary_key=True)
version = Column('version', Integer, default=1, nullable=False)
name = Column('name', Unicode(32), nullable=False)
color = Column('color', Unicode(32), nullable=False, default='#000000')
default = Column('default', Boolean, nullable=False, default=False)

View File

@ -20,13 +20,14 @@ class User(Base):
RANK_NOBODY = 'nobody' # used for privileges: "nobody can be higher than admin"
user_id = Column('id', Integer, primary_key=True)
creation_time = Column('creation_time', DateTime, nullable=False)
last_login_time = Column('last_login_time', DateTime)
version = Column('version', Integer, default=1, nullable=False)
name = Column('name', Unicode(50), nullable=False, unique=True)
password_hash = Column('password_hash', Unicode(64), nullable=False)
password_salt = Column('password_salt', Unicode(32))
email = Column('email', Unicode(64), nullable=True)
rank = Column('rank', Unicode(32), nullable=False)
creation_time = Column('creation_time', DateTime, nullable=False)
last_login_time = Column('last_login_time', DateTime)
avatar_style = Column(
'avatar_style', Unicode(32), nullable=False, default=AVATAR_GRAVATAR)

View File

@ -12,6 +12,7 @@ def serialize_comment(comment, authenticated_user, options=None):
'id': lambda: comment.comment_id,
'user': lambda: users.serialize_micro_user(comment.user),
'postId': lambda: comment.post.post_id,
'version': lambda: comment.version,
'text': lambda: comment.text,
'creationTime': lambda: comment.creation_time,
'lastEditTime': lambda: comment.last_edit_time,

View File

@ -67,6 +67,7 @@ def serialize_post(post, authenticated_user, options=None):
post,
{
'id': lambda: post.post_id,
'version': lambda: post.version,
'creationTime': lambda: post.creation_time,
'lastEditTime': lambda: post.last_edit_time,
'safety': lambda: SAFETY_MAP[post.safety],

View File

@ -20,6 +20,7 @@ def serialize_category(category, options=None):
category,
{
'name': lambda: category.name,
'version': lambda: category.version,
'color': lambda: category.color,
'usages': lambda: category.tag_count,
'default': lambda: category.default,

View File

@ -53,6 +53,7 @@ def serialize_tag(tag, options=None):
{
'names': lambda: [tag_name.name for tag_name in tag.names],
'category': lambda: tag.category.name,
'version': lambda: tag.version,
'description': lambda: tag.description,
'creationTime': lambda: tag.creation_time,
'lastEditTime': lambda: tag.last_edit_time,

View File

@ -46,9 +46,10 @@ def serialize_user(user, authenticated_user, options=None, force_show_email=Fals
user,
{
'name': lambda: user.name,
'rank': lambda: user.rank,
'creationTime': lambda: user.creation_time,
'lastLoginTime': lambda: user.last_login_time,
'version': lambda: user.version,
'rank': lambda: user.rank,
'avatarStyle': lambda: user.avatar_style,
'avatarUrl': lambda: _get_avatar_url(user),
'commentCount': lambda: user.comment_count,

View File

@ -139,3 +139,14 @@ def value_exceeds_column_size(value, column):
if max_length is None:
return False
return len(value) > max_length
def verify_version(entity, context, field_name='version'):
actual_version = context.get_param_as_int(field_name, required=True)
expected_version = entity.version
if actual_version != expected_version:
raise errors.InvalidParameterError(
'Someone else modified this in the meantime. ' +
'Please try again.')
def bump_version(entity):
entity.version += 1

View File

@ -0,0 +1,26 @@
'''
Add entity versions
Revision ID: 7f6baf38c27c
Created at: 2016-08-06 22:26:58.111763
'''
import sqlalchemy as sa
from alembic import op
revision = '7f6baf38c27c'
down_revision = '4c526f869323'
branch_labels = None
depends_on = None
tables = ['tag_category', 'tag', 'user', 'post', 'comment']
def upgrade():
for table in tables:
op.add_column(table, sa.Column('version', sa.Integer(), nullable=True))
op.execute(sa.table(table, sa.column('version')).update().values(version=1))
op.alter_column(table, 'version', nullable=False)
def downgrade():
for table in tables:
op.drop_column(table, 'version')

View File

@ -24,7 +24,8 @@ def test_deleting_own_comment(test_ctx):
db.session.add(comment)
db.session.commit()
result = test_ctx.api.delete(
test_ctx.context_factory(user=user), comment.comment_id)
test_ctx.context_factory(input={'version': 1}, user=user),
comment.comment_id)
assert result == {}
assert db.session.query(db.Comment).count() == 0
@ -35,7 +36,8 @@ def test_deleting_someones_else_comment(test_ctx):
db.session.add(comment)
db.session.commit()
result = test_ctx.api.delete(
test_ctx.context_factory(user=user2), comment.comment_id)
test_ctx.context_factory(input={'version': 1}, user=user2),
comment.comment_id)
assert db.session.query(db.Comment).count() == 0
def test_trying_to_delete_someones_else_comment_without_privileges(test_ctx):
@ -46,11 +48,14 @@ def test_trying_to_delete_someones_else_comment_without_privileges(test_ctx):
db.session.commit()
with pytest.raises(errors.AuthError):
test_ctx.api.delete(
test_ctx.context_factory(user=user2), comment.comment_id)
test_ctx.context_factory(input={'version': 1}, user=user2),
comment.comment_id)
assert db.session.query(db.Comment).count() == 1
def test_trying_to_delete_non_existing(test_ctx):
with pytest.raises(comments.CommentNotFoundError):
test_ctx.api.delete(
test_ctx.context_factory(
user=test_ctx.user_factory(rank=db.User.RANK_REGULAR)), 1)
input={'version': 1},
user=test_ctx.user_factory(rank=db.User.RANK_REGULAR)),
1)

View File

@ -31,7 +31,8 @@ def test_simple_updating(test_ctx, fake_datetime):
db.session.commit()
with fake_datetime('1997-12-01'):
result = test_ctx.api.put(
test_ctx.context_factory(input={'text': 'new text'}, user=user),
test_ctx.context_factory(
input={'text': 'new text', 'version': 1}, user=user),
comment.comment_id)
assert result['text'] == 'new text'
comment = db.session.query(db.Comment).one()
@ -53,7 +54,8 @@ def test_trying_to_pass_invalid_input(test_ctx, input, expected_exception):
db.session.commit()
with pytest.raises(expected_exception):
test_ctx.api.put(
test_ctx.context_factory(input=input, user=user),
test_ctx.context_factory(
input={**input, **{'version': 1}}, user=user),
comment.comment_id)
def test_trying_to_omit_mandatory_field(test_ctx):
@ -63,7 +65,7 @@ def test_trying_to_omit_mandatory_field(test_ctx):
db.session.commit()
with pytest.raises(errors.ValidationError):
test_ctx.api.put(
test_ctx.context_factory(input={}, user=user),
test_ctx.context_factory(input={'version': 1}, user=user),
comment.comment_id)
def test_trying_to_update_non_existing(test_ctx):
@ -82,7 +84,8 @@ def test_trying_to_update_someones_comment_without_privileges(test_ctx):
db.session.commit()
with pytest.raises(errors.AuthError):
test_ctx.api.put(
test_ctx.context_factory(input={'text': 'new text'}, user=user2),
test_ctx.context_factory(
input={'text': 'new text', 'version': 1}, user=user2),
comment.comment_id)
def test_updating_someones_comment_with_privileges(test_ctx):
@ -93,7 +96,8 @@ def test_updating_someones_comment_with_privileges(test_ctx):
db.session.commit()
try:
test_ctx.api.put(
test_ctx.context_factory(input={'text': 'new text'}, user=user2),
test_ctx.context_factory(
input={'text': 'new text', 'version': 1}, user=user2),
comment.comment_id)
except:
pytest.fail()

View File

@ -25,6 +25,7 @@ def test_deleting(test_ctx):
db.session.commit()
result = test_ctx.api.delete(
test_ctx.context_factory(
input={'version': 1},
user=test_ctx.user_factory(rank=db.User.RANK_REGULAR)),
1)
assert result == {}

View File

@ -43,6 +43,7 @@ def test_post_updating(
result = api.PostDetailApi().put(
context_factory(
input={
'version': 1,
'safety': 'safe',
'tags': ['tag1', 'tag2'],
'relations': [1, 2],
@ -89,7 +90,7 @@ def test_uploading_from_url_saves_source(
net.download.return_value = b'content'
api.PostDetailApi().put(
context_factory(
input={'contentUrl': 'example.com'},
input={'contentUrl': 'example.com', 'version': 1},
user=user_factory(rank=db.User.RANK_REGULAR)),
post.post_id)
net.download.assert_called_once_with('example.com')
@ -116,7 +117,10 @@ def test_uploading_from_url_with_source_specified(
net.download.return_value = b'content'
api.PostDetailApi().put(
context_factory(
input={'contentUrl': 'example.com', 'source': 'example2.com'},
input={
'contentUrl': 'example.com',
'source': 'example2.com',
'version': 1},
user=user_factory(rank=db.User.RANK_REGULAR)),
post.post_id)
net.download.assert_called_once_with('example.com')
@ -158,7 +162,7 @@ def test_trying_to_update_field_without_privileges(
with pytest.raises(errors.AuthError):
api.PostDetailApi().put(
context_factory(
input=input,
input={**input, **{'version': 1}},
files=files,
user=user_factory(rank=db.User.RANK_ANONYMOUS)),
post.post_id)
@ -179,6 +183,6 @@ def test_trying_to_create_tags_without_privileges(
posts.update_post_tags.return_value = ['new-tag']
api.PostDetailApi().put(
context_factory(
input={'tags': ['tag1', 'tag2']},
input={'tags': ['tag1', 'tag2'], 'version': 1},
user=user_factory(rank=db.User.RANK_REGULAR)),
post.post_id)

View File

@ -28,6 +28,7 @@ def test_creating_category(test_ctx):
'color': 'black',
'usages': 0,
'default': True,
'version': 1,
}
category = db.session.query(db.TagCategory).one()
assert category.name == 'meta'

View File

@ -32,6 +32,7 @@ def test_deleting(test_ctx):
db.session.commit()
result = test_ctx.api.delete(
test_ctx.context_factory(
input={'version': 1},
user=test_ctx.user_factory(rank=db.User.RANK_REGULAR)),
'category')
assert result == {}
@ -49,6 +50,7 @@ def test_trying_to_delete_used(test_ctx, tag_factory):
with pytest.raises(tag_categories.TagCategoryIsInUseError):
test_ctx.api.delete(
test_ctx.context_factory(
input={'version': 1},
user=test_ctx.user_factory(rank=db.User.RANK_REGULAR)),
'category')
assert db.session.query(db.TagCategory).count() == 1
@ -59,6 +61,7 @@ def test_trying_to_delete_last(test_ctx, tag_factory):
with pytest.raises(tag_categories.TagCategoryIsInUseError):
result = test_ctx.api.delete(
test_ctx.context_factory(
input={'version': 1},
user=test_ctx.user_factory(rank=db.User.RANK_REGULAR)),
'root')
@ -66,7 +69,8 @@ def test_trying_to_delete_non_existing(test_ctx):
with pytest.raises(tag_categories.TagCategoryNotFoundError):
test_ctx.api.delete(
test_ctx.context_factory(
user=test_ctx.user_factory(rank=db.User.RANK_REGULAR)), 'bad')
user=test_ctx.user_factory(rank=db.User.RANK_REGULAR)),
'bad')
def test_trying_to_delete_without_privileges(test_ctx):
db.session.add(test_ctx.tag_category_factory(name='category'))
@ -74,6 +78,7 @@ def test_trying_to_delete_without_privileges(test_ctx):
with pytest.raises(errors.AuthError):
test_ctx.api.delete(
test_ctx.context_factory(
input={'version': 1},
user=test_ctx.user_factory(rank=db.User.RANK_ANONYMOUS)),
'category')
assert db.session.query(db.TagCategory).count() == 1

View File

@ -43,6 +43,7 @@ def test_retrieving_single(test_ctx):
'usages': 0,
'default': False,
'snapshots': [],
'version': 1,
}
def test_trying_to_retrieve_single_non_existing(test_ctx):

View File

@ -34,6 +34,7 @@ def test_simple_updating(test_ctx):
input={
'name': 'changed',
'color': 'white',
'version': 1,
},
user=test_ctx.user_factory(rank=db.User.RANK_REGULAR)),
'name')
@ -44,6 +45,7 @@ def test_simple_updating(test_ctx):
'color': 'white',
'usages': 0,
'default': False,
'version': 2,
}
assert tag_categories.try_get_category_by_name('name') is None
category = tag_categories.get_category_by_name('changed')
@ -66,7 +68,7 @@ def test_trying_to_pass_invalid_input(test_ctx, input):
with pytest.raises(tag_categories.InvalidTagCategoryNameError):
test_ctx.api.put(
test_ctx.context_factory(
input=input,
input={**input, **{'version': 1}},
user=test_ctx.user_factory(rank=db.User.RANK_REGULAR)),
'meta')
@ -81,7 +83,7 @@ def test_omitting_optional_field(test_ctx, field):
del input[field]
result = test_ctx.api.put(
test_ctx.context_factory(
input=input,
input={**input, **{'version': 1}},
user=test_ctx.user_factory(rank=db.User.RANK_REGULAR)),
'name')
assert result is not None
@ -100,7 +102,7 @@ def test_reusing_own_name(test_ctx, new_name):
db.session.commit()
result = test_ctx.api.put(
test_ctx.context_factory(
input={'name': new_name},
input={'name': new_name, 'version': 1},
user=test_ctx.user_factory(rank=db.User.RANK_REGULAR)),
'cat')
assert result['name'] == new_name
@ -116,7 +118,7 @@ def test_trying_to_use_existing_name(test_ctx, dup_name):
with pytest.raises(tag_categories.TagCategoryAlreadyExistsError):
test_ctx.api.put(
test_ctx.context_factory(
input={'name': dup_name},
input={'name': dup_name, 'version': 1},
user=test_ctx.user_factory(rank=db.User.RANK_REGULAR)),
'cat2')
@ -130,6 +132,6 @@ def test_trying_to_update_without_privileges(test_ctx, input):
with pytest.raises(errors.AuthError):
test_ctx.api.put(
test_ctx.context_factory(
input=input,
input={**input, **{'version': 1}},
user=test_ctx.user_factory(rank=db.User.RANK_ANONYMOUS)),
'dummy')

View File

@ -50,6 +50,7 @@ def test_creating_simple_tags(test_ctx, fake_datetime):
'creationTime': datetime.datetime(1997, 12, 1),
'lastEditTime': None,
'usages': 0,
'version': 1,
}
tag = tags.get_tag_by_name('tag1')
assert [tag_name.name for tag_name in tag.names] == ['tag1', 'tag2']

View File

@ -25,6 +25,7 @@ def test_deleting(test_ctx):
db.session.commit()
result = test_ctx.api.delete(
test_ctx.context_factory(
input={'version': 1},
user=test_ctx.user_factory(rank=db.User.RANK_REGULAR)),
'tag')
assert result == {}
@ -39,6 +40,7 @@ def test_deleting_used(test_ctx, post_factory):
db.session.commit()
test_ctx.api.delete(
test_ctx.context_factory(
input={'version': 1},
user=test_ctx.user_factory(rank=db.User.RANK_REGULAR)),
'tag')
db.session.refresh(post)
@ -57,6 +59,7 @@ def test_trying_to_delete_without_privileges(test_ctx):
with pytest.raises(errors.AuthError):
test_ctx.api.delete(
test_ctx.context_factory(
input={'version': 1},
user=test_ctx.user_factory(rank=db.User.RANK_ANONYMOUS)),
'tag')
assert db.session.query(db.Tag).count() == 1

View File

@ -29,6 +29,8 @@ def test_merging_without_usages(test_ctx, fake_datetime):
result = test_ctx.api.post(
test_ctx.context_factory(
input={
'removeVersion': 1,
'mergeToVersion': 1,
'remove': 'source',
'mergeTo': 'target',
},
@ -44,6 +46,7 @@ def test_merging_without_usages(test_ctx, fake_datetime):
'creationTime': datetime.datetime(1996, 1, 1),
'lastEditTime': None,
'usages': 0,
'version': 2,
}
assert tags.try_get_tag_by_name('source') is None
tag = tags.get_tag_by_name('target')
@ -67,6 +70,8 @@ def test_merging_with_usages(test_ctx, fake_datetime, post_factory):
result = test_ctx.api.post(
test_ctx.context_factory(
input={
'removeVersion': 1,
'mergeToVersion': 1,
'remove': 'source',
'mergeTo': 'target',
},
@ -90,6 +95,8 @@ def test_merging_when_related(test_ctx, fake_datetime):
result = test_ctx.api.post(
test_ctx.context_factory(
input={
'removeVersion': 1,
'mergeToVersion': 1,
'remove': 'source',
'mergeTo': 'target',
},
@ -113,6 +120,8 @@ def test_merging_when_target_exists(test_ctx, fake_datetime, post_factory):
result = test_ctx.api.post(
test_ctx.context_factory(
input={
'removeVersion': 1,
'mergeToVersion': 1,
'remove': 'source',
'mergeTo': 'target',
},
@ -134,6 +143,8 @@ def test_trying_to_pass_invalid_input(test_ctx, input, expected_exception):
db.session.add_all([source_tag, target_tag])
db.session.commit()
real_input = {
'removeVersion': 1,
'mergeToVersion': 1,
'remove': 'source',
'mergeTo': 'target',
}
@ -146,7 +157,7 @@ def test_trying_to_pass_invalid_input(test_ctx, input, expected_exception):
user=test_ctx.user_factory(rank=db.User.RANK_REGULAR)))
@pytest.mark.parametrize(
'field', ['remove', 'mergeTo'])
'field', ['remove', 'mergeTo', 'removeVersion', 'mergeToVersion'])
def test_trying_to_omit_mandatory_field(test_ctx, field):
db.session.add_all([
test_ctx.tag_factory(names=['source'], category_name='meta'),
@ -154,6 +165,8 @@ def test_trying_to_omit_mandatory_field(test_ctx, field):
])
db.session.commit()
input = {
'removeVersion': 1,
'mergeToVersion': 1,
'remove': 'source',
'mergeTo': 'target',
}
@ -184,7 +197,11 @@ def test_trying_to_merge_to_itself(test_ctx):
with pytest.raises(tags.InvalidTagRelationError):
test_ctx.api.post(
test_ctx.context_factory(
input={'remove': 'good', 'mergeTo': 'good'},
input={
'removeVersion': 1,
'mergeToVersion': 1,
'remove': 'good',
'mergeTo': 'good'},
user=test_ctx.user_factory(rank=db.User.RANK_REGULAR)))
@pytest.mark.parametrize('input', [
@ -203,6 +220,8 @@ def test_trying_to_merge_without_privileges(test_ctx, input):
test_ctx.api.post(
test_ctx.context_factory(
input={
'removeVersion': 1,
'mergeToVersion': 1,
'remove': 'source',
'mergeTo': 'target',
},

View File

@ -57,6 +57,7 @@ def test_retrieving_single(test_ctx):
'implications': [],
'usages': 0,
'snapshots': [],
'version': 1,
}
def test_trying_to_retrieve_single_non_existing(test_ctx):

View File

@ -42,6 +42,7 @@ def test_simple_updating(test_ctx, fake_datetime):
result = test_ctx.api.put(
test_ctx.context_factory(
input={
'version': 1,
'names': ['tag3'],
'category': 'character',
'description': 'desc',
@ -59,6 +60,7 @@ def test_simple_updating(test_ctx, fake_datetime):
'creationTime': datetime.datetime(1996, 1, 1),
'lastEditTime': datetime.datetime(1997, 12, 1),
'usages': 0,
'version': 2,
}
assert tags.try_get_tag_by_name('tag1') is None
assert tags.try_get_tag_by_name('tag2') is None
@ -89,7 +91,7 @@ def test_trying_to_pass_invalid_input(test_ctx, input, expected_exception):
with pytest.raises(expected_exception):
test_ctx.api.put(
test_ctx.context_factory(
input=input,
input={**input, **{'version': 1}},
user=test_ctx.user_factory(rank=db.User.RANK_REGULAR)),
'tag1')
@ -108,7 +110,7 @@ def test_omitting_optional_field(test_ctx, field):
del input[field]
result = test_ctx.api.put(
test_ctx.context_factory(
input=input,
input={**input, **{'version': 1}},
user=test_ctx.user_factory(rank=db.User.RANK_REGULAR)),
'tag')
assert result is not None
@ -128,7 +130,7 @@ def test_reusing_own_name(test_ctx, dup_name):
db.session.commit()
result = test_ctx.api.put(
test_ctx.context_factory(
input={'names': [dup_name, 'tag3']},
input={'names': [dup_name, 'tag3'], 'version': 1},
user=test_ctx.user_factory(rank=db.User.RANK_REGULAR)),
'tag1')
assert result['names'] == ['tag1', 'tag3']
@ -143,7 +145,7 @@ def test_duplicating_names(test_ctx):
test_ctx.tag_factory(names=['tag1', 'tag2'], category_name='meta'))
result = test_ctx.api.put(
test_ctx.context_factory(
input={'names': ['tag3', 'TAG3']},
input={'names': ['tag3', 'TAG3'], 'version': 1},
user=test_ctx.user_factory(rank=db.User.RANK_REGULAR)),
'tag1')
assert result['names'] == ['tag3']
@ -162,7 +164,7 @@ def test_trying_to_use_existing_name(test_ctx, dup_name):
with pytest.raises(tags.TagAlreadyExistsError):
test_ctx.api.put(
test_ctx.context_factory(
input={'names': [dup_name]},
input={'names': [dup_name], 'version': 1},
user=test_ctx.user_factory(rank=db.User.RANK_REGULAR)),
'tag3')
@ -195,7 +197,8 @@ def test_updating_new_suggestions_and_implications(
db.session.commit()
result = test_ctx.api.put(
test_ctx.context_factory(
input=input, user=test_ctx.user_factory(rank=db.User.RANK_REGULAR)),
input={**input, **{'version': 1}},
user=test_ctx.user_factory(rank=db.User.RANK_REGULAR)),
'main')
assert result['suggestions'] == expected_suggestions
assert result['implications'] == expected_implications
@ -219,6 +222,7 @@ def test_reusing_suggestions_and_implications(test_ctx):
'category': 'meta',
'suggestions': ['TAG2'],
'implications': ['tag1'],
'version': 1,
},
user=test_ctx.user_factory(rank=db.User.RANK_REGULAR)),
'tag4')
@ -249,7 +253,8 @@ def test_trying_to_relate_tag_to_itself(test_ctx, input):
with pytest.raises(tags.InvalidTagRelationError):
test_ctx.api.put(
test_ctx.context_factory(
input=input, user=test_ctx.user_factory(rank=db.User.RANK_REGULAR)),
input={**input, **{'version': 1}},
user=test_ctx.user_factory(rank=db.User.RANK_REGULAR)),
'tag1')
@pytest.mark.parametrize('input', [
@ -264,6 +269,6 @@ def test_trying_to_update_without_privileges(test_ctx, input):
with pytest.raises(errors.AuthError):
test_ctx.api.put(
test_ctx.context_factory(
input=input,
input={**input, **{'version': 1}},
user=test_ctx.user_factory(rank=db.User.RANK_ANONYMOUS)),
'tag')

View File

@ -50,6 +50,7 @@ def test_creating_user(test_ctx, fake_datetime):
'dislikedPostCount': 0,
'favoritePostCount': 0,
'uploadedPostCount': 0,
'version': 1,
}
user = users.get_user_by_name('chewie1')
assert user.name == 'chewie1'

View File

@ -21,7 +21,8 @@ def test_deleting_oneself(test_ctx):
user = test_ctx.user_factory(name='u', rank=db.User.RANK_REGULAR)
db.session.add(user)
db.session.commit()
result = test_ctx.api.delete(test_ctx.context_factory(user=user), 'u')
result = test_ctx.api.delete(
test_ctx.context_factory(input={'version': 1}, user=user), 'u')
assert result == {}
assert db.session.query(db.User).count() == 0
@ -30,7 +31,8 @@ def test_deleting_someone_else(test_ctx):
user2 = test_ctx.user_factory(name='u2', rank=db.User.RANK_MODERATOR)
db.session.add_all([user1, user2])
db.session.commit()
test_ctx.api.delete(test_ctx.context_factory(user=user2), 'u1')
test_ctx.api.delete(
test_ctx.context_factory(input={'version': 1}, user=user2), 'u1')
assert db.session.query(db.User).count() == 1
def test_trying_to_delete_someone_else_without_privileges(test_ctx):
@ -39,11 +41,14 @@ def test_trying_to_delete_someone_else_without_privileges(test_ctx):
db.session.add_all([user1, user2])
db.session.commit()
with pytest.raises(errors.AuthError):
test_ctx.api.delete(test_ctx.context_factory(user=user2), 'u1')
test_ctx.api.delete(
test_ctx.context_factory(input={'version': 1}, user=user2), 'u1')
assert db.session.query(db.User).count() == 2
def test_trying_to_delete_non_existing(test_ctx):
with pytest.raises(users.UserNotFoundError):
test_ctx.api.delete(
test_ctx.context_factory(
user=test_ctx.user_factory(rank=db.User.RANK_REGULAR)), 'bad')
input={'version': 1},
user=test_ctx.user_factory(rank=db.User.RANK_REGULAR)),
'bad')

View File

@ -61,6 +61,7 @@ def test_retrieving_single(test_ctx):
'dislikedPostCount': False,
'favoritePostCount': 0,
'uploadedPostCount': 0,
'version': 1,
}
assert result['email'] is False
assert result['likedPostCount'] is False

View File

@ -42,6 +42,7 @@ def test_updating_user(test_ctx):
result = test_ctx.api.put(
test_ctx.context_factory(
input={
'version': 1,
'name': 'chewie',
'email': 'asd@asd.asd',
'password': 'oks',
@ -64,6 +65,7 @@ def test_updating_user(test_ctx):
'dislikedPostCount': 0,
'favoritePostCount': 0,
'uploadedPostCount': 0,
'version': 2,
}
user = users.get_user_by_name('chewie')
assert user.name == 'chewie'
@ -98,7 +100,10 @@ def test_trying_to_pass_invalid_input(test_ctx, input, expected_exception):
db.session.add(user)
with pytest.raises(expected_exception):
test_ctx.api.put(
test_ctx.context_factory(input=input, user=user), 'u1')
test_ctx.context_factory(
input={**input, **{'version': 1}},
user=user),
'u1')
@pytest.mark.parametrize(
'field', ['name', 'email', 'password', 'rank', 'avatarStyle'])
@ -115,7 +120,7 @@ def test_omitting_optional_field(test_ctx, field):
del input[field]
result = test_ctx.api.put(
test_ctx.context_factory(
input=input,
input={**input, **{'version': 1}},
files={'avatar': EMPTY_PIXEL},
user=user),
'u1')
@ -131,7 +136,8 @@ def test_removing_email(test_ctx):
user = test_ctx.user_factory(name='u1', rank=db.User.RANK_ADMINISTRATOR)
db.session.add(user)
test_ctx.api.put(
test_ctx.context_factory(input={'email': ''}, user=user), 'u1')
test_ctx.context_factory(
input={'email': '', 'version': 1}, user=user), 'u1')
assert users.get_user_by_name('u1').email is None
@pytest.mark.parametrize('input', [
@ -147,7 +153,10 @@ def test_trying_to_update_someone_else(test_ctx, input):
db.session.add_all([user1, user2])
with pytest.raises(errors.AuthError):
test_ctx.api.put(
test_ctx.context_factory(input=input, user=user1), user2.name)
test_ctx.context_factory(
input={**input, **{'version': 1}},
user=user1),
user2.name)
def test_trying_to_become_someone_else(test_ctx):
user1 = test_ctx.user_factory(name='me', rank=db.User.RANK_REGULAR)
@ -155,10 +164,14 @@ def test_trying_to_become_someone_else(test_ctx):
db.session.add_all([user1, user2])
with pytest.raises(users.UserAlreadyExistsError):
test_ctx.api.put(
test_ctx.context_factory(input={'name': 'her'}, user=user1), 'me')
test_ctx.context_factory(
input={'name': 'her', 'version': 1}, user=user1),
'me')
with pytest.raises(users.UserAlreadyExistsError):
test_ctx.api.put(
test_ctx.context_factory(input={'name': 'HER'}, user=user1), 'me')
test_ctx.context_factory(
input={'name': 'HER', 'version': 1}, user=user1),
'me')
def test_trying_to_make_someone_into_someone_else(test_ctx):
user1 = test_ctx.user_factory(name='him', rank=db.User.RANK_REGULAR)
@ -167,25 +180,35 @@ def test_trying_to_make_someone_into_someone_else(test_ctx):
db.session.add_all([user1, user2, user3])
with pytest.raises(users.UserAlreadyExistsError):
test_ctx.api.put(
test_ctx.context_factory(input={'name': 'her'}, user=user3), 'him')
test_ctx.context_factory(
input={'name': 'her', 'version': 1}, user=user3),
'him')
with pytest.raises(users.UserAlreadyExistsError):
test_ctx.api.put(
test_ctx.context_factory(input={'name': 'HER'}, user=user3), 'him')
test_ctx.context_factory(
input={'name': 'HER', 'version': 1}, user=user3),
'him')
def test_renaming_someone_else(test_ctx):
user1 = test_ctx.user_factory(name='him', rank=db.User.RANK_REGULAR)
user2 = test_ctx.user_factory(name='me', rank=db.User.RANK_MODERATOR)
db.session.add_all([user1, user2])
test_ctx.api.put(
test_ctx.context_factory(input={'name': 'himself'}, user=user2), 'him')
test_ctx.context_factory(
input={'name': 'himself', 'version': 1}, user=user2),
'him')
test_ctx.api.put(
test_ctx.context_factory(input={'name': 'HIMSELF'}, user=user2), 'himself')
test_ctx.context_factory(
input={'name': 'HIMSELF', 'version': 2}, user=user2),
'himself')
def test_mods_trying_to_become_admin(test_ctx):
user1 = test_ctx.user_factory(name='u1', rank=db.User.RANK_MODERATOR)
user2 = test_ctx.user_factory(name='u2', rank=db.User.RANK_MODERATOR)
db.session.add_all([user1, user2])
context = test_ctx.context_factory(input={'rank': 'administrator'}, user=user1)
context = test_ctx.context_factory(
input={'rank': 'administrator', 'version': 1},
user=user1)
with pytest.raises(errors.AuthError):
test_ctx.api.put(context, user1.name)
with pytest.raises(errors.AuthError):
@ -196,7 +219,7 @@ def test_uploading_avatar(test_ctx):
db.session.add(user)
response = test_ctx.api.put(
test_ctx.context_factory(
input={'avatarStyle': 'manual'},
input={'avatarStyle': 'manual', 'version': 1},
files={'avatar': EMPTY_PIXEL},
user=user),
'u1')

View File

@ -130,6 +130,7 @@ def test_serialize_post(
assert result == {
'id': 1,
'version': 1,
'creationTime': datetime.datetime(1997, 1, 1),
'lastEditTime': datetime.datetime(1998, 1, 1),
'safety': 'safe',