From 9467e47f2f0e893a2271389b8f371ad7f5723607 Mon Sep 17 00:00:00 2001 From: Evandro Myller Date: Mon, 5 Jan 2026 21:07:18 -0300 Subject: [PATCH 1/6] Add `make reset` command for local development --- api/Makefile | 5 + api/app/settings/test.py | 4 +- .../commands/reset_local_database.py | 379 ++++++++++++++++++ .../core/test_reset_local_database.py | 101 +++++ .../project-and-community/contributing.md | 19 + 5 files changed, 507 insertions(+), 1 deletion(-) create mode 100644 api/core/management/commands/reset_local_database.py create mode 100644 api/tests/integration/core/test_reset_local_database.py diff --git a/api/Makefile b/api/Makefile index 5e550dae2421..cb74c8c96888 100644 --- a/api/Makefile +++ b/api/Makefile @@ -156,3 +156,8 @@ generate-docs: .PHONY: add-known-sdk-version add-known-sdk-version: poetry run python scripts/add-known-sdk-version.py $(opts) + +.PHONY: reset +reset: docker-up + poetry run python manage.py waitfordb + poetry run python manage.py reset_local_database diff --git a/api/app/settings/test.py b/api/app/settings/test.py index 605d54aba53b..0185c7b3bb4e 100644 --- a/api/app/settings/test.py +++ b/api/app/settings/test.py @@ -1,5 +1,7 @@ from app.settings.common import * # noqa -from app.settings.common import REST_FRAMEWORK +from app.settings.common import ALLOWED_HOSTS, REST_FRAMEWORK + +ALLOWED_HOSTS = [*ALLOWED_HOSTS, "testserver"] # We dont want to track tests ENABLE_TELEMETRY = False diff --git a/api/core/management/commands/reset_local_database.py b/api/core/management/commands/reset_local_database.py new file mode 100644 index 000000000000..78f356431328 --- /dev/null +++ b/api/core/management/commands/reset_local_database.py @@ -0,0 +1,379 @@ +import json +from typing import Any + +from django.core.management import BaseCommand, call_command +from django.urls import reverse +from rest_framework.test import APIClient + + +class Command(BaseCommand): + help = "Resets and seeds the database with test data for local development" + + def handle(self, *args: Any, **kwargs: Any) -> None: + self.stdout.write("Flushing database...") + call_command("flush", "--noinput", verbosity=0) + + self.stdout.write("Running migrations...") + call_command("migrate", verbosity=0) + + self.stdout.write("Creating cache table...") + call_command("createcachetable", verbosity=0) + + self.stdout.write("Seeding database with test data...") + self._seed_database() + + def _seed_database(self) -> None: + email = "local@flagsmith.com" + password = "testpass1" + + client = APIClient() + + # Create user via signup API + signup_url = reverse("api-v1:custom_auth:ffadminuser-list") + signup_response = client.post( + signup_url, + data={ + "email": email, + "password": password, + "re_password": password, + "first_name": "Local", + "last_name": "Developer", + }, + ) + auth_token = signup_response.json()["key"] + client.credentials(HTTP_AUTHORIZATION=f"Token {auth_token}") + + # Create organisation via API (user becomes admin automatically) + org_name = "Acme, Inc." + org_url = reverse("api-v1:organisations:organisation-list") + org_response = client.post(org_url, data={"name": org_name}) + organisation_id = org_response.json()["id"] + + # Create project via API + project_name = "AI Booster" + project_url = reverse("api-v1:projects:project-list") + project_response = client.post( + project_url, + data={"name": project_name, "organisation": organisation_id}, + ) + project_id = project_response.json()["id"] + + # Create environments via API + env_url = reverse("api-v1:environments:environment-list") + dev_env_response = client.post( + env_url, + data={"name": "Development", "project": project_id}, + ) + dev_environment = dev_env_response.json() + dev_env_id = dev_environment["id"] + dev_env_api_key = dev_environment["api_key"] + + client.post( + env_url, + data={"name": "Staging", "project": project_id}, + ) + + client.post( + env_url, + data={"name": "Production", "project": project_id}, + ) + + # Create features via API + feature_url = reverse( + "api-v1:projects:project-features-list", args=[project_id] + ) + + dark_mode_response = client.post( + feature_url, + data={ + "name": "dark_mode", + "description": "Enable dark mode theme for the application", + "default_enabled": True, + "type": "FLAG", + }, + ) + dark_mode_id = dark_mode_response.json()["id"] + + client.post( + feature_url, + data={ + "name": "ai_assistant", + "description": "Enable AI-powered assistant features", + "default_enabled": False, + "type": "FLAG", + }, + ) + + api_rate_limit_response = client.post( + feature_url, + data={ + "name": "api_rate_limit", + "description": "Maximum API requests per minute", + "default_enabled": True, + "type": "CONFIG", + "initial_value": "100", + }, + ) + api_rate_limit_id = api_rate_limit_response.json()["id"] + + welcome_message_response = client.post( + feature_url, + data={ + "name": "welcome_message", + "description": "Welcome message displayed to users", + "default_enabled": True, + "type": "CONFIG", + "initial_value": "Welcome to AI Booster!", + }, + ) + welcome_message_id = welcome_message_response.json()["id"] + + client.post( + feature_url, + data={ + "name": "feature_config", + "description": "JSON configuration for feature behavior", + "default_enabled": True, + "type": "CONFIG", + "initial_value": '{"theme": "modern", "animations": true}', + }, + ) + + beta_features_response = client.post( + feature_url, + data={ + "name": "beta_features", + "description": "Enable access to beta features", + "default_enabled": True, + "type": "FLAG", + }, + ) + beta_features_id = beta_features_response.json()["id"] + + # Create segments via API + segment_url = reverse( + "api-v1:projects:project-segments-list", args=[project_id] + ) + + premium_segment_response = client.post( + segment_url, + data=json.dumps( + { + "name": "Premium Users", + "description": "Users with premium subscription and active status", + "project": project_id, + "rules": [ + { + "type": "ALL", + "rules": [ + { + "type": "ANY", + "rules": [], + "conditions": [ + { + "property": "subscription_tier", + "operator": "EQUAL", + "value": "premium", + }, + { + "property": "account_age", + "operator": "GREATER_THAN_INCLUSIVE", + "value": "30", + }, + ], + } + ], + "conditions": [], + } + ], + } + ), + content_type="application/json", + ) + premium_segment_id = premium_segment_response.json()["id"] + + beta_segment_response = client.post( + segment_url, + data=json.dumps( + { + "name": "Beta Testers", + "description": "Users enrolled in beta testing program", + "project": project_id, + "rules": [ + { + "type": "ALL", + "rules": [], + "conditions": [ + { + "property": "beta_tester", + "operator": "EQUAL", + "value": "true", + }, + ], + } + ], + } + ), + content_type="application/json", + ) + beta_segment_id = beta_segment_response.json()["id"] + + client.post( + segment_url, + data=json.dumps( + { + "name": "50% Rollout", + "description": "50% of users for gradual feature rollout", + "project": project_id, + "rules": [ + { + "type": "ALL", + "rules": [], + "conditions": [ + { + "property": "id", + "operator": "PERCENTAGE_SPLIT", + "value": "50", + }, + ], + } + ], + } + ), + content_type="application/json", + ) + + # Create feature segments via API + feature_segment_url = reverse("api-v1:features:feature-segment-list") + + client.post( + feature_segment_url, + data=json.dumps( + { + "feature": dark_mode_id, + "segment": premium_segment_id, + "environment": dev_env_id, + "enabled": True, + } + ), + content_type="application/json", + ) + + client.post( + feature_segment_url, + data=json.dumps( + { + "feature": beta_features_id, + "segment": beta_segment_id, + "environment": dev_env_id, + "enabled": True, + } + ), + content_type="application/json", + ) + + # Create segment overrides with custom values + client.post( + feature_segment_url, + data=json.dumps( + { + "feature": api_rate_limit_id, + "segment": premium_segment_id, + "environment": dev_env_id, + "enabled": True, + "value": "500", + } + ), + content_type="application/json", + ) + + client.post( + feature_segment_url, + data=json.dumps( + { + "feature": welcome_message_id, + "segment": beta_segment_id, + "environment": dev_env_id, + "enabled": True, + "value": "Welcome, Beta Tester!", + } + ), + content_type="application/json", + ) + + # Create identities via API + identity_url = reverse( + "api-v1:environments:environment-identities-list", args=[dev_env_api_key] + ) + + alice_response = client.post( + identity_url, data={"identifier": "alice@example.com"} + ) + alice_id = alice_response.json()["id"] + + bob_response = client.post(identity_url, data={"identifier": "bob@example.com"}) + bob_id = bob_response.json()["id"] + + # Create identity overrides via API + identity_featurestates_url = reverse( + "api-v1:environments:identity-featurestates-list", + args=[dev_env_api_key, alice_id], + ) + + # Override dark_mode to false for alice + client.post( + identity_featurestates_url, + data=json.dumps( + { + "feature": dark_mode_id, + "enabled": False, + } + ), + content_type="application/json", + ) + + # Override welcome_message for bob + bob_featurestates_url = reverse( + "api-v1:environments:identity-featurestates-list", + args=[dev_env_api_key, bob_id], + ) + + client.post( + bob_featurestates_url, + data=json.dumps( + { + "feature": dark_mode_id, + "enabled": True, + } + ), + content_type="application/json", + ) + + # Print summary + self.stdout.write(self.style.SUCCESS("\nDatabase seeded successfully\n")) + + self.stdout.write("Created entities:\n") + self.stdout.write(f" Organisation: {org_name}\n") + self.stdout.write(f" Project: {project_name}\n") + self.stdout.write(" Environments (3):\n") + self.stdout.write(" Development\n") + self.stdout.write(" Staging\n") + self.stdout.write(" Production\n") + self.stdout.write(" Features (6):\n") + self.stdout.write(" dark_mode (FLAG, enabled)\n") + self.stdout.write(" ai_assistant (FLAG, disabled)\n") + self.stdout.write(" api_rate_limit (CONFIG)\n") + self.stdout.write(" welcome_message (CONFIG)\n") + self.stdout.write(" feature_config (CONFIG)\n") + self.stdout.write(" beta_features (FLAG, enabled)\n") + self.stdout.write(" Segments (3):\n") + self.stdout.write(" Premium Users (with overrides)\n") + self.stdout.write(" Beta Testers (with overrides)\n") + self.stdout.write(" 50% Rollout\n") + self.stdout.write(" Identities (2):\n") + self.stdout.write(" alice@example.com (with overrides)\n") + self.stdout.write(" bob@example.com (with overrides)\n") + + self.stdout.write("\nLogin credentials:\n") + self.stdout.write(f" Email: {email}\n") + self.stdout.write(f" Password: {password}\n") diff --git a/api/tests/integration/core/test_reset_local_database.py b/api/tests/integration/core/test_reset_local_database.py new file mode 100644 index 000000000000..9d12f99fdd6c --- /dev/null +++ b/api/tests/integration/core/test_reset_local_database.py @@ -0,0 +1,101 @@ +from unittest.mock import MagicMock + +import pytest +from django.core.management import call_command +from pytest_mock import MockerFixture + +from environments.identities.models import Identity +from environments.models import Environment +from environments.permissions.models import UserEnvironmentPermission +from features.models import Feature, FeatureSegment +from organisations.models import ( + Organisation, + OrganisationRole, + Subscription, + UserOrganisation, +) +from projects.models import Project, UserProjectPermission +from segments.models import Segment +from users.models import FFAdminUser + +pytestmark = pytest.mark.django_db + + +@pytest.fixture(autouse=True) +def mock_reset_commands(mocker: MockerFixture) -> MagicMock: + """Mock flush/migrate/createcachetable to avoid resetting the test database.""" + return mocker.patch( + "core.management.commands.reset_local_database.call_command", + ) + + +def test_reset_local_database__calls_reset_commands( + mock_reset_commands: MagicMock, + mocker: MockerFixture, +) -> None: + # When + call_command("reset_local_database") + + # Then + assert mock_reset_commands.call_args_list == [ + mocker.call("flush", "--noinput", verbosity=0), + mocker.call("migrate", verbosity=0), + mocker.call("createcachetable", verbosity=0), + ] + + +# Multiple assertions are grouped in this test to avoid running the slow +# reset_local_database command multiple times. +def test_reset_local_database__creates_expected_data() -> None: + # When + call_command("reset_local_database") + + # Then: expected entity counts + assert FFAdminUser.objects.count() == 1 + assert Organisation.objects.count() == 1 + assert Project.objects.count() == 1 + assert Environment.objects.count() == 3 + assert Feature.objects.count() == 6 + assert Segment.objects.count() == 3 + assert FeatureSegment.objects.count() == 4 + + # Then: environments are created correctly + environment_names = set(Environment.objects.values_list("name", flat=True)) + assert environment_names == {"Development", "Staging", "Production"} + environment = Environment.objects.get(name="Development") + assert environment.project.name == "AI Booster" + + # Then: feature segments belong to the Development environment + feature_segments = FeatureSegment.objects.all() + for feature_segment in feature_segments: + assert feature_segment.environment == environment + + # Then: user organisation has admin role + user_organisation = UserOrganisation.objects.first() + assert user_organisation is not None + assert user_organisation.role == OrganisationRole.ADMIN.name + + # Then: subscription exists for the organisation + organisation = Organisation.objects.first() + assert organisation is not None + assert Subscription.objects.filter(organisation=organisation).exists() + + # Then: user has project admin access + user = FFAdminUser.objects.first() + project = Project.objects.first() + assert user is not None + assert project is not None + user_project_permission = UserProjectPermission.objects.filter( + user=user, project=project + ).first() + assert user_project_permission is not None + assert user_project_permission.admin is True + + # Then: user has admin access to all three environments + assert UserEnvironmentPermission.objects.filter(user=user, admin=True).count() == 3 + + # Then: identities are created + assert Identity.objects.count() == 2 + identifiers = list(Identity.objects.values_list("identifier", flat=True)) + assert "alice@example.com" in identifiers + assert "bob@example.com" in identifiers diff --git a/docs/docs/project-and-community/contributing.md b/docs/docs/project-and-community/contributing.md index 39bed9a895e5..e669a5a3a3b3 100644 --- a/docs/docs/project-and-community/contributing.md +++ b/docs/docs/project-and-community/contributing.md @@ -49,6 +49,25 @@ You can also manually run all the checks across the entire codebase with: pre-commit run --all-files ``` +## Local Development + +To run the API locally with Docker services: + +```bash +cd api +make serve +``` + +To reset your local database and populate it with test data: + +```bash +cd api +make reset +``` + +The command creates a development environment with sample organisations, projects, +features, and segments for testing. + ## Running Tests The application uses pytest for writing (appropriate use of fixtures) and running tests. Before running tests please make sure that `DJANGO_SETTINGS_MODULE` env var is pointing to the right module, e.g. `app.settings.test`. From 41a49a9581ce53a23a545d12a3f276572ca3aa1f Mon Sep 17 00:00:00 2001 From: Evandro Myller Date: Mon, 5 Jan 2026 17:09:16 -0300 Subject: [PATCH 2/6] Fix local environment defaults --- api/.env-local | 2 ++ frontend/.env-local | 5 +++++ 2 files changed, 7 insertions(+) create mode 100644 frontend/.env-local diff --git a/api/.env-local b/api/.env-local index ff290fe34c85..fe53d02b4521 100644 --- a/api/.env-local +++ b/api/.env-local @@ -1,3 +1,5 @@ DATABASE_URL=postgresql://postgres:password@localhost:5432/flagsmith DJANGO_SETTINGS_MODULE=app.settings.local PYTEST_ADDOPTS=--cov . --cov-report html -n auto +USE_SECURE_COOKIES=False +COOKIE_SAME_SITE=lax diff --git a/frontend/.env-local b/frontend/.env-local new file mode 100644 index 000000000000..3c01e1fdd0d0 --- /dev/null +++ b/frontend/.env-local @@ -0,0 +1,5 @@ +# Use local environment configuration (frontend/env/project_local.js) +ENV=local + +USE_SECURE_COOKIES=false +COOKIE_SAME_SITE=lax From 5ab6637694dd84a26d137eb5c9bd209f933663c7 Mon Sep 17 00:00:00 2001 From: Evandro Myller Date: Mon, 5 Jan 2026 18:18:49 -0300 Subject: [PATCH 3/6] Fix cookieSameSite reading wrong environment variable --- frontend/api/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/api/index.js b/frontend/api/index.js index 1027f557943c..1dd331ac8c1a 100755 --- a/frontend/api/index.js +++ b/frontend/api/index.js @@ -107,7 +107,7 @@ app.get('/config/project-overrides', (req, res) => { }, { name: 'albacross', value: process.env.ALBACROSS_CLIENT_ID }, { name: 'useSecureCookies', value: envToBool('USE_SECURE_COOKIES', true) }, - { name: 'cookieSameSite', value: process.env.USE_SECURE_COOKIES }, + { name: 'cookieSameSite', value: process.env.COOKIE_SAME_SITE }, { name: 'cookieAuthEnabled', value: process.env.COOKIE_AUTH_ENABLED }, { name: 'githubAppURL', From c05a9c570642d8e9b88efb990528ce1927c3a1a8 Mon Sep 17 00:00:00 2001 From: Evandro Myller Date: Mon, 5 Jan 2026 21:08:45 -0300 Subject: [PATCH 4/6] Block `make reset` with a safety setting --- api/app/settings/common.py | 4 ++++ api/app/settings/local.py | 2 ++ .../commands/reset_local_database.py | 8 ++++++- .../core/test_reset_local_database.py | 22 ++++++++++++++++++- 4 files changed, 34 insertions(+), 2 deletions(-) diff --git a/api/app/settings/common.py b/api/app/settings/common.py index e194a7a36b34..9dca9103f9a5 100644 --- a/api/app/settings/common.py +++ b/api/app/settings/common.py @@ -1471,3 +1471,7 @@ PYLON_IDENTITY_VERIFICATION_SECRET = env.str("PYLON_IDENTITY_VERIFICATION_SECRET", None) OSIC_UPDATE_BATCH_SIZE = env.int("OSIC_UPDATE_BATCH_SIZE", default=500) + +# Allow the reset_local_database management command to run. +# This command flushes and seeds the database with test data. +ENABLE_LOCAL_DATABASE_RESET = False diff --git a/api/app/settings/local.py b/api/app/settings/local.py index becc3a0cea18..c2a1ec0d8c48 100644 --- a/api/app/settings/local.py +++ b/api/app/settings/local.py @@ -18,3 +18,5 @@ ENABLE_ADMIN_ACCESS_USER_PASS = True SKIP_MIGRATION_TESTS = True + +ENABLE_LOCAL_DATABASE_RESET = True diff --git a/api/core/management/commands/reset_local_database.py b/api/core/management/commands/reset_local_database.py index 78f356431328..e548ea46b9d6 100644 --- a/api/core/management/commands/reset_local_database.py +++ b/api/core/management/commands/reset_local_database.py @@ -1,7 +1,8 @@ import json from typing import Any -from django.core.management import BaseCommand, call_command +from django.conf import settings +from django.core.management import BaseCommand, CommandError, call_command from django.urls import reverse from rest_framework.test import APIClient @@ -10,6 +11,11 @@ class Command(BaseCommand): help = "Resets and seeds the database with test data for local development" def handle(self, *args: Any, **kwargs: Any) -> None: + if not settings.ENABLE_LOCAL_DATABASE_RESET: + raise CommandError( + "This command is disabled. " + "Set ENABLE_LOCAL_DATABASE_RESET to True in Django settings to enable it." + ) self.stdout.write("Flushing database...") call_command("flush", "--noinput", verbosity=0) diff --git a/api/tests/integration/core/test_reset_local_database.py b/api/tests/integration/core/test_reset_local_database.py index 9d12f99fdd6c..219792bd521b 100644 --- a/api/tests/integration/core/test_reset_local_database.py +++ b/api/tests/integration/core/test_reset_local_database.py @@ -1,7 +1,8 @@ from unittest.mock import MagicMock import pytest -from django.core.management import call_command +from django.core.management import CommandError, call_command +from pytest_django.fixtures import SettingsWrapper from pytest_mock import MockerFixture from environments.identities.models import Identity @@ -21,6 +22,12 @@ pytestmark = pytest.mark.django_db +@pytest.fixture(autouse=True) +def enable_local_database_reset(settings: SettingsWrapper) -> None: + """Enable the reset_local_database command for tests.""" + settings.ENABLE_LOCAL_DATABASE_RESET = True + + @pytest.fixture(autouse=True) def mock_reset_commands(mocker: MockerFixture) -> MagicMock: """Mock flush/migrate/createcachetable to avoid resetting the test database.""" @@ -99,3 +106,16 @@ def test_reset_local_database__creates_expected_data() -> None: identifiers = list(Identity.objects.values_list("identifier", flat=True)) assert "alice@example.com" in identifiers assert "bob@example.com" in identifiers + + +def test_reset_local_database__raises_error_when_disabled( + settings: SettingsWrapper, +) -> None: + # Given + settings.ENABLE_LOCAL_DATABASE_RESET = False + + # When / Then + with pytest.raises(CommandError) as exc_info: + call_command("reset_local_database") + + assert "ENABLE_LOCAL_DATABASE_RESET" in str(exc_info.value) From 1c11cc66838f0d86a854cb0ad21be53eaafb6c96 Mon Sep 17 00:00:00 2001 From: Evandro Myller Date: Mon, 5 Jan 2026 22:09:05 -0300 Subject: [PATCH 5/6] Fix segment and identity overrides in reset command --- .../commands/reset_local_database.py | 59 +++--- .../core/test_reset_local_database.py | 174 ++++++++++++++---- 2 files changed, 171 insertions(+), 62 deletions(-) diff --git a/api/core/management/commands/reset_local_database.py b/api/core/management/commands/reset_local_database.py index e548ea46b9d6..763e960eeff5 100644 --- a/api/core/management/commands/reset_local_database.py +++ b/api/core/management/commands/reset_local_database.py @@ -71,7 +71,6 @@ def _seed_database(self) -> None: data={"name": "Development", "project": project_id}, ) dev_environment = dev_env_response.json() - dev_env_id = dev_environment["id"] dev_env_api_key = dev_environment["api_key"] client.post( @@ -249,59 +248,70 @@ def _seed_database(self) -> None: content_type="application/json", ) - # Create feature segments via API - feature_segment_url = reverse("api-v1:features:feature-segment-list") - + # Create segment overrides via API + # dark_mode enabled for Premium Users + dark_mode_override_url = reverse( + "api-v1:environments:create-segment-override", + args=[dev_env_api_key, dark_mode_id], + ) client.post( - feature_segment_url, + dark_mode_override_url, data=json.dumps( { - "feature": dark_mode_id, - "segment": premium_segment_id, - "environment": dev_env_id, + "feature_segment": {"segment": premium_segment_id}, "enabled": True, + "feature_state_value": {}, } ), content_type="application/json", ) + # beta_features enabled for Beta Testers + beta_features_override_url = reverse( + "api-v1:environments:create-segment-override", + args=[dev_env_api_key, beta_features_id], + ) client.post( - feature_segment_url, + beta_features_override_url, data=json.dumps( { - "feature": beta_features_id, - "segment": beta_segment_id, - "environment": dev_env_id, + "feature_segment": {"segment": beta_segment_id}, "enabled": True, + "feature_state_value": {}, } ), content_type="application/json", ) - # Create segment overrides with custom values + # api_rate_limit with custom value for Premium Users + api_rate_limit_override_url = reverse( + "api-v1:environments:create-segment-override", + args=[dev_env_api_key, api_rate_limit_id], + ) client.post( - feature_segment_url, + api_rate_limit_override_url, data=json.dumps( { - "feature": api_rate_limit_id, - "segment": premium_segment_id, - "environment": dev_env_id, + "feature_segment": {"segment": premium_segment_id}, "enabled": True, - "value": "500", + "feature_state_value": {"type": "int", "integer_value": 500}, } ), content_type="application/json", ) + # welcome_message with custom value for Beta Testers + welcome_message_override_url = reverse( + "api-v1:environments:create-segment-override", + args=[dev_env_api_key, welcome_message_id], + ) client.post( - feature_segment_url, + welcome_message_override_url, data=json.dumps( { - "feature": welcome_message_id, - "segment": beta_segment_id, - "environment": dev_env_id, + "feature_segment": {"segment": beta_segment_id}, "enabled": True, - "value": "Welcome, Beta Tester!", + "feature_state_value": {"string_value": "Welcome, Beta Tester!"}, } ), content_type="application/json", @@ -348,8 +358,9 @@ def _seed_database(self) -> None: bob_featurestates_url, data=json.dumps( { - "feature": dark_mode_id, + "feature": welcome_message_id, "enabled": True, + "feature_state_value": "Hello, Bob!", } ), content_type="application/json", diff --git a/api/tests/integration/core/test_reset_local_database.py b/api/tests/integration/core/test_reset_local_database.py index 219792bd521b..39c997d0b1a2 100644 --- a/api/tests/integration/core/test_reset_local_database.py +++ b/api/tests/integration/core/test_reset_local_database.py @@ -8,7 +8,7 @@ from environments.identities.models import Identity from environments.models import Environment from environments.permissions.models import UserEnvironmentPermission -from features.models import Feature, FeatureSegment +from features.models import Feature, FeatureSegment, FeatureState from organisations.models import ( Organisation, OrganisationRole, @@ -57,55 +57,153 @@ def test_reset_local_database__creates_expected_data() -> None: # When call_command("reset_local_database") - # Then: expected entity counts - assert FFAdminUser.objects.count() == 1 - assert Organisation.objects.count() == 1 - assert Project.objects.count() == 1 - assert Environment.objects.count() == 3 - assert Feature.objects.count() == 6 - assert Segment.objects.count() == 3 - assert FeatureSegment.objects.count() == 4 - - # Then: environments are created correctly - environment_names = set(Environment.objects.values_list("name", flat=True)) - assert environment_names == {"Development", "Staging", "Production"} - environment = Environment.objects.get(name="Development") - assert environment.project.name == "AI Booster" - - # Then: feature segments belong to the Development environment - feature_segments = FeatureSegment.objects.all() - for feature_segment in feature_segments: - assert feature_segment.environment == environment - - # Then: user organisation has admin role - user_organisation = UserOrganisation.objects.first() - assert user_organisation is not None + # Then: user is created with expected attributes + user = FFAdminUser.objects.get() + assert user.email == "local@flagsmith.com" + assert user.first_name == "Local" + assert user.last_name == "Developer" + assert user.check_password("testpass1") + + # Then: organisation is created with expected name + organisation = Organisation.objects.get() + assert organisation.name == "Acme, Inc." + + # Then: user has admin role in organisation + user_organisation = UserOrganisation.objects.get() + assert user_organisation.user == user + assert user_organisation.organisation == organisation assert user_organisation.role == OrganisationRole.ADMIN.name # Then: subscription exists for the organisation - organisation = Organisation.objects.first() - assert organisation is not None assert Subscription.objects.filter(organisation=organisation).exists() + # Then: project is created with expected name + project = Project.objects.get() + assert project.name == "AI Booster" + assert project.organisation == organisation + # Then: user has project admin access - user = FFAdminUser.objects.first() - project = Project.objects.first() - assert user is not None - assert project is not None - user_project_permission = UserProjectPermission.objects.filter( - user=user, project=project - ).first() - assert user_project_permission is not None + user_project_permission = UserProjectPermission.objects.get(user=user) + assert user_project_permission.project == project assert user_project_permission.admin is True + # Then: environments are created correctly + environments = Environment.objects.all() + assert environments.count() == 3 + environment_names = set(environments.values_list("name", flat=True)) + assert environment_names == {"Development", "Staging", "Production"} + for env in environments: + assert env.project == project + # Then: user has admin access to all three environments assert UserEnvironmentPermission.objects.filter(user=user, admin=True).count() == 3 + dev_environment = Environment.objects.get(name="Development") + + # Then: features are created with correct attributes + features = Feature.objects.all() + assert features.count() == 6 + + dark_mode = Feature.objects.get(name="dark_mode") + assert dark_mode.description == "Enable dark mode theme for the application" + assert dark_mode.default_enabled is True + assert dark_mode.type == "FLAG" + + ai_assistant = Feature.objects.get(name="ai_assistant") + assert ai_assistant.description == "Enable AI-powered assistant features" + assert ai_assistant.default_enabled is False + assert ai_assistant.type == "FLAG" + + api_rate_limit = Feature.objects.get(name="api_rate_limit") + assert api_rate_limit.description == "Maximum API requests per minute" + assert api_rate_limit.default_enabled is True + assert api_rate_limit.type == "CONFIG" + assert api_rate_limit.initial_value == "100" + + welcome_message = Feature.objects.get(name="welcome_message") + assert welcome_message.description == "Welcome message displayed to users" + assert welcome_message.default_enabled is True + assert welcome_message.type == "CONFIG" + assert welcome_message.initial_value == "Welcome to AI Booster!" + + feature_config = Feature.objects.get(name="feature_config") + assert feature_config.description == "JSON configuration for feature behavior" + assert feature_config.default_enabled is True + assert feature_config.type == "CONFIG" + assert feature_config.initial_value == '{"theme": "modern", "animations": true}' + + beta_features = Feature.objects.get(name="beta_features") + assert beta_features.description == "Enable access to beta features" + assert beta_features.default_enabled is True + assert beta_features.type == "FLAG" + + # Then: segments are created with correct attributes + segments = Segment.objects.all() + assert segments.count() == 3 + + premium_segment = Segment.objects.get(name="Premium Users") + assert ( + premium_segment.description + == "Users with premium subscription and active status" + ) + + beta_segment = Segment.objects.get(name="Beta Testers") + assert beta_segment.description == "Users enrolled in beta testing program" + + rollout_segment = Segment.objects.get(name="50% Rollout") + assert rollout_segment.description == "50% of users for gradual feature rollout" + + # Then: segment overrides are created with correct values + feature_segments = FeatureSegment.objects.all() + assert feature_segments.count() == 4 + for feature_segment in feature_segments: + assert feature_segment.environment == dev_environment + + # dark_mode -> Premium Users + dark_mode_fs = FeatureSegment.objects.get( + feature=dark_mode, segment=premium_segment + ) + dark_mode_override = FeatureState.objects.get(feature_segment=dark_mode_fs) + assert dark_mode_override.enabled is True + + # beta_features -> Beta Testers + beta_fs = FeatureSegment.objects.get(feature=beta_features, segment=beta_segment) + beta_override = FeatureState.objects.get(feature_segment=beta_fs) + assert beta_override.enabled is True + + # api_rate_limit -> Premium Users with value "500" + rate_limit_fs = FeatureSegment.objects.get( + feature=api_rate_limit, segment=premium_segment + ) + rate_limit_override = FeatureState.objects.get(feature_segment=rate_limit_fs) + assert rate_limit_override.enabled is True + assert rate_limit_override.get_feature_state_value() == 500 + + # welcome_message -> Beta Testers with value "Welcome, Beta Tester!" + welcome_fs = FeatureSegment.objects.get( + feature=welcome_message, segment=beta_segment + ) + welcome_override = FeatureState.objects.get(feature_segment=welcome_fs) + assert welcome_override.enabled is True + assert welcome_override.get_feature_state_value() == "Welcome, Beta Tester!" + # Then: identities are created - assert Identity.objects.count() == 2 - identifiers = list(Identity.objects.values_list("identifier", flat=True)) - assert "alice@example.com" in identifiers - assert "bob@example.com" in identifiers + identities = Identity.objects.all() + assert identities.count() == 2 + alice = Identity.objects.get(identifier="alice@example.com") + bob = Identity.objects.get(identifier="bob@example.com") + assert alice.environment == dev_environment + assert bob.environment == dev_environment + + # Then: identity overrides are created with correct values + # alice: dark_mode disabled + alice_dark_mode = FeatureState.objects.get(identity=alice, feature=dark_mode) + assert alice_dark_mode.enabled is False + + # bob: welcome_message with custom value + bob_welcome = FeatureState.objects.get(identity=bob, feature=welcome_message) + assert bob_welcome.enabled is True + assert bob_welcome.get_feature_state_value() == "Hello, Bob!" def test_reset_local_database__raises_error_when_disabled( From a45b8b8188e3163e805412ed2b63befe8dd0e68f Mon Sep 17 00:00:00 2001 From: Evandro Myller Date: Mon, 5 Jan 2026 22:38:59 -0300 Subject: [PATCH 6/6] Fix coverage --- api/pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/api/pyproject.toml b/api/pyproject.toml index 077a39a6d35a..90d919fb6cfb 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -63,6 +63,7 @@ exclude_also = [ [tool.coverage.run] omit = [ "app/settings/common.py", + "app/settings/local.py", "manage.py", "e2etests/*", "scripts/*",