diff --git a/.dockerignore b/.dockerignore index 3715b0d..2cb20bd 100644 --- a/.dockerignore +++ b/.dockerignore @@ -4,9 +4,10 @@ __pycache__/ .gitlab-ci.yml .coverage.* coverage.xml +docs/ .*cache/ .python-version .rozental.sqlite .coverage .git/ -Dockerfile \ No newline at end of file +Dockerfile diff --git a/.gitignore b/.gitignore index 5a33731..3931c34 100644 --- a/.gitignore +++ b/.gitignore @@ -185,3 +185,5 @@ yum-root-* .rozental.sqlite .tm_properties + +docker-compose.override.yml diff --git a/.mdlrc b/.mdlrc new file mode 100644 index 0000000..bb722c1 --- /dev/null +++ b/.mdlrc @@ -0,0 +1 @@ +style '.mdlrc.rb' diff --git a/.mdlrc.rb b/.mdlrc.rb new file mode 100644 index 0000000..aaf06ab --- /dev/null +++ b/.mdlrc.rb @@ -0,0 +1,3 @@ +all +rule 'MD013', :line_length => 120 +exclude_rule 'MD033' diff --git a/README.md b/README.md index e77a7c2..2f8dc1e 100644 --- a/README.md +++ b/README.md @@ -9,10 +9,12 @@ Flag/feature toggle service, written in aiohttp. ### Basic -| Method | Endpoint | Description | -| ------- | ---------------------------| ----------------------------------- | -| `GET` | `/api/docs` | Api documentation | -| `GET` | `/api/v1/switch` | List of flags for the group. | +| Method | Endpoint | Description | +| ------- |----------------------------------|-----------------------------------------| +| `GET` | `/api/docs` | Api documentation | +| `GET` | `/api/v1/switch` | List of flags for the group. | +| `GET` | `/api/v1/switches/{id}/svg-badge` | SVG badge with actual flag information | +| `GET` | `/api/v1/switches_full_info` | List of all active flags with full info. | ### Admin @@ -31,6 +33,58 @@ Flag/feature toggle service, written in aiohttp. } ``` +## SVG badges + +SVG badges can be useful for showing actual feature flag states. + +Open `Flag detail` page, find `SVG badge` section and copy badge. + + + Screenshot + + ![admin-flag-editing-page](docs/assets/img/admin-flag-editing-page.png) + + + +You will get an image with a link in Markdown format: + +```markdown +[![flag-name](link-to-svg-badge)](link-to-flag-editing-page) +``` + +Then paste text in a text editor with Markdown support (task tracker, Slack, Mattermost etc.). +Actual flag state will be displayed as follows: + +| State | Badge | +|---------------|-------------------------------------------------------------------| +| Active flag | ![active-flag-badge](its_on/static/img/active-flag-badge.svg) | +| Inactive flag | ![inactive-flag-badge](its_on/static/img/inactive-flag-badge.svg) | +| Deleted flag | ![deleted-flag-badge](its_on/static/img/deleted-flag-badge.svg) | +| Unknown flag | ![unknown-flag-badge](its_on/static/img/unknown-flag-badge.svg) | + +See [settings.yaml](settings.yaml) for additional settings. + +## Environment notice + +Environment notice helps to visually distinguish environments (e.g. development, staging, production). + + + Screenshot + + ![admin-environment-indicator](docs/assets/img/admin-environment-indicator.png) + + + +To add the environment notice to every page in the admin, add following settings: + +```env +export DYNACONF_ENVIRONMENT_NOTICE__show=true # false by default +export DYNACONF_ENVIRONMENT_NOTICE__environment_name='' +export DYNACONF_ENVIRONMENT_NOTICE__background_color='' +``` + +See [settings.yaml](settings.yaml) for default settings for each specified environment. + ## Installation ### Prerequisites @@ -75,7 +129,7 @@ and you are good to go! Example `docker-compose.yml` file: -``` +```yaml version: '3' services: cache: diff --git a/docs/assets/img/admin-environment-indicator.png b/docs/assets/img/admin-environment-indicator.png new file mode 100644 index 0000000..f923dc1 Binary files /dev/null and b/docs/assets/img/admin-environment-indicator.png differ diff --git a/docs/assets/img/admin-flag-editing-page.png b/docs/assets/img/admin-flag-editing-page.png new file mode 100644 index 0000000..da89186 Binary files /dev/null and b/docs/assets/img/admin-flag-editing-page.png differ diff --git a/its_on/admin/views/switches.py b/its_on/admin/views/switches.py index 1348649..006e6e5 100644 --- a/its_on/admin/views/switches.py +++ b/its_on/admin/views/switches.py @@ -21,6 +21,7 @@ from its_on.admin.utils import get_switch_history, save_switch_history from its_on.models import switches from its_on.schemes import RemoteSwitchesDataSchema +from its_on.utils import get_switch_badge_svg, get_switch_markdown_badge class SwitchListAdminView(web.View): @@ -83,12 +84,19 @@ class SwitchDetailAdminView(web.View, UpdateMixin): model = switches async def get_context_data( - self, switch: Optional[RowProxy] = None, errors: ValidationError = None, updated: bool = False, + self, switch: Optional[RowProxy] = None, errors: ValidationError = None, + updated: bool = False, ) -> Dict[str, Any]: switch = switch if switch else await self.get_object(self.request) switch_history = await get_switch_history(self.request, switch) + + svg_badge = get_switch_badge_svg(self.request.host, switch) + markdown_badge = get_switch_markdown_badge(self.request, switch) + context_data = { 'object': switch, + 'svg_badge': svg_badge, + 'markdown_badge': markdown_badge, 'switch_history': switch_history, 'errors': errors, 'updated': updated, diff --git a/its_on/constants.py b/its_on/constants.py new file mode 100644 index 0000000..2695a64 --- /dev/null +++ b/its_on/constants.py @@ -0,0 +1,10 @@ +from dynaconf import settings + +SVG_BADGE_SETTINGS = settings.FLAG_SVG_BADGE + +SVG_BADGE_BACKGROUND_COLOR = SVG_BADGE_SETTINGS.BACKGROUND_COLOR + +SWITCH_IS_ACTIVE_SVG_BADGE_PREFIX = SVG_BADGE_SETTINGS.PREFIX.IS_ACTIVE +SWITCH_IS_INACTIVE_SVG_BADGE_PREFIX = SVG_BADGE_SETTINGS.PREFIX.IS_INACTIVE +SWITCH_IS_HIDDEN_SVG_BADGE_PREFIX = SVG_BADGE_SETTINGS.PREFIX.IS_HIDDEN +SWITCH_NOT_FOUND_SVG_BADGE_PREFIX = SVG_BADGE_SETTINGS.PREFIX.NOT_FOUND diff --git a/its_on/main.py b/its_on/main.py index fba8e9b..037b3e1 100644 --- a/its_on/main.py +++ b/its_on/main.py @@ -55,12 +55,19 @@ async def dispose_redis_pool(app: web.Application) -> None: redis_pool.close() await redis_pool.wait_closed() - aiohttp_jinja2.setup( + jinja2_env = aiohttp_jinja2.setup( app, loader=jinja2.FileSystemLoader( str(BASE_DIR / 'its_on' / 'templates'), ), ) + + jinja2_env.globals.update({ + 'show_env_notice': settings.ENVIRONMENT_NOTICE.SHOW, + 'env_notice_name': settings.ENVIRONMENT_NOTICE.ENVIRONMENT_NAME, + 'env_notice_background_color': settings.ENVIRONMENT_NOTICE.BACKGROUND_COLOR, + }) + app['static_root_url'] = '/static' app.on_startup.append(init_pg) diff --git a/its_on/routes.py b/its_on/routes.py index 0ca37e0..1acf592 100644 --- a/its_on/routes.py +++ b/its_on/routes.py @@ -6,7 +6,7 @@ from dynaconf import settings from auth.views import LoginView, LogoutView -from its_on.views import SwitchFullListView, SwitchListView +from its_on.views import SwitchFullListView, SwitchListView, SwitchSvgBadgeView from its_on.admin.views.switches import ( SwitchAddAdminView, SwitchDeleteAdminView, @@ -34,6 +34,13 @@ def setup_routes(app: Application, base_dir: Path, cors_config: CorsConfig) -> N get_switch_view = app.router.add_view('/api/v1/switch', SwitchListView) cors_config.add(get_switch_view) + get_switch_svg_badge_view = app.router.add_view( + '/api/v1/switches/{id}/svg-badge', + SwitchSvgBadgeView, + name='switch_svg_badge', + ) + cors_config.add(get_switch_svg_badge_view) + if settings.ENABLE_SWITCHES_FULL_INFO_ENDPOINT: get_switch_full_view = app.router.add_view('/api/v1/switches_full_info', SwitchFullListView) cors_config.add(get_switch_full_view) diff --git a/its_on/static/img/active-flag-badge.svg b/its_on/static/img/active-flag-badge.svg new file mode 100644 index 0000000..f7e6bda --- /dev/null +++ b/its_on/static/img/active-flag-badge.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + ✅ {host} + ✅ {host} + + + feature-flag-title + feature-flag-title + + diff --git a/its_on/static/img/deleted-flag-badge.svg b/its_on/static/img/deleted-flag-badge.svg new file mode 100644 index 0000000..d5f7683 --- /dev/null +++ b/its_on/static/img/deleted-flag-badge.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + ⚠️ {host} + ⚠️ {host} + + + feature-flag-title (deleted) + feature-flag-title (deleted) + + diff --git a/its_on/static/img/favicon.ico b/its_on/static/img/favicon.ico new file mode 100644 index 0000000..11bf6f3 Binary files /dev/null and b/its_on/static/img/favicon.ico differ diff --git a/its_on/static/img/inactive-flag-badge.svg b/its_on/static/img/inactive-flag-badge.svg new file mode 100644 index 0000000..94d07f1 --- /dev/null +++ b/its_on/static/img/inactive-flag-badge.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + ❌ {host} + ❌ {host} + + + feature-flag-title + feature-flag-title + + diff --git a/its_on/static/img/unknown-flag-badge.svg b/its_on/static/img/unknown-flag-badge.svg new file mode 100644 index 0000000..1fb57e8 --- /dev/null +++ b/its_on/static/img/unknown-flag-badge.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + ⛔ {host} + ⛔ {host} + + + not found + not found + + diff --git a/its_on/templates/base.html b/its_on/templates/base.html index df767a6..4e5c261 100644 --- a/its_on/templates/base.html +++ b/its_on/templates/base.html @@ -1,17 +1,41 @@ - ITS ON Admin - - - - - - - - + {% block title %} + ITS ON Admin + {% endblock %} + {% block head_meta %} + + + + {% endblock %} + {% block head_css %} + + + + {% if show_env_notice %} + + {% endif %} + {% endblock %} + {% block head_js %} + + + + {% endblock %} +{% block page_body %} @@ -24,10 +48,15 @@ ITS ON Admin Log out - + + {% block content %}{% endblock %} +{% endblock %} + +{% block tail_js %} +{% endblock %} diff --git a/its_on/templates/switches/detail.html b/its_on/templates/switches/detail.html index a3afff0..f4fbe7c 100644 --- a/its_on/templates/switches/detail.html +++ b/its_on/templates/switches/detail.html @@ -1,5 +1,22 @@ {% extends "base.html" %} +{% block head_css %} + {{ super() }} + +{% endblock %} + {% block content %} @@ -31,6 +48,24 @@ Flag editing {% else %} - {% endif %} + + + SVG badge: + + + + + + {{ svg_badge | safe }} + + + + + + + + @@ -81,26 +116,26 @@ - - Changed at - Value - + + Changed at + Value + - {% for switch in switch_history %} - - - {{ switch.changed_at.strftime('%d-%m-%Y %H:%M:%S') }} - - - {% if switch.new_value == '1' %} - - {% else %} - - {% endif %} - - - {% endfor %} + {% for switch in switch_history %} + + + {{ switch.changed_at.strftime('%d-%m-%Y %H:%M:%S') }} + + + {% if switch.new_value == '1' %} + + {% else %} + + {% endif %} + + + {% endfor %} @@ -113,3 +148,33 @@ {% endblock %} + +{% block tail_js %} + {{ super() }} + +{% endblock %} diff --git a/its_on/templates/users/login.html b/its_on/templates/users/login.html index a44f6e7..22f55cc 100644 --- a/its_on/templates/users/login.html +++ b/its_on/templates/users/login.html @@ -4,6 +4,7 @@ + diff --git a/its_on/utils.py b/its_on/utils.py index d78d316..065d921 100644 --- a/its_on/utils.py +++ b/its_on/utils.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import datetime import json from typing import Any, Optional, Dict @@ -6,6 +8,16 @@ from aiocache import Cache from aiocache.serializers import JsonSerializer from aiohttp import web +from aiopg.sa.result import RowProxy +from anybadge import Badge + +from its_on.constants import ( + SVG_BADGE_BACKGROUND_COLOR, + SWITCH_IS_HIDDEN_SVG_BADGE_PREFIX, + SWITCH_IS_ACTIVE_SVG_BADGE_PREFIX, + SWITCH_IS_INACTIVE_SVG_BADGE_PREFIX, + SWITCH_NOT_FOUND_SVG_BADGE_PREFIX, +) def setup_cache(app: web.Application) -> None: @@ -26,6 +38,50 @@ def reverse( return str(request.url.join(path_url)) +def get_switch_badge_prefix_and_value(switch: RowProxy) -> tuple[str, str]: + value = switch.name + + if switch.is_hidden: + prefix = SWITCH_IS_HIDDEN_SVG_BADGE_PREFIX + value = value + ' (deleted)' + elif switch.is_active: + prefix = SWITCH_IS_ACTIVE_SVG_BADGE_PREFIX + else: + prefix = SWITCH_IS_INACTIVE_SVG_BADGE_PREFIX + + return prefix, value + + +def get_switch_badge_svg(hostname: str, switch: RowProxy | None = None) -> str: + if switch is not None: + prefix, value = get_switch_badge_prefix_and_value(switch) + else: + prefix = SWITCH_NOT_FOUND_SVG_BADGE_PREFIX + value = 'not found' + + badge = Badge( + label=f'{prefix} {hostname}', value=value, + default_color=SVG_BADGE_BACKGROUND_COLOR, + ) + + return badge.badge_svg_text + + +def get_switch_markdown_badge(request: web.Request, switch: RowProxy) -> str: + flag_url = reverse( + request=request, + router_name='switch_detail', + params={'id': str(switch.id)}, + ) + svg_badge_url = reverse( + request=request, + router_name='switch_svg_badge', + params={'id': str(switch.id)}, + ) + + return f'[![{switch.name}]({svg_badge_url})]({flag_url})' + + class AwareDateTime(sa.TypeDecorator): """Results returned as local datetimes with timezone.""" diff --git a/its_on/views.py b/its_on/views.py index 7744469..e210208 100644 --- a/its_on/views.py +++ b/its_on/views.py @@ -1,5 +1,6 @@ import functools import json +import textwrap from typing import Dict, List, Optional from aiocache import cached @@ -9,6 +10,8 @@ from dynaconf import settings from sqlalchemy.sql import Select, false +from its_on.admin.mixins import GetObjectMixin +from its_on.utils import get_switch_badge_svg from its_on.cache import switch_list_cache_key_builder from its_on.models import switches from its_on.schemes import ( @@ -23,7 +26,7 @@ class SwitchListView(CorsViewMixin, web.View): description='Returns a list of active flags for the passed group.', ) @request_schema(SwitchListRequestSchema(), locations=['query']) - @response_schema(SwitchListResponseSchema(), 200) + @response_schema(SwitchListResponseSchema(), code=200, description='Successful operation') async def get(self) -> web.Response: data = await self.get_response_data() return web.json_response(data) @@ -77,7 +80,7 @@ class SwitchFullListView(CorsViewMixin, web.View): summary='List of all active flags with full info.', description='Returns a list of all active flags with all necessary info for recreation.', ) - @response_schema(SwitchFullListResponseSchema(), 200) + @response_schema(SwitchFullListResponseSchema(), code=200, description='Successful operation') async def get(self) -> web.Response: data = await self.get_response_data() return web.json_response(data, dumps=functools.partial(json.dumps, cls=DateTimeJSONEncoder)) @@ -115,3 +118,37 @@ async def load_objects(self) -> List: def get_queryset(self) -> Select: return switches.select() + + +class SwitchSvgBadgeView(CorsViewMixin, GetObjectMixin, web.View): + model = switches + + @docs( + summary='SVG badge with actual flag information.', + description=textwrap.dedent( + """ + Returns an SVG image with actual flag information: + + | State | Badge | + |-----|-----| + | Active flag | | + | Inactive flag | | + | Deleted flag | | + | Unknown flag | | + + `{host}` here means a host name of the request. + """, + ), + produces=['image/svg+xml'], + responses={ + 200: { + 'description': 'Successful operation', + }, + }, + ) + async def get(self) -> web.Response: + switch = await self.get_object(self.request) + + svg_badge = get_switch_badge_svg(self.request.host, switch) + + return web.Response(body=svg_badge, content_type='image/svg+xml') diff --git a/requirements.txt b/requirements.txt index 151de55..6da6580 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,6 +7,7 @@ aiohttp-session==2.8.0 aiodns==2.0.0 aiopg==1.0.0 aioredis==1.3.1 +anybadge==1.11.1 sqlalchemy==1.3.16 alembic==1.4.2 aiocache[redis]==0.11.1 diff --git a/settings.yaml b/settings.yaml index e1478dd..21b18bb 100644 --- a/settings.yaml +++ b/settings.yaml @@ -13,6 +13,18 @@ default: enable_switches_full_info_endpoint: false sync_from_its_on_url: '@none' flag_ttl_days: 14 + flag_svg_badge: + background_color: '#ff6c6c' + prefix: + is_active: '✅' + is_inactive: '❌' + is_hidden: '⚠️' + not_found: '⛔' + environment_notice: + show: false + environment_name: Development + background_color: '#74b91d' # green + development: environment: Dev @@ -24,9 +36,17 @@ development: production: environment: Prod + environment_notice: + dynaconf_merge: true + environment_name: Production + background_color: '#e64747' # red staging: environment: Staging + environment_notice: + dynaconf_merge: true + environment_name: Staging + background_color: '#c3721a' # orange testing: environment: Test @@ -36,3 +56,7 @@ testing: superuser_dsn: postgresql://bestdoctor:bestdoctor@localhost:5432/its_on cache_ttl: 0 cors_allow_origin: ['http://localhost:8081'] + environment_notice: + dynaconf_merge: true + environment_name: Test + background_color: '#c3721a' # orange diff --git a/tests/conftest.py b/tests/conftest.py index a06066c..0b3b487 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -4,6 +4,8 @@ import pytest import factory.fuzzy +from anybadge import Badge +from anybadge.config import MASK_ID_PREFIX from dynaconf import settings from sqlalchemy.orm import Session @@ -228,7 +230,7 @@ def switch_data_factory_with_ttl(request, switch_data_factory): @pytest.fixture() -async def switch_factory(setup_tables: Callable) -> Callable: +async def switches_factory(setup_tables: Callable) -> Callable: engine = get_engine(settings.DATABASE.DSN) session = Session(engine) @@ -250,3 +252,42 @@ async def _with_params(batch_size: int = 1) -> list: return session.query(switches).all() return _with_params + + +@pytest.fixture() +async def switch_factory(loop, setup_tables: Callable) -> Callable: + engine = get_engine(settings.DATABASE.DSN) + + async def _with_params(**kwargs) -> list: + switch_params = { + 'name': factory.fuzzy.FuzzyText(length=10).fuzz(), + 'is_active': factory.fuzzy.FuzzyChoice([True, False]).fuzz(), + 'is_hidden': factory.fuzzy.FuzzyChoice([True, False]).fuzz(), + 'groups': ( + factory.fuzzy.FuzzyText(length=5).fuzz(), factory.fuzzy.FuzzyText(length=5).fuzz(), + ), + 'version': factory.fuzzy.FuzzyInteger(low=0, high=100).fuzz(), + 'jira_ticket': factory.fuzzy.FuzzyText(length=10).fuzz(), + 'ttl': factory.fuzzy.FuzzyInteger(low=0, high=100).fuzz(), + **kwargs, + } + + with engine.connect() as conn: + conn.execute(switches.insert(), switch_params) + query = switches.select(switches.c.name == switch_params['name']) + return conn.execute(query).fetchone() + + return _with_params + + +@pytest.fixture() +def badge_mask_id_patch(mocker): + # anybadge increments mask_id for each badge to prevent + # SVG duplicates + # set it to 1 just to compare two SVG images without regard to anybadge internal details. + + return mocker.patch.object( + Badge, + '_get_next_mask_str', + return_value=f'{MASK_ID_PREFIX}_1', + ) diff --git a/tests/test_admin.py b/tests/test_admin.py index c1f819c..ab92882 100644 --- a/tests/test_admin.py +++ b/tests/test_admin.py @@ -1,16 +1,18 @@ import datetime import pytest +from aiohttp.test_utils import make_mocked_request from aiohttp.web_exceptions import HTTPOk from freezegun import freeze_time from sqlalchemy import desc from auth.models import users from its_on.models import switch_history, switches +from its_on.utils import get_switch_badge_svg, get_switch_markdown_badge @pytest.mark.usefixtures('setup_tables_and_data') -async def test_switches_list_without_auhtorize(client): +async def test_switches_list_without_authorization(client): response = await client.get('/zbs/switches') assert response.status == 401 @@ -189,7 +191,7 @@ async def test_resurrect_switch(client, login, switch): assert 'switch3' in content.decode('utf-8') -async def test_switches_copy_without_auhtorize(setup_tables_and_data, client): +async def test_switches_copy_without_authorization(setup_tables_and_data, client): response = await client.post('/zbs/switches/copy') assert response.status == 401 @@ -207,7 +209,8 @@ async def test_switches_copy_without_auhtorize(setup_tables_and_data, client): ], ) @freeze_time(datetime.datetime(2020, 10, 15, tzinfo=datetime.timezone.utc)) -@pytest.mark.usefixtures('setup_tables_and_data', 'login', 'get_switches_data_mocked_existing_switch') +@pytest.mark.usefixtures('setup_tables_and_data', 'login', + 'get_switches_data_mocked_existing_switch') async def test_switches_copy_existing_switch_foo( client, db_conn_acquirer, @@ -258,3 +261,31 @@ async def test_switch_strip_spaces( created_switch = await result.first() assert created_switch.name == 'switch' + + +@pytest.mark.usefixtures('setup_tables_and_data', 'login', 'badge_mask_id_patch') +async def test_switch_detail_svg_badge(client, switch): + svg_badge_url = str(client.make_url(f'/api/v1/switches/{switch.id}/svg-badge')) + expected_svg_badge = get_switch_badge_svg( + hostname=f'{client.host}:{client.port}', + switch=switch, + ) + expected_markdown_badge = get_switch_markdown_badge( + request=make_mocked_request( + method='GET', + path=svg_badge_url, + app=client.app, + ), + switch=switch, + ) + + response = await client.get('/zbs/switches/1') + content = await response.content.read() + content = content.decode('utf-8') + + assert switch.name in content + assert 'block-svg-badge' in content + assert 'copy-md-badge-btn' in content + assert 'md-badge' in content + assert expected_svg_badge in content + assert expected_markdown_badge in content diff --git a/tests/test_api_switch.py b/tests/test_api_switch.py index 0ebe24f..5b49d99 100644 --- a/tests/test_api_switch.py +++ b/tests/test_api_switch.py @@ -1,5 +1,7 @@ import pytest +from its_on.utils import get_switch_badge_svg + async def test_switch(setup_tables_and_data, client): response = await client.get('/api/v1/switch?group=group1') @@ -76,10 +78,26 @@ async def test_switch_filter_by_version(version, expected_result, setup_tables_a async def test_switches_full_info( - switch_factory, client, asserted_switch_full_info_data, + switches_factory, client, asserted_switch_full_info_data, ): - all_switches = await switch_factory(batch_size=5) + all_switches = await switches_factory(batch_size=5) response = await client.get('/api/v1/switches_full_info') assert response.status == 200 assert await response.json() == asserted_switch_full_info_data(all_switches) + + +@pytest.mark.usefixtures('badge_mask_id_patch') +async def test_switch_svg_badge_view( + switch_factory, client, asserted_switch_full_info_data, +): + switch = await switch_factory() + expected_badge_svg = get_switch_badge_svg( + hostname=f'{client.host}:{client.port}', + switch=switch, + ) + + response = await client.get(f'/api/v1/switches/{switch.id}/svg-badge') + + assert response.status == 200 + assert await response.text() == expected_badge_svg diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 0000000..a759e42 --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,135 @@ +import pytest +from aiohttp.test_utils import make_mocked_request +from anybadge import Badge + +from its_on.constants import ( + SVG_BADGE_BACKGROUND_COLOR, + SWITCH_NOT_FOUND_SVG_BADGE_PREFIX, + SWITCH_IS_INACTIVE_SVG_BADGE_PREFIX, + SWITCH_IS_ACTIVE_SVG_BADGE_PREFIX, + SWITCH_IS_HIDDEN_SVG_BADGE_PREFIX, +) +from its_on.utils import ( + get_switch_badge_prefix_and_value, + get_switch_badge_svg, + get_switch_markdown_badge, +) + + +@pytest.mark.usefixtures('setup_tables_and_data') +@pytest.mark.parametrize( + ('switch_params', 'expected_prefix', 'expected_value'), + [ + ( + { + 'name': 'feature-flag-1', + 'is_active': True, + 'is_hidden': False, + }, + SWITCH_IS_ACTIVE_SVG_BADGE_PREFIX, + 'feature-flag-1', + ), + ( + { + 'name': 'feature-flag-1', + 'is_active': False, + 'is_hidden': False, + }, + SWITCH_IS_INACTIVE_SVG_BADGE_PREFIX, + 'feature-flag-1', + ), + + ( + { + 'name': 'feature-flag-1', + 'is_hidden': True, + }, + SWITCH_IS_HIDDEN_SVG_BADGE_PREFIX, + 'feature-flag-1 (deleted)', + ), + ], + ids=['active-flag', 'inactive-flag', 'deleted-flag'], +) +async def test_get_switch_badge_prefix_and_value( + switch_factory, switch_params, expected_prefix, expected_value, +): + switch = await switch_factory(**switch_params) + + prefix, value = get_switch_badge_prefix_and_value(switch) + + assert prefix == expected_prefix + assert value == expected_value + + +@pytest.mark.usefixtures('setup_tables_and_data', 'badge_mask_id_patch') +async def test_get_switch_badge_svg_for_active_switch(switch_factory): + hostname = 'flags-staging.bestdoctor.ru' + switch = await switch_factory(is_active=True, is_hidden=False) + expected_badge = Badge( + label=f'{SWITCH_IS_ACTIVE_SVG_BADGE_PREFIX} {hostname}', value=switch.name, + default_color=SVG_BADGE_BACKGROUND_COLOR, + ) + + svg_badge = get_switch_badge_svg(hostname, switch) + + assert svg_badge == expected_badge.badge_svg_text + + +@pytest.mark.usefixtures('setup_tables_and_data', 'badge_mask_id_patch') +async def test_get_switch_badge_svg_for_inactive_switch(switch_factory): + hostname = 'flags-staging.bestdoctor.ru' + switch = await switch_factory(is_active=False, is_hidden=False) + expected_badge = Badge( + label=f'{SWITCH_IS_INACTIVE_SVG_BADGE_PREFIX} {hostname}', value=switch.name, + default_color=SVG_BADGE_BACKGROUND_COLOR, + ) + + svg_badge = get_switch_badge_svg(hostname, switch) + + assert svg_badge == expected_badge.badge_svg_text + + +@pytest.mark.usefixtures('setup_tables_and_data', 'badge_mask_id_patch') +async def test_get_switch_badge_svg_for_deleted_switch(switch_factory): + hostname = 'flags-staging.bestdoctor.ru' + switch = await switch_factory(is_hidden=True) + expected_badge = Badge( + label=f'{SWITCH_IS_HIDDEN_SVG_BADGE_PREFIX} {hostname}', + value=f'{switch.name} (deleted)', + default_color=SVG_BADGE_BACKGROUND_COLOR, + ) + + svg_badge = get_switch_badge_svg(hostname, switch) + + assert svg_badge == expected_badge.badge_svg_text + + +@pytest.mark.usefixtures('badge_mask_id_patch') +def test_get_switch_badge_svg_for_unknown_switch(): + hostname = 'flags-staging.bestdoctor.ru' + expected_badge = Badge( + label=f'{SWITCH_NOT_FOUND_SVG_BADGE_PREFIX} {hostname}', + value='not found', + default_color=SVG_BADGE_BACKGROUND_COLOR, + ) + + svg_badge = get_switch_badge_svg(hostname, switch=None) + + assert svg_badge == expected_badge.badge_svg_text + + +@pytest.mark.usefixtures('setup_tables_and_data', 'badge_mask_id_patch') +async def test_get_switch_markdown_badge(switch_factory, client): + switch = await switch_factory() + flag_url = str(client.make_url(f'/zbs/switches/{switch.id}')) + svg_badge_url = str(client.make_url(f'/api/v1/switches/{switch.id}/svg-badge')) + expected_badge = f'[![{switch.name}]({svg_badge_url})]({flag_url})' + request = make_mocked_request( + method='GET', + path=svg_badge_url, + app=client.app, + ) + + markdown_badge = get_switch_markdown_badge(request, switch) + + assert markdown_badge == expected_badge