6 Commits

Author SHA1 Message Date
Felipe Cardoso
664415111a test(backend): add comprehensive tests for OAuth and agent endpoints
- Added tests for OAuth provider admin and consent endpoints covering edge cases.
- Extended agent-related tests to handle incorrect project associations and lifecycle state transitions.
- Introduced tests for sprint status transitions and validation checks.
- Improved multiline formatting consistency across all test functions.
2026-01-03 01:44:11 +01:00
Felipe Cardoso
acd18ff694 chore(backend): standardize multiline formatting across modules
Reformatted multiline function calls, object definitions, and queries for improved code readability and consistency. Adjusted imports and constraints where necessary.
2026-01-03 01:35:18 +01:00
Felipe Cardoso
da5affd613 fix(frontend): remove locale-dependent routing and migrate to centralized locale-aware router
- Replaced `next/navigation` with `@/lib/i18n/routing` across components, pages, and tests.
- Removed redundant `locale` props from `ProjectWizard` and related pages.
- Updated navigation to exclude explicit `locale` in paths.
- Refactored tests to use mocks from `next-intl/navigation`.
2026-01-03 01:34:53 +01:00
Felipe Cardoso
a79d923dc1 test(frontend): improve test coverage and update edge case handling
- Refactor tests to handle empty `model_params` in AgentTypeForm.
- Add return type annotations (`: never`) for throwing functions in ErrorBoundary tests.
- Mock `useAuth` in home page tests for consistent auth state handling.
- Update Header test to validate updated `/dashboard` link.
2026-01-03 01:19:35 +01:00
Felipe Cardoso
c72f6aa2f9 fix(frontend): redirect authenticated users to dashboard from landing page
- Added auth check in landing page using `useAuth`.
- Redirect authenticated users to `/dashboard`.
- Display blank screen during auth verification or redirection.
2026-01-03 01:12:58 +01:00
Felipe Cardoso
4f24cebf11 chore(frontend): improve code formatting for readability
Standardize multiline formatting across components, tests, and API hooks for better consistency and clarity:
- Adjusted function and object property indentation.
- Updated tests and components to align with clean coding practices.
2026-01-03 01:12:51 +01:00
81 changed files with 2238 additions and 701 deletions

View File

@@ -40,6 +40,7 @@ def include_object(object, name, type_, reflected, compare_to):
return False
return True
# Interpret the config file for Python logging.
# This line sets up loggers basically.
if config.config_file_name is not None:

View File

@@ -5,6 +5,7 @@ Revises:
Create Date: 2025-11-27 09:08:09.464506
"""
from collections.abc import Sequence
import sqlalchemy as sa
@@ -12,7 +13,7 @@ from alembic import op
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = '0001'
revision: str = "0001"
down_revision: str | None = None
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
@@ -20,243 +21,426 @@ depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('oauth_states',
sa.Column('state', sa.String(length=255), nullable=False),
sa.Column('code_verifier', sa.String(length=128), nullable=True),
sa.Column('nonce', sa.String(length=255), nullable=True),
sa.Column('provider', sa.String(length=50), nullable=False),
sa.Column('redirect_uri', sa.String(length=500), nullable=True),
sa.Column('user_id', sa.UUID(), nullable=True),
sa.Column('expires_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint('id')
op.create_table(
"oauth_states",
sa.Column("state", sa.String(length=255), nullable=False),
sa.Column("code_verifier", sa.String(length=128), nullable=True),
sa.Column("nonce", sa.String(length=255), nullable=True),
sa.Column("provider", sa.String(length=50), nullable=False),
sa.Column("redirect_uri", sa.String(length=500), nullable=True),
sa.Column("user_id", sa.UUID(), nullable=True),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("id", sa.UUID(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f('ix_oauth_states_state'), 'oauth_states', ['state'], unique=True)
op.create_table('organizations',
sa.Column('name', sa.String(length=255), nullable=False),
sa.Column('slug', sa.String(length=255), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=False),
sa.Column('settings', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint('id')
op.create_index(
op.f("ix_oauth_states_state"), "oauth_states", ["state"], unique=True
)
op.create_index(op.f('ix_organizations_is_active'), 'organizations', ['is_active'], unique=False)
op.create_index(op.f('ix_organizations_name'), 'organizations', ['name'], unique=False)
op.create_index('ix_organizations_name_active', 'organizations', ['name', 'is_active'], unique=False)
op.create_index(op.f('ix_organizations_slug'), 'organizations', ['slug'], unique=True)
op.create_index('ix_organizations_slug_active', 'organizations', ['slug', 'is_active'], unique=False)
op.create_table('users',
sa.Column('email', sa.String(length=255), nullable=False),
sa.Column('password_hash', sa.String(length=255), nullable=True),
sa.Column('first_name', sa.String(length=100), nullable=False),
sa.Column('last_name', sa.String(length=100), nullable=True),
sa.Column('phone_number', sa.String(length=20), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=False),
sa.Column('is_superuser', sa.Boolean(), nullable=False),
sa.Column('preferences', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column('locale', sa.String(length=10), nullable=True),
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint('id')
op.create_table(
"organizations",
sa.Column("name", sa.String(length=255), nullable=False),
sa.Column("slug", sa.String(length=255), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("is_active", sa.Boolean(), nullable=False),
sa.Column("settings", postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column("id", sa.UUID(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f('ix_users_deleted_at'), 'users', ['deleted_at'], unique=False)
op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True)
op.create_index(op.f('ix_users_is_active'), 'users', ['is_active'], unique=False)
op.create_index(op.f('ix_users_is_superuser'), 'users', ['is_superuser'], unique=False)
op.create_index(op.f('ix_users_locale'), 'users', ['locale'], unique=False)
op.create_table('oauth_accounts',
sa.Column('user_id', sa.UUID(), nullable=False),
sa.Column('provider', sa.String(length=50), nullable=False),
sa.Column('provider_user_id', sa.String(length=255), nullable=False),
sa.Column('provider_email', sa.String(length=255), nullable=True),
sa.Column('access_token_encrypted', sa.String(length=2048), nullable=True),
sa.Column('refresh_token_encrypted', sa.String(length=2048), nullable=True),
sa.Column('token_expires_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('provider', 'provider_user_id', name='uq_oauth_provider_user')
op.create_index(
op.f("ix_organizations_is_active"), "organizations", ["is_active"], unique=False
)
op.create_index(op.f('ix_oauth_accounts_provider'), 'oauth_accounts', ['provider'], unique=False)
op.create_index(op.f('ix_oauth_accounts_provider_email'), 'oauth_accounts', ['provider_email'], unique=False)
op.create_index(op.f('ix_oauth_accounts_user_id'), 'oauth_accounts', ['user_id'], unique=False)
op.create_index('ix_oauth_accounts_user_provider', 'oauth_accounts', ['user_id', 'provider'], unique=False)
op.create_table('oauth_clients',
sa.Column('client_id', sa.String(length=64), nullable=False),
sa.Column('client_secret_hash', sa.String(length=255), nullable=True),
sa.Column('client_name', sa.String(length=255), nullable=False),
sa.Column('client_description', sa.String(length=1000), nullable=True),
sa.Column('client_type', sa.String(length=20), nullable=False),
sa.Column('redirect_uris', postgresql.JSONB(astext_type=sa.Text()), nullable=False),
sa.Column('allowed_scopes', postgresql.JSONB(astext_type=sa.Text()), nullable=False),
sa.Column('access_token_lifetime', sa.String(length=10), nullable=False),
sa.Column('refresh_token_lifetime', sa.String(length=10), nullable=False),
sa.Column('is_active', sa.Boolean(), nullable=False),
sa.Column('owner_user_id', sa.UUID(), nullable=True),
sa.Column('mcp_server_url', sa.String(length=2048), nullable=True),
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['owner_user_id'], ['users.id'], ondelete='SET NULL'),
sa.PrimaryKeyConstraint('id')
op.create_index(
op.f("ix_organizations_name"), "organizations", ["name"], unique=False
)
op.create_index(op.f('ix_oauth_clients_client_id'), 'oauth_clients', ['client_id'], unique=True)
op.create_index(op.f('ix_oauth_clients_is_active'), 'oauth_clients', ['is_active'], unique=False)
op.create_table('user_organizations',
sa.Column('user_id', sa.UUID(), nullable=False),
sa.Column('organization_id', sa.UUID(), nullable=False),
sa.Column('role', sa.Enum('OWNER', 'ADMIN', 'MEMBER', 'GUEST', name='organizationrole'), nullable=False),
sa.Column('is_active', sa.Boolean(), nullable=False),
sa.Column('custom_permissions', sa.String(length=500), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['organization_id'], ['organizations.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('user_id', 'organization_id')
op.create_index(
"ix_organizations_name_active",
"organizations",
["name", "is_active"],
unique=False,
)
op.create_index('ix_user_org_org_active', 'user_organizations', ['organization_id', 'is_active'], unique=False)
op.create_index('ix_user_org_role', 'user_organizations', ['role'], unique=False)
op.create_index('ix_user_org_user_active', 'user_organizations', ['user_id', 'is_active'], unique=False)
op.create_index(op.f('ix_user_organizations_is_active'), 'user_organizations', ['is_active'], unique=False)
op.create_table('user_sessions',
sa.Column('user_id', sa.UUID(), nullable=False),
sa.Column('refresh_token_jti', sa.String(length=255), nullable=False),
sa.Column('device_name', sa.String(length=255), nullable=True),
sa.Column('device_id', sa.String(length=255), nullable=True),
sa.Column('ip_address', sa.String(length=45), nullable=True),
sa.Column('user_agent', sa.String(length=500), nullable=True),
sa.Column('last_used_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('expires_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('is_active', sa.Boolean(), nullable=False),
sa.Column('location_city', sa.String(length=100), nullable=True),
sa.Column('location_country', sa.String(length=100), nullable=True),
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
op.create_index(
op.f("ix_organizations_slug"), "organizations", ["slug"], unique=True
)
op.create_index(op.f('ix_user_sessions_is_active'), 'user_sessions', ['is_active'], unique=False)
op.create_index('ix_user_sessions_jti_active', 'user_sessions', ['refresh_token_jti', 'is_active'], unique=False)
op.create_index(op.f('ix_user_sessions_refresh_token_jti'), 'user_sessions', ['refresh_token_jti'], unique=True)
op.create_index('ix_user_sessions_user_active', 'user_sessions', ['user_id', 'is_active'], unique=False)
op.create_index(op.f('ix_user_sessions_user_id'), 'user_sessions', ['user_id'], unique=False)
op.create_table('oauth_authorization_codes',
sa.Column('code', sa.String(length=128), nullable=False),
sa.Column('client_id', sa.String(length=64), nullable=False),
sa.Column('user_id', sa.UUID(), nullable=False),
sa.Column('redirect_uri', sa.String(length=2048), nullable=False),
sa.Column('scope', sa.String(length=1000), nullable=False),
sa.Column('code_challenge', sa.String(length=128), nullable=True),
sa.Column('code_challenge_method', sa.String(length=10), nullable=True),
sa.Column('state', sa.String(length=256), nullable=True),
sa.Column('nonce', sa.String(length=256), nullable=True),
sa.Column('expires_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('used', sa.Boolean(), nullable=False),
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['client_id'], ['oauth_clients.client_id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
op.create_index(
"ix_organizations_slug_active",
"organizations",
["slug", "is_active"],
unique=False,
)
op.create_index('ix_oauth_authorization_codes_client_user', 'oauth_authorization_codes', ['client_id', 'user_id'], unique=False)
op.create_index(op.f('ix_oauth_authorization_codes_code'), 'oauth_authorization_codes', ['code'], unique=True)
op.create_index('ix_oauth_authorization_codes_expires_at', 'oauth_authorization_codes', ['expires_at'], unique=False)
op.create_table('oauth_consents',
sa.Column('user_id', sa.UUID(), nullable=False),
sa.Column('client_id', sa.String(length=64), nullable=False),
sa.Column('granted_scopes', sa.String(length=1000), nullable=False),
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['client_id'], ['oauth_clients.client_id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
op.create_table(
"users",
sa.Column("email", sa.String(length=255), nullable=False),
sa.Column("password_hash", sa.String(length=255), nullable=True),
sa.Column("first_name", sa.String(length=100), nullable=False),
sa.Column("last_name", sa.String(length=100), nullable=True),
sa.Column("phone_number", sa.String(length=20), nullable=True),
sa.Column("is_active", sa.Boolean(), nullable=False),
sa.Column("is_superuser", sa.Boolean(), nullable=False),
sa.Column(
"preferences", postgresql.JSONB(astext_type=sa.Text()), nullable=True
),
sa.Column("locale", sa.String(length=10), nullable=True),
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("id", sa.UUID(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("id"),
)
op.create_index('ix_oauth_consents_user_client', 'oauth_consents', ['user_id', 'client_id'], unique=True)
op.create_table('oauth_provider_refresh_tokens',
sa.Column('token_hash', sa.String(length=64), nullable=False),
sa.Column('jti', sa.String(length=64), nullable=False),
sa.Column('client_id', sa.String(length=64), nullable=False),
sa.Column('user_id', sa.UUID(), nullable=False),
sa.Column('scope', sa.String(length=1000), nullable=False),
sa.Column('expires_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('revoked', sa.Boolean(), nullable=False),
sa.Column('last_used_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('device_info', sa.String(length=500), nullable=True),
sa.Column('ip_address', sa.String(length=45), nullable=True),
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['client_id'], ['oauth_clients.client_id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
op.create_index(op.f("ix_users_deleted_at"), "users", ["deleted_at"], unique=False)
op.create_index(op.f("ix_users_email"), "users", ["email"], unique=True)
op.create_index(op.f("ix_users_is_active"), "users", ["is_active"], unique=False)
op.create_index(
op.f("ix_users_is_superuser"), "users", ["is_superuser"], unique=False
)
op.create_index(op.f("ix_users_locale"), "users", ["locale"], unique=False)
op.create_table(
"oauth_accounts",
sa.Column("user_id", sa.UUID(), nullable=False),
sa.Column("provider", sa.String(length=50), nullable=False),
sa.Column("provider_user_id", sa.String(length=255), nullable=False),
sa.Column("provider_email", sa.String(length=255), nullable=True),
sa.Column("access_token_encrypted", sa.String(length=2048), nullable=True),
sa.Column("refresh_token_encrypted", sa.String(length=2048), nullable=True),
sa.Column("token_expires_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("id", sa.UUID(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint(
"provider", "provider_user_id", name="uq_oauth_provider_user"
),
)
op.create_index(
op.f("ix_oauth_accounts_provider"), "oauth_accounts", ["provider"], unique=False
)
op.create_index(
op.f("ix_oauth_accounts_provider_email"),
"oauth_accounts",
["provider_email"],
unique=False,
)
op.create_index(
op.f("ix_oauth_accounts_user_id"), "oauth_accounts", ["user_id"], unique=False
)
op.create_index(
"ix_oauth_accounts_user_provider",
"oauth_accounts",
["user_id", "provider"],
unique=False,
)
op.create_table(
"oauth_clients",
sa.Column("client_id", sa.String(length=64), nullable=False),
sa.Column("client_secret_hash", sa.String(length=255), nullable=True),
sa.Column("client_name", sa.String(length=255), nullable=False),
sa.Column("client_description", sa.String(length=1000), nullable=True),
sa.Column("client_type", sa.String(length=20), nullable=False),
sa.Column(
"redirect_uris", postgresql.JSONB(astext_type=sa.Text()), nullable=False
),
sa.Column(
"allowed_scopes", postgresql.JSONB(astext_type=sa.Text()), nullable=False
),
sa.Column("access_token_lifetime", sa.String(length=10), nullable=False),
sa.Column("refresh_token_lifetime", sa.String(length=10), nullable=False),
sa.Column("is_active", sa.Boolean(), nullable=False),
sa.Column("owner_user_id", sa.UUID(), nullable=True),
sa.Column("mcp_server_url", sa.String(length=2048), nullable=True),
sa.Column("id", sa.UUID(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(["owner_user_id"], ["users.id"], ondelete="SET NULL"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
op.f("ix_oauth_clients_client_id"), "oauth_clients", ["client_id"], unique=True
)
op.create_index(
op.f("ix_oauth_clients_is_active"), "oauth_clients", ["is_active"], unique=False
)
op.create_table(
"user_organizations",
sa.Column("user_id", sa.UUID(), nullable=False),
sa.Column("organization_id", sa.UUID(), nullable=False),
sa.Column(
"role",
sa.Enum("OWNER", "ADMIN", "MEMBER", "GUEST", name="organizationrole"),
nullable=False,
),
sa.Column("is_active", sa.Boolean(), nullable=False),
sa.Column("custom_permissions", sa.String(length=500), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(
["organization_id"], ["organizations.id"], ondelete="CASCADE"
),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("user_id", "organization_id"),
)
op.create_index(
"ix_user_org_org_active",
"user_organizations",
["organization_id", "is_active"],
unique=False,
)
op.create_index("ix_user_org_role", "user_organizations", ["role"], unique=False)
op.create_index(
"ix_user_org_user_active",
"user_organizations",
["user_id", "is_active"],
unique=False,
)
op.create_index(
op.f("ix_user_organizations_is_active"),
"user_organizations",
["is_active"],
unique=False,
)
op.create_table(
"user_sessions",
sa.Column("user_id", sa.UUID(), nullable=False),
sa.Column("refresh_token_jti", sa.String(length=255), nullable=False),
sa.Column("device_name", sa.String(length=255), nullable=True),
sa.Column("device_id", sa.String(length=255), nullable=True),
sa.Column("ip_address", sa.String(length=45), nullable=True),
sa.Column("user_agent", sa.String(length=500), nullable=True),
sa.Column("last_used_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("is_active", sa.Boolean(), nullable=False),
sa.Column("location_city", sa.String(length=100), nullable=True),
sa.Column("location_country", sa.String(length=100), nullable=True),
sa.Column("id", sa.UUID(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
op.f("ix_user_sessions_is_active"), "user_sessions", ["is_active"], unique=False
)
op.create_index(
"ix_user_sessions_jti_active",
"user_sessions",
["refresh_token_jti", "is_active"],
unique=False,
)
op.create_index(
op.f("ix_user_sessions_refresh_token_jti"),
"user_sessions",
["refresh_token_jti"],
unique=True,
)
op.create_index(
"ix_user_sessions_user_active",
"user_sessions",
["user_id", "is_active"],
unique=False,
)
op.create_index(
op.f("ix_user_sessions_user_id"), "user_sessions", ["user_id"], unique=False
)
op.create_table(
"oauth_authorization_codes",
sa.Column("code", sa.String(length=128), nullable=False),
sa.Column("client_id", sa.String(length=64), nullable=False),
sa.Column("user_id", sa.UUID(), nullable=False),
sa.Column("redirect_uri", sa.String(length=2048), nullable=False),
sa.Column("scope", sa.String(length=1000), nullable=False),
sa.Column("code_challenge", sa.String(length=128), nullable=True),
sa.Column("code_challenge_method", sa.String(length=10), nullable=True),
sa.Column("state", sa.String(length=256), nullable=True),
sa.Column("nonce", sa.String(length=256), nullable=True),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("used", sa.Boolean(), nullable=False),
sa.Column("id", sa.UUID(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(
["client_id"], ["oauth_clients.client_id"], ondelete="CASCADE"
),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
"ix_oauth_authorization_codes_client_user",
"oauth_authorization_codes",
["client_id", "user_id"],
unique=False,
)
op.create_index(
op.f("ix_oauth_authorization_codes_code"),
"oauth_authorization_codes",
["code"],
unique=True,
)
op.create_index(
"ix_oauth_authorization_codes_expires_at",
"oauth_authorization_codes",
["expires_at"],
unique=False,
)
op.create_table(
"oauth_consents",
sa.Column("user_id", sa.UUID(), nullable=False),
sa.Column("client_id", sa.String(length=64), nullable=False),
sa.Column("granted_scopes", sa.String(length=1000), nullable=False),
sa.Column("id", sa.UUID(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(
["client_id"], ["oauth_clients.client_id"], ondelete="CASCADE"
),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
"ix_oauth_consents_user_client",
"oauth_consents",
["user_id", "client_id"],
unique=True,
)
op.create_table(
"oauth_provider_refresh_tokens",
sa.Column("token_hash", sa.String(length=64), nullable=False),
sa.Column("jti", sa.String(length=64), nullable=False),
sa.Column("client_id", sa.String(length=64), nullable=False),
sa.Column("user_id", sa.UUID(), nullable=False),
sa.Column("scope", sa.String(length=1000), nullable=False),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("revoked", sa.Boolean(), nullable=False),
sa.Column("last_used_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("device_info", sa.String(length=500), nullable=True),
sa.Column("ip_address", sa.String(length=45), nullable=True),
sa.Column("id", sa.UUID(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(
["client_id"], ["oauth_clients.client_id"], ondelete="CASCADE"
),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
"ix_oauth_provider_refresh_tokens_client_user",
"oauth_provider_refresh_tokens",
["client_id", "user_id"],
unique=False,
)
op.create_index(
"ix_oauth_provider_refresh_tokens_expires_at",
"oauth_provider_refresh_tokens",
["expires_at"],
unique=False,
)
op.create_index(
op.f("ix_oauth_provider_refresh_tokens_jti"),
"oauth_provider_refresh_tokens",
["jti"],
unique=True,
)
op.create_index(
op.f("ix_oauth_provider_refresh_tokens_revoked"),
"oauth_provider_refresh_tokens",
["revoked"],
unique=False,
)
op.create_index(
op.f("ix_oauth_provider_refresh_tokens_token_hash"),
"oauth_provider_refresh_tokens",
["token_hash"],
unique=True,
)
op.create_index(
"ix_oauth_provider_refresh_tokens_user_revoked",
"oauth_provider_refresh_tokens",
["user_id", "revoked"],
unique=False,
)
op.create_index('ix_oauth_provider_refresh_tokens_client_user', 'oauth_provider_refresh_tokens', ['client_id', 'user_id'], unique=False)
op.create_index('ix_oauth_provider_refresh_tokens_expires_at', 'oauth_provider_refresh_tokens', ['expires_at'], unique=False)
op.create_index(op.f('ix_oauth_provider_refresh_tokens_jti'), 'oauth_provider_refresh_tokens', ['jti'], unique=True)
op.create_index(op.f('ix_oauth_provider_refresh_tokens_revoked'), 'oauth_provider_refresh_tokens', ['revoked'], unique=False)
op.create_index(op.f('ix_oauth_provider_refresh_tokens_token_hash'), 'oauth_provider_refresh_tokens', ['token_hash'], unique=True)
op.create_index('ix_oauth_provider_refresh_tokens_user_revoked', 'oauth_provider_refresh_tokens', ['user_id', 'revoked'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index('ix_oauth_provider_refresh_tokens_user_revoked', table_name='oauth_provider_refresh_tokens')
op.drop_index(op.f('ix_oauth_provider_refresh_tokens_token_hash'), table_name='oauth_provider_refresh_tokens')
op.drop_index(op.f('ix_oauth_provider_refresh_tokens_revoked'), table_name='oauth_provider_refresh_tokens')
op.drop_index(op.f('ix_oauth_provider_refresh_tokens_jti'), table_name='oauth_provider_refresh_tokens')
op.drop_index('ix_oauth_provider_refresh_tokens_expires_at', table_name='oauth_provider_refresh_tokens')
op.drop_index('ix_oauth_provider_refresh_tokens_client_user', table_name='oauth_provider_refresh_tokens')
op.drop_table('oauth_provider_refresh_tokens')
op.drop_index('ix_oauth_consents_user_client', table_name='oauth_consents')
op.drop_table('oauth_consents')
op.drop_index('ix_oauth_authorization_codes_expires_at', table_name='oauth_authorization_codes')
op.drop_index(op.f('ix_oauth_authorization_codes_code'), table_name='oauth_authorization_codes')
op.drop_index('ix_oauth_authorization_codes_client_user', table_name='oauth_authorization_codes')
op.drop_table('oauth_authorization_codes')
op.drop_index(op.f('ix_user_sessions_user_id'), table_name='user_sessions')
op.drop_index('ix_user_sessions_user_active', table_name='user_sessions')
op.drop_index(op.f('ix_user_sessions_refresh_token_jti'), table_name='user_sessions')
op.drop_index('ix_user_sessions_jti_active', table_name='user_sessions')
op.drop_index(op.f('ix_user_sessions_is_active'), table_name='user_sessions')
op.drop_table('user_sessions')
op.drop_index(op.f('ix_user_organizations_is_active'), table_name='user_organizations')
op.drop_index('ix_user_org_user_active', table_name='user_organizations')
op.drop_index('ix_user_org_role', table_name='user_organizations')
op.drop_index('ix_user_org_org_active', table_name='user_organizations')
op.drop_table('user_organizations')
op.drop_index(op.f('ix_oauth_clients_is_active'), table_name='oauth_clients')
op.drop_index(op.f('ix_oauth_clients_client_id'), table_name='oauth_clients')
op.drop_table('oauth_clients')
op.drop_index('ix_oauth_accounts_user_provider', table_name='oauth_accounts')
op.drop_index(op.f('ix_oauth_accounts_user_id'), table_name='oauth_accounts')
op.drop_index(op.f('ix_oauth_accounts_provider_email'), table_name='oauth_accounts')
op.drop_index(op.f('ix_oauth_accounts_provider'), table_name='oauth_accounts')
op.drop_table('oauth_accounts')
op.drop_index(op.f('ix_users_locale'), table_name='users')
op.drop_index(op.f('ix_users_is_superuser'), table_name='users')
op.drop_index(op.f('ix_users_is_active'), table_name='users')
op.drop_index(op.f('ix_users_email'), table_name='users')
op.drop_index(op.f('ix_users_deleted_at'), table_name='users')
op.drop_table('users')
op.drop_index('ix_organizations_slug_active', table_name='organizations')
op.drop_index(op.f('ix_organizations_slug'), table_name='organizations')
op.drop_index('ix_organizations_name_active', table_name='organizations')
op.drop_index(op.f('ix_organizations_name'), table_name='organizations')
op.drop_index(op.f('ix_organizations_is_active'), table_name='organizations')
op.drop_table('organizations')
op.drop_index(op.f('ix_oauth_states_state'), table_name='oauth_states')
op.drop_table('oauth_states')
op.drop_index(
"ix_oauth_provider_refresh_tokens_user_revoked",
table_name="oauth_provider_refresh_tokens",
)
op.drop_index(
op.f("ix_oauth_provider_refresh_tokens_token_hash"),
table_name="oauth_provider_refresh_tokens",
)
op.drop_index(
op.f("ix_oauth_provider_refresh_tokens_revoked"),
table_name="oauth_provider_refresh_tokens",
)
op.drop_index(
op.f("ix_oauth_provider_refresh_tokens_jti"),
table_name="oauth_provider_refresh_tokens",
)
op.drop_index(
"ix_oauth_provider_refresh_tokens_expires_at",
table_name="oauth_provider_refresh_tokens",
)
op.drop_index(
"ix_oauth_provider_refresh_tokens_client_user",
table_name="oauth_provider_refresh_tokens",
)
op.drop_table("oauth_provider_refresh_tokens")
op.drop_index("ix_oauth_consents_user_client", table_name="oauth_consents")
op.drop_table("oauth_consents")
op.drop_index(
"ix_oauth_authorization_codes_expires_at",
table_name="oauth_authorization_codes",
)
op.drop_index(
op.f("ix_oauth_authorization_codes_code"),
table_name="oauth_authorization_codes",
)
op.drop_index(
"ix_oauth_authorization_codes_client_user",
table_name="oauth_authorization_codes",
)
op.drop_table("oauth_authorization_codes")
op.drop_index(op.f("ix_user_sessions_user_id"), table_name="user_sessions")
op.drop_index("ix_user_sessions_user_active", table_name="user_sessions")
op.drop_index(
op.f("ix_user_sessions_refresh_token_jti"), table_name="user_sessions"
)
op.drop_index("ix_user_sessions_jti_active", table_name="user_sessions")
op.drop_index(op.f("ix_user_sessions_is_active"), table_name="user_sessions")
op.drop_table("user_sessions")
op.drop_index(
op.f("ix_user_organizations_is_active"), table_name="user_organizations"
)
op.drop_index("ix_user_org_user_active", table_name="user_organizations")
op.drop_index("ix_user_org_role", table_name="user_organizations")
op.drop_index("ix_user_org_org_active", table_name="user_organizations")
op.drop_table("user_organizations")
op.drop_index(op.f("ix_oauth_clients_is_active"), table_name="oauth_clients")
op.drop_index(op.f("ix_oauth_clients_client_id"), table_name="oauth_clients")
op.drop_table("oauth_clients")
op.drop_index("ix_oauth_accounts_user_provider", table_name="oauth_accounts")
op.drop_index(op.f("ix_oauth_accounts_user_id"), table_name="oauth_accounts")
op.drop_index(op.f("ix_oauth_accounts_provider_email"), table_name="oauth_accounts")
op.drop_index(op.f("ix_oauth_accounts_provider"), table_name="oauth_accounts")
op.drop_table("oauth_accounts")
op.drop_index(op.f("ix_users_locale"), table_name="users")
op.drop_index(op.f("ix_users_is_superuser"), table_name="users")
op.drop_index(op.f("ix_users_is_active"), table_name="users")
op.drop_index(op.f("ix_users_email"), table_name="users")
op.drop_index(op.f("ix_users_deleted_at"), table_name="users")
op.drop_table("users")
op.drop_index("ix_organizations_slug_active", table_name="organizations")
op.drop_index(op.f("ix_organizations_slug"), table_name="organizations")
op.drop_index("ix_organizations_name_active", table_name="organizations")
op.drop_index(op.f("ix_organizations_name"), table_name="organizations")
op.drop_index(op.f("ix_organizations_is_active"), table_name="organizations")
op.drop_table("organizations")
op.drop_index(op.f("ix_oauth_states_state"), table_name="oauth_states")
op.drop_table("oauth_states")
# ### end Alembic commands ###

View File

@@ -114,8 +114,13 @@ def upgrade() -> None:
def downgrade() -> None:
# Drop indexes in reverse order
op.drop_index("ix_perf_oauth_auth_codes_expires", table_name="oauth_authorization_codes")
op.drop_index("ix_perf_oauth_refresh_tokens_expires", table_name="oauth_provider_refresh_tokens")
op.drop_index(
"ix_perf_oauth_auth_codes_expires", table_name="oauth_authorization_codes"
)
op.drop_index(
"ix_perf_oauth_refresh_tokens_expires",
table_name="oauth_provider_refresh_tokens",
)
op.drop_index("ix_perf_user_sessions_expires", table_name="user_sessions")
op.drop_index("ix_perf_organizations_slug_lower", table_name="organizations")
op.drop_index("ix_perf_users_active", table_name="users")

View File

@@ -443,9 +443,7 @@ def upgrade() -> None:
),
sa.PrimaryKeyConstraint("id"),
sa.ForeignKeyConstraint(["project_id"], ["projects.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(
["parent_id"], ["issues.id"], ondelete="CASCADE"
),
sa.ForeignKeyConstraint(["parent_id"], ["issues.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["sprint_id"], ["sprints.id"], ondelete="SET NULL"),
sa.ForeignKeyConstraint(
["assigned_agent_id"], ["agent_instances.id"], ondelete="SET NULL"
@@ -462,7 +460,9 @@ def upgrade() -> None:
op.create_index("ix_issues_human_assignee", "issues", ["human_assignee"])
op.create_index("ix_issues_sprint_id", "issues", ["sprint_id"])
op.create_index("ix_issues_due_date", "issues", ["due_date"])
op.create_index("ix_issues_external_tracker_type", "issues", ["external_tracker_type"])
op.create_index(
"ix_issues_external_tracker_type", "issues", ["external_tracker_type"]
)
op.create_index("ix_issues_sync_status", "issues", ["sync_status"])
op.create_index("ix_issues_closed_at", "issues", ["closed_at"])
# Composite indexes
@@ -470,7 +470,9 @@ def upgrade() -> None:
op.create_index("ix_issues_project_priority", "issues", ["project_id", "priority"])
op.create_index("ix_issues_project_sprint", "issues", ["project_id", "sprint_id"])
op.create_index("ix_issues_project_type", "issues", ["project_id", "type"])
op.create_index("ix_issues_project_agent", "issues", ["project_id", "assigned_agent_id"])
op.create_index(
"ix_issues_project_agent", "issues", ["project_id", "assigned_agent_id"]
)
op.create_index(
"ix_issues_project_status_priority",
"issues",

View File

@@ -32,9 +32,7 @@ api_router.include_router(
api_router.include_router(events.router, tags=["Events"])
# Syndarix domain routers
api_router.include_router(
projects.router, prefix="/projects", tags=["Projects"]
)
api_router.include_router(projects.router, prefix="/projects", tags=["Projects"])
api_router.include_router(
agent_types.router, prefix="/agent-types", tags=["Agent Types"]
)

View File

@@ -57,8 +57,18 @@ RATE_MULTIPLIER = 100 if IS_TEST else 1
# Valid status transitions for agent lifecycle management
VALID_STATUS_TRANSITIONS: dict[AgentStatus, set[AgentStatus]] = {
AgentStatus.IDLE: {AgentStatus.WORKING, AgentStatus.PAUSED, AgentStatus.TERMINATED},
AgentStatus.WORKING: {AgentStatus.IDLE, AgentStatus.WAITING, AgentStatus.PAUSED, AgentStatus.TERMINATED},
AgentStatus.WAITING: {AgentStatus.IDLE, AgentStatus.WORKING, AgentStatus.PAUSED, AgentStatus.TERMINATED},
AgentStatus.WORKING: {
AgentStatus.IDLE,
AgentStatus.WAITING,
AgentStatus.PAUSED,
AgentStatus.TERMINATED,
},
AgentStatus.WAITING: {
AgentStatus.IDLE,
AgentStatus.WORKING,
AgentStatus.PAUSED,
AgentStatus.TERMINATED,
},
AgentStatus.PAUSED: {AgentStatus.IDLE, AgentStatus.TERMINATED},
AgentStatus.TERMINATED: set(), # Terminal state, no transitions allowed
}
@@ -870,9 +880,7 @@ async def terminate_agent(
agent_name = agent.name
# Terminate the agent
terminated_agent = await agent_instance_crud.terminate(
db, instance_id=agent_id
)
terminated_agent = await agent_instance_crud.terminate(db, instance_id=agent_id)
if not terminated_agent:
raise NotFoundError(
@@ -881,8 +889,7 @@ async def terminate_agent(
)
logger.info(
f"User {current_user.email} terminated agent {agent_name} "
f"(id={agent_id})"
f"User {current_user.email} terminated agent {agent_name} (id={agent_id})"
)
return MessageResponse(

View File

@@ -199,7 +199,9 @@ async def stream_project_events(
project_id: UUID,
db: "AsyncSession" = Depends(get_db),
event_bus: EventBus = Depends(get_event_bus),
token: str | None = Query(None, description="Auth token (for EventSource compatibility)"),
token: str | None = Query(
None, description="Auth token (for EventSource compatibility)"
),
authorization: str | None = Header(None, alias="Authorization"),
last_event_id: str | None = Header(None, alias="Last-Event-ID"),
):

View File

@@ -278,9 +278,7 @@ async def list_issues(
assigned_agent_id: UUID | None = Query(
None, description="Filter by assigned agent ID"
),
sync_status: SyncStatus | None = Query(
None, description="Filter by sync status"
),
sync_status: SyncStatus | None = Query(None, description="Filter by sync status"),
search: str | None = Query(
None, min_length=1, max_length=100, description="Search in title and body"
),
@@ -783,9 +781,7 @@ async def assign_issue(
updated_issue = await issue_crud.assign_to_agent(
db, issue_id=issue_id, agent_id=None
)
logger.info(
f"User {current_user.email} unassigned issue {issue_id}"
)
logger.info(f"User {current_user.email} unassigned issue {issue_id}")
if not updated_issue:
raise NotFoundError(

View File

@@ -197,10 +197,10 @@ async def list_projects(
status_filter: ProjectStatus | None = Query(
None, alias="status", description="Filter by project status"
),
search: str | None = Query(None, description="Search by name, slug, or description"),
all_projects: bool = Query(
False, description="Show all projects (superuser only)"
search: str | None = Query(
None, description="Search by name, slug, or description"
),
all_projects: bool = Query(False, description="Show all projects (superuser only)"),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> Any:
@@ -212,7 +212,9 @@ async def list_projects(
"""
try:
# Determine owner filter based on user role and request
owner_id = None if (current_user.is_superuser and all_projects) else current_user.id
owner_id = (
None if (current_user.is_superuser and all_projects) else current_user.id
)
projects_data, total = await project_crud.get_multi_with_counts(
db,
@@ -379,13 +381,15 @@ async def update_project(
_check_project_ownership(project, current_user)
# Update the project
updated_project = await project_crud.update(db, db_obj=project, obj_in=project_in)
logger.info(
f"User {current_user.email} updated project {updated_project.slug}"
updated_project = await project_crud.update(
db, db_obj=project, obj_in=project_in
)
logger.info(f"User {current_user.email} updated project {updated_project.slug}")
# Get updated project with counts
project_data = await project_crud.get_with_counts(db, project_id=updated_project.id)
project_data = await project_crud.get_with_counts(
db, project_id=updated_project.id
)
if not project_data:
# This shouldn't happen, but handle gracefully
@@ -551,7 +555,9 @@ async def pause_project(
logger.info(f"User {current_user.email} paused project {project.slug}")
# Get project with counts
project_data = await project_crud.get_with_counts(db, project_id=updated_project.id)
project_data = await project_crud.get_with_counts(
db, project_id=updated_project.id
)
if not project_data:
raise NotFoundError(
@@ -634,7 +640,9 @@ async def resume_project(
logger.info(f"User {current_user.email} resumed project {project.slug}")
# Get project with counts
project_data = await project_crud.get_with_counts(db, project_id=updated_project.id)
project_data = await project_crud.get_with_counts(
db, project_id=updated_project.id
)
if not project_data:
raise NotFoundError(

View File

@@ -320,7 +320,9 @@ async def list_sprints(
return PaginatedResponse(data=sprint_responses, pagination=pagination_meta)
except Exception as e:
logger.error(f"Error listing sprints for project {project_id}: {e!s}", exc_info=True)
logger.error(
f"Error listing sprints for project {project_id}: {e!s}", exc_info=True
)
raise
@@ -564,7 +566,9 @@ async def update_sprint(
)
# Update the sprint
updated_sprint = await sprint_crud.update(db, db_obj=sprint, obj_in=sprint_update)
updated_sprint = await sprint_crud.update(
db, db_obj=sprint, obj_in=sprint_update
)
logger.info(
f"User {current_user.id} updated sprint {sprint_id} in project {project_id}"
@@ -1123,7 +1127,9 @@ async def remove_issue_from_sprint(
request: Request,
project_id: UUID,
sprint_id: UUID,
issue_id: UUID = Query(..., description="ID of the issue to remove from the sprint"),
issue_id: UUID = Query(
..., description="ID of the issue to remove from the sprint"
),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> Any:

View File

@@ -243,7 +243,9 @@ class RedisClient:
try:
client = await self._get_client()
result = await client.expire(key, ttl)
logger.debug(f"Cache expire for key: {key} (TTL: {ttl}s, success: {result})")
logger.debug(
f"Cache expire for key: {key} (TTL: {ttl}s, success: {result})"
)
return result
except (ConnectionError, TimeoutError) as e:
logger.error(f"Redis cache_expire failed for key '{key}': {e}")
@@ -323,9 +325,7 @@ class RedisClient:
return 0
@asynccontextmanager
async def subscribe(
self, *channels: str
) -> AsyncGenerator[PubSub, None]:
async def subscribe(self, *channels: str) -> AsyncGenerator[PubSub, None]:
"""
Subscribe to one or more channels.
@@ -353,9 +353,7 @@ class RedisClient:
logger.debug(f"Unsubscribed from channels: {channels}")
@asynccontextmanager
async def psubscribe(
self, *patterns: str
) -> AsyncGenerator[PubSub, None]:
async def psubscribe(self, *patterns: str) -> AsyncGenerator[PubSub, None]:
"""
Subscribe to channels matching patterns.

View File

@@ -20,7 +20,9 @@ from app.schemas.syndarix import AgentInstanceCreate, AgentInstanceUpdate
logger = logging.getLogger(__name__)
class CRUDAgentInstance(CRUDBase[AgentInstance, AgentInstanceCreate, AgentInstanceUpdate]):
class CRUDAgentInstance(
CRUDBase[AgentInstance, AgentInstanceCreate, AgentInstanceUpdate]
):
"""Async CRUD operations for AgentInstance model."""
async def create(
@@ -91,8 +93,12 @@ class CRUDAgentInstance(CRUDBase[AgentInstance, AgentInstanceCreate, AgentInstan
return {
"instance": instance,
"agent_type_name": instance.agent_type.name if instance.agent_type else None,
"agent_type_slug": instance.agent_type.slug if instance.agent_type else None,
"agent_type_name": instance.agent_type.name
if instance.agent_type
else None,
"agent_type_slug": instance.agent_type.slug
if instance.agent_type
else None,
"project_name": instance.project.name if instance.project else None,
"project_slug": instance.project.slug if instance.project else None,
"assigned_issues_count": assigned_issues_count,
@@ -115,9 +121,7 @@ class CRUDAgentInstance(CRUDBase[AgentInstance, AgentInstanceCreate, AgentInstan
) -> tuple[list[AgentInstance], int]:
"""Get agent instances for a specific project."""
try:
query = select(AgentInstance).where(
AgentInstance.project_id == project_id
)
query = select(AgentInstance).where(AgentInstance.project_id == project_id)
if status is not None:
query = query.where(AgentInstance.status == status)

View File

@@ -22,17 +22,13 @@ class CRUDAgentType(CRUDBase[AgentType, AgentTypeCreate, AgentTypeUpdate]):
async def get_by_slug(self, db: AsyncSession, *, slug: str) -> AgentType | None:
"""Get agent type by slug."""
try:
result = await db.execute(
select(AgentType).where(AgentType.slug == slug)
)
result = await db.execute(select(AgentType).where(AgentType.slug == slug))
return result.scalar_one_or_none()
except Exception as e:
logger.error(f"Error getting agent type by slug {slug}: {e!s}")
raise
async def create(
self, db: AsyncSession, *, obj_in: AgentTypeCreate
) -> AgentType:
async def create(self, db: AsyncSession, *, obj_in: AgentTypeCreate) -> AgentType:
"""Create a new agent type with error handling."""
try:
db_obj = AgentType(
@@ -57,16 +53,12 @@ class CRUDAgentType(CRUDBase[AgentType, AgentTypeCreate, AgentTypeUpdate]):
error_msg = str(e.orig) if hasattr(e, "orig") else str(e)
if "slug" in error_msg.lower():
logger.warning(f"Duplicate slug attempted: {obj_in.slug}")
raise ValueError(
f"Agent type with slug '{obj_in.slug}' already exists"
)
raise ValueError(f"Agent type with slug '{obj_in.slug}' already exists")
logger.error(f"Integrity error creating agent type: {error_msg}")
raise ValueError(f"Database integrity error: {error_msg}")
except Exception as e:
await db.rollback()
logger.error(
f"Unexpected error creating agent type: {e!s}", exc_info=True
)
logger.error(f"Unexpected error creating agent type: {e!s}", exc_info=True)
raise
async def get_multi_with_filters(
@@ -215,9 +207,7 @@ class CRUDAgentType(CRUDBase[AgentType, AgentTypeCreate, AgentTypeUpdate]):
return results, total
except Exception as e:
logger.error(
f"Error getting agent types with counts: {e!s}", exc_info=True
)
logger.error(f"Error getting agent types with counts: {e!s}", exc_info=True)
raise
async def get_by_expertise(

View File

@@ -75,7 +75,9 @@ class CRUDIssue(CRUDBase[Issue, IssueCreate, IssueUpdate]):
.options(
joinedload(Issue.project),
joinedload(Issue.sprint),
joinedload(Issue.assigned_agent).joinedload(AgentInstance.agent_type),
joinedload(Issue.assigned_agent).joinedload(
AgentInstance.agent_type
),
)
.where(Issue.id == issue_id)
)
@@ -449,9 +451,7 @@ class CRUDIssue(CRUDBase[Issue, IssueCreate, IssueUpdate]):
from sqlalchemy import update
result = await db.execute(
update(Issue)
.where(Issue.sprint_id == sprint_id)
.values(sprint_id=None)
update(Issue).where(Issue.sprint_id == sprint_id).values(sprint_id=None)
)
await db.commit()
return result.rowcount

View File

@@ -2,11 +2,10 @@
"""Async CRUD operations for Project model using SQLAlchemy 2.0 patterns."""
import logging
from datetime import UTC, datetime
from typing import Any
from uuid import UUID
from datetime import UTC, datetime
from sqlalchemy import func, or_, select, update
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
@@ -234,9 +233,7 @@ class CRUDProject(CRUDBase[Project, ProjectCreate, ProjectUpdate]):
Sprint.status == SprintStatus.ACTIVE,
)
)
active_sprints = {
row.project_id: row.name for row in active_sprints_result
}
active_sprints = {row.project_id: row.name for row in active_sprints_result}
# Combine results
results = [
@@ -251,9 +248,7 @@ class CRUDProject(CRUDBase[Project, ProjectCreate, ProjectUpdate]):
return results, total
except Exception as e:
logger.error(
f"Error getting projects with counts: {e!s}", exc_info=True
)
logger.error(f"Error getting projects with counts: {e!s}", exc_info=True)
raise
async def get_projects_by_owner(
@@ -293,9 +288,7 @@ class CRUDProject(CRUDBase[Project, ProjectCreate, ProjectUpdate]):
- Unassigns issues from terminated agents
"""
try:
result = await db.execute(
select(Project).where(Project.id == project_id)
)
result = await db.execute(select(Project).where(Project.id == project_id))
project = result.scalar_one_or_none()
if not project:
@@ -361,9 +354,7 @@ class CRUDProject(CRUDBase[Project, ProjectCreate, ProjectUpdate]):
return project
except Exception as e:
await db.rollback()
logger.error(
f"Error archiving project {project_id}: {e!s}", exc_info=True
)
logger.error(f"Error archiving project {project_id}: {e!s}", exc_info=True)
raise

View File

@@ -193,9 +193,7 @@ class CRUDSprint(CRUDBase[Sprint, SprintCreate, SprintUpdate]):
try:
# Lock the sprint row to prevent concurrent modifications
result = await db.execute(
select(Sprint)
.where(Sprint.id == sprint_id)
.with_for_update()
select(Sprint).where(Sprint.id == sprint_id).with_for_update()
)
sprint = result.scalar_one_or_none()
@@ -257,9 +255,7 @@ class CRUDSprint(CRUDBase[Sprint, SprintCreate, SprintUpdate]):
try:
# Lock the sprint row to prevent concurrent modifications
result = await db.execute(
select(Sprint)
.where(Sprint.id == sprint_id)
.with_for_update()
select(Sprint).where(Sprint.id == sprint_id).with_for_update()
)
sprint = result.scalar_one_or_none()
@@ -308,9 +304,7 @@ class CRUDSprint(CRUDBase[Sprint, SprintCreate, SprintUpdate]):
try:
# Lock the sprint row to prevent concurrent modifications
result = await db.execute(
select(Sprint)
.where(Sprint.id == sprint_id)
.with_for_update()
select(Sprint).where(Sprint.id == sprint_id).with_for_update()
)
sprint = result.scalar_one_or_none()
@@ -425,7 +419,8 @@ class CRUDSprint(CRUDBase[Sprint, SprintCreate, SprintUpdate]):
{
"sprint": sprint,
**counts_map.get(
sprint.id, {"issue_count": 0, "open_issues": 0, "completed_issues": 0}
sprint.id,
{"issue_count": 0, "open_issues": 0, "completed_issues": 0},
),
}
for sprint in sprints

View File

@@ -158,7 +158,11 @@ class Issue(Base, UUIDMixin, TimestampMixin):
Index("ix_issues_project_status", "project_id", "status"),
Index("ix_issues_project_priority", "project_id", "priority"),
Index("ix_issues_project_sprint", "project_id", "sprint_id"),
Index("ix_issues_external_tracker_id", "external_tracker_type", "external_issue_id"),
Index(
"ix_issues_external_tracker_id",
"external_tracker_type",
"external_issue_id",
),
Index("ix_issues_sync_status", "sync_status"),
Index("ix_issues_project_agent", "project_id", "assigned_agent_id"),
Index("ix_issues_project_type", "project_id", "type"),

View File

@@ -5,7 +5,17 @@ Sprint model for Syndarix AI consulting platform.
A Sprint represents a time-boxed iteration for organizing and delivering work.
"""
from sqlalchemy import Column, Date, Enum, ForeignKey, Index, Integer, String, Text, UniqueConstraint
from sqlalchemy import (
Column,
Date,
Enum,
ForeignKey,
Index,
Integer,
String,
Text,
UniqueConstraint,
)
from sqlalchemy.dialects.postgresql import UUID as PGUUID
from sqlalchemy.orm import relationship

View File

@@ -205,9 +205,7 @@ class SprintCompletedPayload(BaseModel):
sprint_id: UUID = Field(..., description="Sprint ID")
sprint_name: str = Field(..., description="Sprint name")
completed_issues: int = Field(default=0, description="Number of completed issues")
incomplete_issues: int = Field(
default=0, description="Number of incomplete issues"
)
incomplete_issues: int = Field(default=0, description="Number of incomplete issues")
class ApprovalRequestedPayload(BaseModel):

View File

@@ -99,9 +99,7 @@ class IssueAssign(BaseModel):
def validate_assignment(self) -> "IssueAssign":
"""Ensure only one type of assignee is set."""
if self.assigned_agent_id and self.human_assignee:
raise ValueError(
"Cannot assign to both an agent and a human. Choose one."
)
raise ValueError("Cannot assign to both an agent and a human. Choose one.")
return self

View File

@@ -54,22 +54,18 @@ class EventBusError(Exception):
"""Base exception for EventBus errors."""
class EventBusConnectionError(EventBusError):
"""Raised when connection to Redis fails."""
class EventBusPublishError(EventBusError):
"""Raised when publishing an event fails."""
class EventBusSubscriptionError(EventBusError):
"""Raised when subscribing to channels fails."""
class EventBus:
"""
EventBus for Redis Pub/Sub communication.

View File

@@ -343,7 +343,9 @@ class OAuthService:
await oauth_account.update_tokens(
db,
account=existing_oauth,
access_token_encrypted=token.get("access_token"), refresh_token_encrypted=token.get("refresh_token"), token_expires_at=datetime.now(UTC)
access_token_encrypted=token.get("access_token"),
refresh_token_encrypted=token.get("refresh_token"),
token_expires_at=datetime.now(UTC)
+ timedelta(seconds=token.get("expires_in", 3600)),
)
@@ -375,7 +377,9 @@ class OAuthService:
provider=provider,
provider_user_id=provider_user_id,
provider_email=provider_email,
access_token_encrypted=token.get("access_token"), refresh_token_encrypted=token.get("refresh_token"), token_expires_at=datetime.now(UTC)
access_token_encrypted=token.get("access_token"),
refresh_token_encrypted=token.get("refresh_token"),
token_expires_at=datetime.now(UTC)
+ timedelta(seconds=token.get("expires_in", 3600))
if token.get("expires_in")
else None,
@@ -644,7 +648,9 @@ class OAuthService:
provider=provider,
provider_user_id=provider_user_id,
provider_email=email,
access_token_encrypted=token.get("access_token"), refresh_token_encrypted=token.get("refresh_token"), token_expires_at=datetime.now(UTC)
access_token_encrypted=token.get("access_token"),
refresh_token_encrypted=token.get("refresh_token"),
token_expires_at=datetime.now(UTC)
+ timedelta(seconds=token.get("expires_in", 3600))
if token.get("expires_in")
else None,

View File

@@ -91,9 +91,7 @@ def spawn_agent(
Returns:
dict with status, agent_type_id, and project_id
"""
logger.info(
f"Spawning agent of type {agent_type_id} for project {project_id}"
)
logger.info(f"Spawning agent of type {agent_type_id} for project {project_id}")
# TODO: Implement agent spawning
# This will involve:
@@ -132,9 +130,7 @@ def terminate_agent(
Returns:
dict with status and agent_instance_id
"""
logger.info(
f"Terminating agent instance {agent_instance_id} with reason: {reason}"
)
logger.info(f"Terminating agent instance {agent_instance_id} with reason: {reason}")
# TODO: Implement agent termination
# This will involve:

View File

@@ -86,9 +86,7 @@ def commit_changes(
Returns:
dict with status and project_id
"""
logger.info(
f"Committing changes for project {project_id}: {message}"
)
logger.info(f"Committing changes for project {project_id}: {message}")
# TODO: Implement commit operation
# This will involve:
@@ -209,9 +207,7 @@ def push_changes(
Returns:
dict with status and project_id
"""
logger.info(
f"Pushing branch {branch} for project {project_id} (force={force})"
)
logger.info(f"Pushing branch {branch} for project {project_id} (force={force})")
# TODO: Implement push operation
# This will involve:

View File

@@ -140,9 +140,7 @@ def sync_project_issues(
Returns:
dict with status and project_id
"""
logger.info(
f"Syncing issues for project {project_id} (full={full})"
)
logger.info(f"Syncing issues for project {project_id} (full={full})")
# TODO: Implement project-specific sync
# This will involve:
@@ -180,9 +178,7 @@ def push_issue_to_external(
Returns:
dict with status, issue_id, and operation
"""
logger.info(
f"Pushing {operation} for issue {issue_id} in project {project_id}"
)
logger.info(f"Pushing {operation} for issue {issue_id} in project {project_id}")
# TODO: Implement outbound sync
# This will involve:

View File

@@ -72,9 +72,7 @@ def execute_workflow_step(
Returns:
dict with status, workflow_id, and transition
"""
logger.info(
f"Executing transition '{transition}' for workflow {workflow_id}"
)
logger.info(f"Executing transition '{transition}' for workflow {workflow_id}")
# TODO: Implement workflow transition
# This will involve:
@@ -196,9 +194,7 @@ def start_story_workflow(
Returns:
dict with status and story_id
"""
logger.info(
f"Starting story workflow for story {story_id} in project {project_id}"
)
logger.info(f"Starting story workflow for story {story_id} in project {project_id}")
# TODO: Implement story workflow initialization
# This will involve:

View File

@@ -0,0 +1,39 @@
# tests/api/dependencies/test_event_bus.py
"""Tests for the event_bus dependency."""
from unittest.mock import AsyncMock, patch
import pytest
from app.api.dependencies.event_bus import get_event_bus
from app.services.event_bus import EventBus
@pytest.mark.asyncio
class TestGetEventBusDependency:
"""Tests for the get_event_bus FastAPI dependency."""
async def test_get_event_bus_returns_event_bus(self):
"""Test that get_event_bus returns an EventBus instance."""
mock_event_bus = AsyncMock(spec=EventBus)
with patch(
"app.api.dependencies.event_bus._get_connected_event_bus",
return_value=mock_event_bus,
):
result = await get_event_bus()
assert result is mock_event_bus
async def test_get_event_bus_calls_get_connected_event_bus(self):
"""Test that get_event_bus calls the underlying function."""
mock_event_bus = AsyncMock(spec=EventBus)
mock_get_connected = AsyncMock(return_value=mock_event_bus)
with patch(
"app.api.dependencies.event_bus._get_connected_event_bus",
mock_get_connected,
):
await get_event_bus()
mock_get_connected.assert_called_once()

View File

@@ -299,9 +299,7 @@ class TestListAgentTypes:
class TestGetAgentType:
"""Tests for GET /api/v1/agent-types/{agent_type_id} endpoint."""
async def test_get_agent_type_success(
self, client, user_token, test_agent_type
):
async def test_get_agent_type_success(self, client, user_token, test_agent_type):
"""Test successful retrieval of agent type by ID."""
agent_type_id = test_agent_type["id"]
@@ -383,7 +381,9 @@ class TestGetAgentTypeBySlug:
assert data["errors"][0]["code"] == "SYS_002" # NOT_FOUND
assert "non-existent-slug" in data["errors"][0]["message"]
async def test_get_agent_type_by_slug_unauthenticated(self, client, test_agent_type):
async def test_get_agent_type_by_slug_unauthenticated(
self, client, test_agent_type
):
"""Test that unauthenticated users cannot get agent types by slug."""
slug = test_agent_type["slug"]
@@ -671,9 +671,7 @@ class TestAgentTypeModelParams:
assert data["tool_permissions"]["read_files"] is True
assert data["tool_permissions"]["execute_code"] is False
async def test_update_model_params(
self, client, superuser_token, test_agent_type
):
async def test_update_model_params(self, client, superuser_token, test_agent_type):
"""Test updating model parameters."""
agent_type_id = test_agent_type["id"]
@@ -697,9 +695,7 @@ class TestAgentTypeModelParams:
class TestAgentTypeInstanceCount:
"""Tests for instance count tracking."""
async def test_new_agent_type_has_zero_instances(
self, client, superuser_token
):
async def test_new_agent_type_has_zero_instances(self, client, superuser_token):
"""Test that newly created agent types have zero instances."""
unique_slug = f"zero-instances-{uuid.uuid4().hex[:8]}"
response = await client.post(

View File

@@ -122,9 +122,7 @@ class TestSpawnAgent:
assert response.status_code == status.HTTP_404_NOT_FOUND
async def test_spawn_agent_nonexistent_type(
self, client, user_token, test_project
):
async def test_spawn_agent_nonexistent_type(self, client, user_token, test_project):
"""Test spawning agent with nonexistent agent type."""
project_id = test_project["id"]
fake_type_id = str(uuid.uuid4())
@@ -376,9 +374,7 @@ class TestUpdateAgent:
class TestAgentLifecycle:
"""Tests for agent lifecycle management endpoints."""
async def test_pause_agent(
self, client, user_token, test_project, test_agent_type
):
async def test_pause_agent(self, client, user_token, test_project, test_agent_type):
"""Test pausing an agent."""
project_id = test_project["id"]
agent_type_id = test_agent_type["id"]
@@ -617,3 +613,364 @@ class TestAgentAuthorization:
)
assert response.status_code == status.HTTP_403_FORBIDDEN
@pytest.mark.asyncio
class TestSpawnAgentEdgeCases:
"""Tests for agent spawn edge cases."""
async def test_spawn_agent_with_inactive_agent_type(
self, client, user_token, superuser_token, test_project
):
"""Test spawning agent with an inactive agent type fails."""
project_id = test_project["id"]
# Create an inactive agent type
unique_slug = f"inactive-agent-type-{uuid.uuid4().hex[:8]}"
create_response = await client.post(
"/api/v1/agent-types",
json={
"name": "Inactive Agent Type",
"slug": unique_slug,
"expertise": ["testing"],
"primary_model": "claude-3-opus",
"personality_prompt": "Test inactive agent.",
"description": "An inactive agent type for testing",
"is_active": False,
},
headers={"Authorization": f"Bearer {superuser_token}"},
)
assert create_response.status_code == status.HTTP_201_CREATED
inactive_type_id = create_response.json()["id"]
# Try to spawn agent with inactive type
response = await client.post(
f"/api/v1/projects/{project_id}/agents",
json={
"project_id": project_id,
"agent_type_id": inactive_type_id,
"name": "Agent With Inactive Type",
},
headers={"Authorization": f"Bearer {user_token}"},
)
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
# Error response uses standardized format with "errors" list
data = response.json()
assert "errors" in data
assert any("inactive" in err["message"].lower() for err in data["errors"])
@pytest.mark.asyncio
class TestAgentWrongProject:
"""Tests for agent operations when agent belongs to different project."""
@pytest_asyncio.fixture
async def two_projects_with_agent(
self, client, user_token, superuser_token, test_agent_type
):
"""Create two projects and an agent in project1."""
# Create project1
resp1 = await client.post(
"/api/v1/projects",
json={
"name": "Project One",
"slug": f"project-one-{uuid.uuid4().hex[:8]}",
},
headers={"Authorization": f"Bearer {user_token}"},
)
project1 = resp1.json()
# Create project2
resp2 = await client.post(
"/api/v1/projects",
json={
"name": "Project Two",
"slug": f"project-two-{uuid.uuid4().hex[:8]}",
},
headers={"Authorization": f"Bearer {user_token}"},
)
project2 = resp2.json()
# Create agent in project1
agent_resp = await client.post(
f"/api/v1/projects/{project1['id']}/agents",
json={
"project_id": project1["id"],
"agent_type_id": test_agent_type["id"],
"name": "Project1 Agent",
},
headers={"Authorization": f"Bearer {user_token}"},
)
agent = agent_resp.json()
return {"project1": project1, "project2": project2, "agent": agent}
async def test_get_agent_wrong_project(
self, client, user_token, two_projects_with_agent
):
"""Test getting an agent via wrong project returns 404."""
data = two_projects_with_agent
agent_id = data["agent"]["id"]
wrong_project_id = data["project2"]["id"]
response = await client.get(
f"/api/v1/projects/{wrong_project_id}/agents/{agent_id}",
headers={"Authorization": f"Bearer {user_token}"},
)
assert response.status_code == status.HTTP_404_NOT_FOUND
async def test_update_agent_wrong_project(
self, client, user_token, two_projects_with_agent
):
"""Test updating an agent via wrong project returns 404."""
data = two_projects_with_agent
agent_id = data["agent"]["id"]
wrong_project_id = data["project2"]["id"]
response = await client.patch(
f"/api/v1/projects/{wrong_project_id}/agents/{agent_id}",
json={"current_task": "Test task"},
headers={"Authorization": f"Bearer {user_token}"},
)
assert response.status_code == status.HTTP_404_NOT_FOUND
async def test_pause_agent_wrong_project(
self, client, user_token, two_projects_with_agent
):
"""Test pausing an agent via wrong project returns 404."""
data = two_projects_with_agent
agent_id = data["agent"]["id"]
wrong_project_id = data["project2"]["id"]
response = await client.post(
f"/api/v1/projects/{wrong_project_id}/agents/{agent_id}/pause",
headers={"Authorization": f"Bearer {user_token}"},
)
assert response.status_code == status.HTTP_404_NOT_FOUND
async def test_resume_agent_wrong_project(
self, client, user_token, two_projects_with_agent
):
"""Test resuming an agent via wrong project returns 404."""
data = two_projects_with_agent
project1_id = data["project1"]["id"]
agent_id = data["agent"]["id"]
wrong_project_id = data["project2"]["id"]
# First pause the agent using correct project
await client.post(
f"/api/v1/projects/{project1_id}/agents/{agent_id}/pause",
headers={"Authorization": f"Bearer {user_token}"},
)
# Try to resume via wrong project
response = await client.post(
f"/api/v1/projects/{wrong_project_id}/agents/{agent_id}/resume",
headers={"Authorization": f"Bearer {user_token}"},
)
assert response.status_code == status.HTTP_404_NOT_FOUND
async def test_terminate_agent_wrong_project(
self, client, user_token, two_projects_with_agent
):
"""Test terminating an agent via wrong project returns 404."""
data = two_projects_with_agent
agent_id = data["agent"]["id"]
wrong_project_id = data["project2"]["id"]
response = await client.delete(
f"/api/v1/projects/{wrong_project_id}/agents/{agent_id}",
headers={"Authorization": f"Bearer {user_token}"},
)
assert response.status_code == status.HTTP_404_NOT_FOUND
async def test_get_agent_metrics_wrong_project(
self, client, user_token, two_projects_with_agent
):
"""Test getting agent metrics via wrong project returns 404."""
data = two_projects_with_agent
agent_id = data["agent"]["id"]
wrong_project_id = data["project2"]["id"]
response = await client.get(
f"/api/v1/projects/{wrong_project_id}/agents/{agent_id}/metrics",
headers={"Authorization": f"Bearer {user_token}"},
)
assert response.status_code == status.HTTP_404_NOT_FOUND
@pytest.mark.asyncio
class TestAgentStatusTransitions:
"""Tests for invalid agent status transitions."""
async def test_terminate_already_terminated_agent(
self, client, user_token, test_project, test_agent_type
):
"""Test terminating an already terminated agent fails."""
project_id = test_project["id"]
agent_type_id = test_agent_type["id"]
# Create agent
create_response = await client.post(
f"/api/v1/projects/{project_id}/agents",
json={
"project_id": project_id,
"agent_type_id": agent_type_id,
"name": "Double Terminate Agent",
},
headers={"Authorization": f"Bearer {user_token}"},
)
agent_id = create_response.json()["id"]
# Terminate once
first_terminate = await client.delete(
f"/api/v1/projects/{project_id}/agents/{agent_id}",
headers={"Authorization": f"Bearer {user_token}"},
)
assert first_terminate.status_code == status.HTTP_200_OK
# Try to terminate again
response = await client.delete(
f"/api/v1/projects/{project_id}/agents/{agent_id}",
headers={"Authorization": f"Bearer {user_token}"},
)
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
data = response.json()
assert "errors" in data
assert any("terminated" in err["message"].lower() for err in data["errors"])
async def test_resume_idle_agent(
self, client, user_token, test_project, test_agent_type
):
"""Test resuming an agent that is not paused fails."""
project_id = test_project["id"]
agent_type_id = test_agent_type["id"]
# Create agent (starts in idle state)
create_response = await client.post(
f"/api/v1/projects/{project_id}/agents",
json={
"project_id": project_id,
"agent_type_id": agent_type_id,
"name": "Resume Idle Agent",
},
headers={"Authorization": f"Bearer {user_token}"},
)
agent_id = create_response.json()["id"]
# Try to resume without pausing first
response = await client.post(
f"/api/v1/projects/{project_id}/agents/{agent_id}/resume",
headers={"Authorization": f"Bearer {user_token}"},
)
# Should fail since agent is not paused
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
async def test_pause_already_paused_agent(
self, client, user_token, test_project, test_agent_type
):
"""Test pausing an already paused agent fails."""
project_id = test_project["id"]
agent_type_id = test_agent_type["id"]
# Create agent
create_response = await client.post(
f"/api/v1/projects/{project_id}/agents",
json={
"project_id": project_id,
"agent_type_id": agent_type_id,
"name": "Double Pause Agent",
},
headers={"Authorization": f"Bearer {user_token}"},
)
agent_id = create_response.json()["id"]
# Pause once
first_pause = await client.post(
f"/api/v1/projects/{project_id}/agents/{agent_id}/pause",
headers={"Authorization": f"Bearer {user_token}"},
)
assert first_pause.status_code == status.HTTP_200_OK
# Try to pause again
response = await client.post(
f"/api/v1/projects/{project_id}/agents/{agent_id}/pause",
headers={"Authorization": f"Bearer {user_token}"},
)
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
async def test_pause_terminated_agent(
self, client, user_token, test_project, test_agent_type
):
"""Test pausing a terminated agent fails."""
project_id = test_project["id"]
agent_type_id = test_agent_type["id"]
# Create agent
create_response = await client.post(
f"/api/v1/projects/{project_id}/agents",
json={
"project_id": project_id,
"agent_type_id": agent_type_id,
"name": "Pause Terminated Agent",
},
headers={"Authorization": f"Bearer {user_token}"},
)
agent_id = create_response.json()["id"]
# Terminate agent
await client.delete(
f"/api/v1/projects/{project_id}/agents/{agent_id}",
headers={"Authorization": f"Bearer {user_token}"},
)
# Try to pause terminated agent
response = await client.post(
f"/api/v1/projects/{project_id}/agents/{agent_id}/pause",
headers={"Authorization": f"Bearer {user_token}"},
)
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
async def test_resume_terminated_agent(
self, client, user_token, test_project, test_agent_type
):
"""Test resuming a terminated agent fails."""
project_id = test_project["id"]
agent_type_id = test_agent_type["id"]
# Create agent
create_response = await client.post(
f"/api/v1/projects/{project_id}/agents",
json={
"project_id": project_id,
"agent_type_id": agent_type_id,
"name": "Resume Terminated Agent",
},
headers={"Authorization": f"Bearer {user_token}"},
)
agent_id = create_response.json()["id"]
# Terminate agent
await client.delete(
f"/api/v1/projects/{project_id}/agents/{agent_id}",
headers={"Authorization": f"Bearer {user_token}"},
)
# Try to resume terminated agent
response = await client.post(
f"/api/v1/projects/{project_id}/agents/{agent_id}/resume",
headers={"Authorization": f"Bearer {user_token}"},
)
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY

View File

@@ -74,7 +74,9 @@ async def terminated_agent(client, user_token, test_project, test_agent):
f"/api/v1/projects/{test_project['id']}/agents/{test_agent['id']}",
headers={"Authorization": f"Bearer {user_token}"},
)
assert response.status_code == status.HTTP_200_OK, f"Failed to terminate: {response.json()}"
assert response.status_code == status.HTTP_200_OK, (
f"Failed to terminate: {response.json()}"
)
# Return agent info with terminated status
return {**test_agent, "status": "terminated"}
@@ -432,7 +434,7 @@ class TestProjectArchivingEdgeCases:
agent_id = test_agent["id"]
# Set agent to working status
status_response = await client.patch(
await client.patch(
f"/api/v1/projects/{project_id}/agents/{agent_id}/status",
json={"status": "working", "current_task": "Processing something"},
headers={"Authorization": f"Bearer {user_token}"},
@@ -475,7 +477,6 @@ class TestConcurrencyEdgeCases:
If two requests try to start sprints simultaneously, only one should succeed.
"""
import asyncio
from datetime import date, timedelta
project_id = test_project["id"]
@@ -509,7 +510,9 @@ class TestConcurrencyEdgeCases:
)
# Exactly one should succeed
successes = sum(1 for r in [start1, start2] if r.status_code == status.HTTP_200_OK)
successes = sum(
1 for r in [start1, start2] if r.status_code == status.HTTP_200_OK
)
failures = sum(1 for r in [start1, start2] if r.status_code in [400, 409, 422])
assert successes == 1, f"Expected exactly 1 success, got {successes}"
@@ -593,9 +596,7 @@ class TestDataIntegrityEdgeCases:
assert update_response.status_code == status.HTTP_404_NOT_FOUND
async def test_assign_issue_to_other_projects_sprint(
self, client, user_token
):
async def test_assign_issue_to_other_projects_sprint(self, client, user_token):
"""
IDOR Test: Try to assign an issue to a sprint from a different project.
"""
@@ -624,6 +625,7 @@ class TestDataIntegrityEdgeCases:
# Create a sprint in project 2
from datetime import date, timedelta
sprint_response = await client.post(
f"/api/v1/projects/{p2['id']}/sprints",
json={
@@ -662,7 +664,9 @@ class TestDataIntegrityEdgeCases:
status.HTTP_400_BAD_REQUEST,
status.HTTP_404_NOT_FOUND,
status.HTTP_422_UNPROCESSABLE_ENTITY,
], f"IDOR BUG: Assigned issue to another project's sprint! Status: {update_response.status_code}"
], (
f"IDOR BUG: Assigned issue to another project's sprint! Status: {update_response.status_code}"
)
async def test_assign_issue_to_other_projects_agent(
self, client, user_token, superuser_token
@@ -744,7 +748,9 @@ class TestDataIntegrityEdgeCases:
status.HTTP_400_BAD_REQUEST,
status.HTTP_404_NOT_FOUND,
status.HTTP_422_UNPROCESSABLE_ENTITY,
], f"IDOR BUG: Assigned issue to another project's agent! Status: {update_response.status_code}"
], (
f"IDOR BUG: Assigned issue to another project's agent! Status: {update_response.status_code}"
)
@pytest.mark.asyncio
@@ -1084,6 +1090,6 @@ class TestArchiveProjectCleanup:
# BUG CHECK: Sprint should be cancelled after project archive
if sprint_data.get("status") == "active":
pytest.fail(
f"BUG: Sprint status is still 'active' after project archive. "
f"Expected 'cancelled'. Archive should cancel active sprints."
"BUG: Sprint status is still 'active' after project archive. "
"Expected 'cancelled'. Archive should cancel active sprints."
)

View File

@@ -108,7 +108,9 @@ class TestCreateIssue:
assert "urgent" in data["labels"]
assert "frontend" in data["labels"]
async def test_create_issue_with_story_points(self, client, user_token, test_project):
async def test_create_issue_with_story_points(
self, client, user_token, test_project
):
"""Test creating issue with story points."""
project_id = test_project["id"]
@@ -237,7 +239,9 @@ class TestListIssues:
assert len(data["data"]) == 1
assert data["data"][0]["status"] == "open"
async def test_list_issues_filter_by_priority(self, client, user_token, test_project):
async def test_list_issues_filter_by_priority(
self, client, user_token, test_project
):
"""Test filtering issues by priority."""
project_id = test_project["id"]
@@ -703,7 +707,9 @@ class TestIssueAssignment:
assert response.status_code == status.HTTP_404_NOT_FOUND
async def test_assign_issue_clears_assignment(self, client, user_token, test_project):
async def test_assign_issue_clears_assignment(
self, client, user_token, test_project
):
"""Test that assigning to null clears both assignments."""
project_id = test_project["id"]
@@ -890,7 +896,9 @@ class TestIssueCrossProjectValidation:
class TestIssueValidation:
"""Tests for issue validation during create/update."""
async def test_create_issue_invalid_priority(self, client, user_token, test_project):
async def test_create_issue_invalid_priority(
self, client, user_token, test_project
):
"""Test creating issue with invalid priority."""
project_id = test_project["id"]
@@ -922,7 +930,9 @@ class TestIssueValidation:
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
async def test_update_issue_invalid_priority(self, client, user_token, test_project):
async def test_update_issue_invalid_priority(
self, client, user_token, test_project
):
"""Test updating issue with invalid priority."""
project_id = test_project["id"]

View File

@@ -243,14 +243,22 @@ class TestListProjects:
# Create active project
await client.post(
"/api/v1/projects",
json={"name": "Active Project", "slug": "active-project", "status": "active"},
json={
"name": "Active Project",
"slug": "active-project",
"status": "active",
},
headers={"Authorization": f"Bearer {user_token}"},
)
# Create paused project
await client.post(
"/api/v1/projects",
json={"name": "Paused Project", "slug": "paused-project", "status": "paused"},
json={
"name": "Paused Project",
"slug": "paused-project",
"status": "paused",
},
headers={"Authorization": f"Bearer {user_token}"},
)

View File

@@ -233,7 +233,9 @@ class TestListSprints:
assert len(data["data"]) == 3
assert data["pagination"]["total"] == 3
async def test_list_sprints_filter_by_status(self, client, user_token, test_project):
async def test_list_sprints_filter_by_status(
self, client, user_token, test_project
):
"""Test filtering sprints by status."""
project_id = test_project["id"]
start_date = date.today()
@@ -582,7 +584,9 @@ class TestSprintLifecycle:
class TestDeleteSprint:
"""Tests for DELETE /api/v1/projects/{project_id}/sprints/{sprint_id} endpoint."""
async def test_delete_planned_sprint_success(self, client, user_token, test_project):
async def test_delete_planned_sprint_success(
self, client, user_token, test_project
):
"""Test deleting a planned sprint."""
project_id = test_project["id"]
start_date = date.today()
@@ -1119,3 +1123,419 @@ class TestSprintCrossProjectValidation:
)
assert response.status_code == status.HTTP_404_NOT_FOUND
@pytest.mark.asyncio
class TestSprintStatusTransitions:
"""Tests for invalid sprint status transitions."""
async def test_cancel_completed_sprint(self, client, user_token, test_project):
"""Test that cancelling a completed sprint fails."""
project_id = test_project["id"]
start_date = date.today()
end_date = start_date + timedelta(days=14)
# Create, start, and complete sprint
create_response = await client.post(
f"/api/v1/projects/{project_id}/sprints",
json={
"project_id": project_id,
"name": "Sprint to Complete Then Cancel",
"number": 1,
"start_date": start_date.isoformat(),
"end_date": end_date.isoformat(),
},
headers={"Authorization": f"Bearer {user_token}"},
)
sprint_id = create_response.json()["id"]
await client.post(
f"/api/v1/projects/{project_id}/sprints/{sprint_id}/start",
headers={"Authorization": f"Bearer {user_token}"},
)
await client.post(
f"/api/v1/projects/{project_id}/sprints/{sprint_id}/complete",
headers={"Authorization": f"Bearer {user_token}"},
)
# Try to cancel completed sprint
response = await client.post(
f"/api/v1/projects/{project_id}/sprints/{sprint_id}/cancel",
headers={"Authorization": f"Bearer {user_token}"},
)
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
async def test_cancel_already_cancelled_sprint(
self, client, user_token, test_project
):
"""Test that cancelling an already cancelled sprint fails."""
project_id = test_project["id"]
start_date = date.today()
end_date = start_date + timedelta(days=14)
# Create and cancel sprint
create_response = await client.post(
f"/api/v1/projects/{project_id}/sprints",
json={
"project_id": project_id,
"name": "Double Cancel Sprint",
"number": 1,
"start_date": start_date.isoformat(),
"end_date": end_date.isoformat(),
},
headers={"Authorization": f"Bearer {user_token}"},
)
sprint_id = create_response.json()["id"]
# Cancel once
first_cancel = await client.post(
f"/api/v1/projects/{project_id}/sprints/{sprint_id}/cancel",
headers={"Authorization": f"Bearer {user_token}"},
)
assert first_cancel.status_code == status.HTTP_200_OK
# Try to cancel again
response = await client.post(
f"/api/v1/projects/{project_id}/sprints/{sprint_id}/cancel",
headers={"Authorization": f"Bearer {user_token}"},
)
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
async def test_complete_already_completed_sprint(
self, client, user_token, test_project
):
"""Test that completing an already completed sprint fails."""
project_id = test_project["id"]
start_date = date.today()
end_date = start_date + timedelta(days=14)
# Create, start, and complete sprint
create_response = await client.post(
f"/api/v1/projects/{project_id}/sprints",
json={
"project_id": project_id,
"name": "Double Complete Sprint",
"number": 1,
"start_date": start_date.isoformat(),
"end_date": end_date.isoformat(),
},
headers={"Authorization": f"Bearer {user_token}"},
)
sprint_id = create_response.json()["id"]
await client.post(
f"/api/v1/projects/{project_id}/sprints/{sprint_id}/start",
headers={"Authorization": f"Bearer {user_token}"},
)
# Complete once
first_complete = await client.post(
f"/api/v1/projects/{project_id}/sprints/{sprint_id}/complete",
headers={"Authorization": f"Bearer {user_token}"},
)
assert first_complete.status_code == status.HTTP_200_OK
# Try to complete again
response = await client.post(
f"/api/v1/projects/{project_id}/sprints/{sprint_id}/complete",
headers={"Authorization": f"Bearer {user_token}"},
)
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
async def test_complete_cancelled_sprint(self, client, user_token, test_project):
"""Test that completing a cancelled sprint fails."""
project_id = test_project["id"]
start_date = date.today()
end_date = start_date + timedelta(days=14)
# Create and cancel sprint
create_response = await client.post(
f"/api/v1/projects/{project_id}/sprints",
json={
"project_id": project_id,
"name": "Complete Cancelled Sprint",
"number": 1,
"start_date": start_date.isoformat(),
"end_date": end_date.isoformat(),
},
headers={"Authorization": f"Bearer {user_token}"},
)
sprint_id = create_response.json()["id"]
await client.post(
f"/api/v1/projects/{project_id}/sprints/{sprint_id}/cancel",
headers={"Authorization": f"Bearer {user_token}"},
)
# Try to complete cancelled sprint
response = await client.post(
f"/api/v1/projects/{project_id}/sprints/{sprint_id}/complete",
headers={"Authorization": f"Bearer {user_token}"},
)
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
async def test_start_cancelled_sprint(self, client, user_token, test_project):
"""Test that starting a cancelled sprint fails."""
project_id = test_project["id"]
start_date = date.today()
end_date = start_date + timedelta(days=14)
# Create and cancel sprint
create_response = await client.post(
f"/api/v1/projects/{project_id}/sprints",
json={
"project_id": project_id,
"name": "Start Cancelled Sprint",
"number": 1,
"start_date": start_date.isoformat(),
"end_date": end_date.isoformat(),
},
headers={"Authorization": f"Bearer {user_token}"},
)
sprint_id = create_response.json()["id"]
await client.post(
f"/api/v1/projects/{project_id}/sprints/{sprint_id}/cancel",
headers={"Authorization": f"Bearer {user_token}"},
)
# Try to start cancelled sprint
response = await client.post(
f"/api/v1/projects/{project_id}/sprints/{sprint_id}/start",
headers={"Authorization": f"Bearer {user_token}"},
)
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
async def test_start_completed_sprint(self, client, user_token, test_project):
"""Test that starting a completed sprint fails."""
project_id = test_project["id"]
start_date = date.today()
end_date = start_date + timedelta(days=14)
# Create, start, and complete sprint
create_response = await client.post(
f"/api/v1/projects/{project_id}/sprints",
json={
"project_id": project_id,
"name": "Start Completed Sprint",
"number": 1,
"start_date": start_date.isoformat(),
"end_date": end_date.isoformat(),
},
headers={"Authorization": f"Bearer {user_token}"},
)
sprint_id = create_response.json()["id"]
await client.post(
f"/api/v1/projects/{project_id}/sprints/{sprint_id}/start",
headers={"Authorization": f"Bearer {user_token}"},
)
await client.post(
f"/api/v1/projects/{project_id}/sprints/{sprint_id}/complete",
headers={"Authorization": f"Bearer {user_token}"},
)
# Try to start completed sprint
response = await client.post(
f"/api/v1/projects/{project_id}/sprints/{sprint_id}/start",
headers={"Authorization": f"Bearer {user_token}"},
)
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
@pytest.mark.asyncio
class TestSprintWrongProject:
"""Tests for sprint operations when sprint belongs to different project."""
async def test_complete_sprint_wrong_project(self, client, user_token):
"""Test completing a sprint via wrong project returns 404."""
# Create two projects
project1 = await client.post(
"/api/v1/projects",
json={"name": "Complete P1", "slug": f"complete-p1-{uuid.uuid4().hex[:6]}"},
headers={"Authorization": f"Bearer {user_token}"},
)
project2 = await client.post(
"/api/v1/projects",
json={"name": "Complete P2", "slug": f"complete-p2-{uuid.uuid4().hex[:6]}"},
headers={"Authorization": f"Bearer {user_token}"},
)
project1_id = project1.json()["id"]
project2_id = project2.json()["id"]
start_date = date.today()
end_date = start_date + timedelta(days=14)
# Create and start sprint in project1
sprint_response = await client.post(
f"/api/v1/projects/{project1_id}/sprints",
json={
"project_id": project1_id,
"name": "Complete Sprint",
"number": 1,
"start_date": start_date.isoformat(),
"end_date": end_date.isoformat(),
},
headers={"Authorization": f"Bearer {user_token}"},
)
sprint_id = sprint_response.json()["id"]
await client.post(
f"/api/v1/projects/{project1_id}/sprints/{sprint_id}/start",
headers={"Authorization": f"Bearer {user_token}"},
)
# Try to complete via wrong project
response = await client.post(
f"/api/v1/projects/{project2_id}/sprints/{sprint_id}/complete",
headers={"Authorization": f"Bearer {user_token}"},
)
assert response.status_code == status.HTTP_404_NOT_FOUND
async def test_cancel_sprint_wrong_project(self, client, user_token):
"""Test cancelling a sprint via wrong project returns 404."""
# Create two projects
project1 = await client.post(
"/api/v1/projects",
json={"name": "Cancel P1", "slug": f"cancel-p1-{uuid.uuid4().hex[:6]}"},
headers={"Authorization": f"Bearer {user_token}"},
)
project2 = await client.post(
"/api/v1/projects",
json={"name": "Cancel P2", "slug": f"cancel-p2-{uuid.uuid4().hex[:6]}"},
headers={"Authorization": f"Bearer {user_token}"},
)
project1_id = project1.json()["id"]
project2_id = project2.json()["id"]
start_date = date.today()
end_date = start_date + timedelta(days=14)
# Create sprint in project1
sprint_response = await client.post(
f"/api/v1/projects/{project1_id}/sprints",
json={
"project_id": project1_id,
"name": "Cancel Sprint",
"number": 1,
"start_date": start_date.isoformat(),
"end_date": end_date.isoformat(),
},
headers={"Authorization": f"Bearer {user_token}"},
)
sprint_id = sprint_response.json()["id"]
# Try to cancel via wrong project
response = await client.post(
f"/api/v1/projects/{project2_id}/sprints/{sprint_id}/cancel",
headers={"Authorization": f"Bearer {user_token}"},
)
assert response.status_code == status.HTTP_404_NOT_FOUND
async def test_delete_sprint_wrong_project(self, client, user_token):
"""Test deleting a sprint via wrong project returns 404."""
# Create two projects
project1 = await client.post(
"/api/v1/projects",
json={"name": "Delete P1", "slug": f"delete-p1-{uuid.uuid4().hex[:6]}"},
headers={"Authorization": f"Bearer {user_token}"},
)
project2 = await client.post(
"/api/v1/projects",
json={"name": "Delete P2", "slug": f"delete-p2-{uuid.uuid4().hex[:6]}"},
headers={"Authorization": f"Bearer {user_token}"},
)
project1_id = project1.json()["id"]
project2_id = project2.json()["id"]
start_date = date.today()
end_date = start_date + timedelta(days=14)
# Create sprint in project1
sprint_response = await client.post(
f"/api/v1/projects/{project1_id}/sprints",
json={
"project_id": project1_id,
"name": "Delete Sprint",
"number": 1,
"start_date": start_date.isoformat(),
"end_date": end_date.isoformat(),
},
headers={"Authorization": f"Bearer {user_token}"},
)
sprint_id = sprint_response.json()["id"]
# Try to delete via wrong project
response = await client.delete(
f"/api/v1/projects/{project2_id}/sprints/{sprint_id}",
headers={"Authorization": f"Bearer {user_token}"},
)
assert response.status_code == status.HTTP_404_NOT_FOUND
async def test_add_issue_to_sprint_wrong_project(self, client, user_token):
"""Test adding issue to sprint via wrong project returns 404."""
# Create two projects
project1 = await client.post(
"/api/v1/projects",
json={
"name": "Add Issue P1",
"slug": f"add-issue-p1-{uuid.uuid4().hex[:6]}",
},
headers={"Authorization": f"Bearer {user_token}"},
)
project2 = await client.post(
"/api/v1/projects",
json={
"name": "Add Issue P2",
"slug": f"add-issue-p2-{uuid.uuid4().hex[:6]}",
},
headers={"Authorization": f"Bearer {user_token}"},
)
project1_id = project1.json()["id"]
project2_id = project2.json()["id"]
start_date = date.today()
end_date = start_date + timedelta(days=14)
# Create sprint in project1
sprint_response = await client.post(
f"/api/v1/projects/{project1_id}/sprints",
json={
"project_id": project1_id,
"name": "Add Issue Sprint",
"number": 1,
"start_date": start_date.isoformat(),
"end_date": end_date.isoformat(),
},
headers={"Authorization": f"Bearer {user_token}"},
)
sprint_id = sprint_response.json()["id"]
# Create issue in project1
issue_response = await client.post(
f"/api/v1/projects/{project1_id}/issues",
json={
"project_id": project1_id,
"title": "Test Issue",
},
headers={"Authorization": f"Bearer {user_token}"},
)
issue_id = issue_response.json()["id"]
# Try to add issue via wrong project
response = await client.post(
f"/api/v1/projects/{project2_id}/sprints/{sprint_id}/issues",
params={"issue_id": issue_id},
headers={"Authorization": f"Bearer {user_token}"},
)
assert response.status_code == status.HTTP_404_NOT_FOUND

View File

@@ -274,7 +274,11 @@ class TestSSEEndpointStream:
@pytest.mark.asyncio
async def test_stream_events_with_events(
self, client_with_mock_bus, user_token_with_mock_bus, mock_event_bus, test_project_for_events
self,
client_with_mock_bus,
user_token_with_mock_bus,
mock_event_bus,
test_project_for_events,
):
"""Test that SSE endpoint yields events."""
project_id = test_project_for_events.id
@@ -361,7 +365,11 @@ class TestTestEventEndpoint:
@pytest.mark.asyncio
async def test_send_test_event_success(
self, client_with_mock_bus, user_token_with_mock_bus, mock_event_bus, test_project_for_events
self,
client_with_mock_bus,
user_token_with_mock_bus,
mock_event_bus,
test_project_for_events,
):
"""Test sending a test event."""
project_id = test_project_for_events.id

View File

@@ -437,3 +437,197 @@ class TestOAuthProviderEndpoints:
)
# Missing client_id returns 401 (invalid_client)
assert response.status_code == 401
class TestOAuthProviderAdminEndpoints:
"""Tests for OAuth provider admin endpoints."""
@pytest.mark.asyncio
async def test_list_clients_admin_only(self, client, user_token):
"""Test that listing clients requires superuser."""
with patch("app.api.routes.oauth_provider.settings") as mock_settings:
mock_settings.OAUTH_PROVIDER_ENABLED = True
response = await client.get(
"/api/v1/oauth/provider/clients",
headers={"Authorization": f"Bearer {user_token}"},
)
# Regular user should be forbidden
assert response.status_code == 403
@pytest.mark.asyncio
async def test_list_clients_success(self, client, superuser_token):
"""Test listing OAuth clients as superuser."""
with patch("app.api.routes.oauth_provider.settings") as mock_settings:
mock_settings.OAUTH_PROVIDER_ENABLED = True
response = await client.get(
"/api/v1/oauth/provider/clients",
headers={"Authorization": f"Bearer {superuser_token}"},
)
assert response.status_code == 200
assert isinstance(response.json(), list)
@pytest.mark.asyncio
async def test_delete_client_not_found(self, client, superuser_token):
"""Test deleting non-existent OAuth client."""
with patch("app.api.routes.oauth_provider.settings") as mock_settings:
mock_settings.OAUTH_PROVIDER_ENABLED = True
response = await client.delete(
"/api/v1/oauth/provider/clients/non_existent_client_id",
headers={"Authorization": f"Bearer {superuser_token}"},
)
assert response.status_code == 404
@pytest.mark.asyncio
async def test_delete_client_success(self, client, superuser_token, async_test_db):
"""Test successfully deleting an OAuth client."""
_test_engine, AsyncTestingSessionLocal = async_test_db
from app.crud.oauth import oauth_client
from app.schemas.oauth import OAuthClientCreate
# Create a test client to delete
async with AsyncTestingSessionLocal() as session:
client_data = OAuthClientCreate(
client_name="Delete Test Client",
redirect_uris=["http://localhost:3000/callback"],
allowed_scopes=["read:users"],
)
test_client, _ = await oauth_client.create_client(
session, obj_in=client_data
)
test_client_id = test_client.client_id
with patch("app.api.routes.oauth_provider.settings") as mock_settings:
mock_settings.OAUTH_PROVIDER_ENABLED = True
response = await client.delete(
f"/api/v1/oauth/provider/clients/{test_client_id}",
headers={"Authorization": f"Bearer {superuser_token}"},
)
assert response.status_code == 204
class TestOAuthProviderConsentEndpoints:
"""Tests for OAuth provider consent management endpoints."""
@pytest.mark.asyncio
async def test_list_consents_unauthenticated(self, client):
"""Test listing consents without authentication."""
with patch("app.api.routes.oauth_provider.settings") as mock_settings:
mock_settings.OAUTH_PROVIDER_ENABLED = True
response = await client.get("/api/v1/oauth/provider/consents")
assert response.status_code == 401
@pytest.mark.asyncio
async def test_list_consents_empty(self, client, user_token):
"""Test listing consents when user has none."""
with patch("app.api.routes.oauth_provider.settings") as mock_settings:
mock_settings.OAUTH_PROVIDER_ENABLED = True
response = await client.get(
"/api/v1/oauth/provider/consents",
headers={"Authorization": f"Bearer {user_token}"},
)
assert response.status_code == 200
assert response.json() == []
@pytest.mark.asyncio
async def test_list_consents_with_data(
self, client, user_token, async_test_user, async_test_db
):
"""Test listing consents when user has granted some."""
_test_engine, AsyncTestingSessionLocal = async_test_db
from app.crud.oauth import oauth_client
from app.models.oauth_provider_token import OAuthConsent
from app.schemas.oauth import OAuthClientCreate
# Create a test client and grant consent
async with AsyncTestingSessionLocal() as session:
client_data = OAuthClientCreate(
client_name="Consented App",
redirect_uris=["http://localhost:3000/callback"],
allowed_scopes=["read:users", "write:users"],
)
test_client, _ = await oauth_client.create_client(
session, obj_in=client_data
)
# Create consent record
consent = OAuthConsent(
user_id=async_test_user.id,
client_id=test_client.client_id,
granted_scopes="read:users write:users",
)
session.add(consent)
await session.commit()
with patch("app.api.routes.oauth_provider.settings") as mock_settings:
mock_settings.OAUTH_PROVIDER_ENABLED = True
response = await client.get(
"/api/v1/oauth/provider/consents",
headers={"Authorization": f"Bearer {user_token}"},
)
assert response.status_code == 200
data = response.json()
assert len(data) == 1
assert data[0]["client_name"] == "Consented App"
assert "read:users" in data[0]["granted_scopes"]
@pytest.mark.asyncio
async def test_revoke_consent_not_found(self, client, user_token):
"""Test revoking consent that doesn't exist."""
with patch("app.api.routes.oauth_provider.settings") as mock_settings:
mock_settings.OAUTH_PROVIDER_ENABLED = True
response = await client.delete(
"/api/v1/oauth/provider/consents/non_existent_client",
headers={"Authorization": f"Bearer {user_token}"},
)
assert response.status_code == 404
@pytest.mark.asyncio
async def test_revoke_consent_success(
self, client, user_token, async_test_user, async_test_db
):
"""Test successfully revoking consent."""
_test_engine, AsyncTestingSessionLocal = async_test_db
from app.crud.oauth import oauth_client
from app.models.oauth_provider_token import OAuthConsent
from app.schemas.oauth import OAuthClientCreate
# Create a test client and grant consent
async with AsyncTestingSessionLocal() as session:
client_data = OAuthClientCreate(
client_name="Revoke Test App",
redirect_uris=["http://localhost:3000/callback"],
allowed_scopes=["read:users"],
)
test_client, _ = await oauth_client.create_client(
session, obj_in=client_data
)
test_client_id = test_client.client_id
# Create consent record
consent = OAuthConsent(
user_id=async_test_user.id,
client_id=test_client.client_id,
granted_scopes="read:users",
)
session.add(consent)
await session.commit()
with patch("app.api.routes.oauth_provider.settings") as mock_settings:
mock_settings.OAUTH_PROVIDER_ENABLED = True
response = await client.delete(
f"/api/v1/oauth/provider/consents/{test_client_id}",
headers={"Authorization": f"Bearer {user_token}"},
)
assert response.status_code == 204

View File

@@ -159,7 +159,9 @@ async def test_agent_type_crud(async_test_db, agent_type_create_data):
@pytest_asyncio.fixture
async def test_agent_instance_crud(async_test_db, test_project_crud, test_agent_type_crud):
async def test_agent_instance_crud(
async_test_db, test_project_crud, test_agent_type_crud
):
"""Create a test agent instance in the database for CRUD tests."""
_test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session:

View File

@@ -203,7 +203,7 @@ class TestAgentInstanceGetByProject:
self, db_session, test_project, test_agent_instance
):
"""Test getting agent instances with status filter."""
instances, total = await agent_instance.get_by_project(
instances, _total = await agent_instance.get_by_project(
db_session,
project_id=test_project.id,
status=AgentStatus.IDLE,

View File

@@ -17,7 +17,9 @@ class TestAgentInstanceCreate:
"""Tests for agent instance creation."""
@pytest.mark.asyncio
async def test_create_agent_instance_success(self, async_test_db, test_project_crud, test_agent_type_crud):
async def test_create_agent_instance_success(
self, async_test_db, test_project_crud, test_agent_type_crud
):
"""Test successfully creating an agent instance."""
_test_engine, AsyncTestingSessionLocal = async_test_db
@@ -41,7 +43,9 @@ class TestAgentInstanceCreate:
assert result.short_term_memory == {"context": "initial"}
@pytest.mark.asyncio
async def test_create_agent_instance_minimal(self, async_test_db, test_project_crud, test_agent_type_crud):
async def test_create_agent_instance_minimal(
self, async_test_db, test_project_crud, test_agent_type_crud
):
"""Test creating agent instance with minimal fields."""
_test_engine, AsyncTestingSessionLocal = async_test_db
@@ -62,12 +66,16 @@ class TestAgentInstanceRead:
"""Tests for agent instance read operations."""
@pytest.mark.asyncio
async def test_get_agent_instance_by_id(self, async_test_db, test_agent_instance_crud):
async def test_get_agent_instance_by_id(
self, async_test_db, test_agent_instance_crud
):
"""Test getting agent instance by ID."""
_test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session:
result = await agent_instance_crud.get(session, id=str(test_agent_instance_crud.id))
result = await agent_instance_crud.get(
session, id=str(test_agent_instance_crud.id)
)
assert result is not None
assert result.id == test_agent_instance_crud.id
@@ -102,33 +110,48 @@ class TestAgentInstanceUpdate:
"""Tests for agent instance update operations."""
@pytest.mark.asyncio
async def test_update_agent_instance_status(self, async_test_db, test_agent_instance_crud):
async def test_update_agent_instance_status(
self, async_test_db, test_agent_instance_crud
):
"""Test updating agent instance status."""
_test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session:
instance = await agent_instance_crud.get(session, id=str(test_agent_instance_crud.id))
instance = await agent_instance_crud.get(
session, id=str(test_agent_instance_crud.id)
)
update_data = AgentInstanceUpdate(
status=AgentStatus.WORKING,
current_task="Processing feature request",
)
result = await agent_instance_crud.update(session, db_obj=instance, obj_in=update_data)
result = await agent_instance_crud.update(
session, db_obj=instance, obj_in=update_data
)
assert result.status == AgentStatus.WORKING
assert result.current_task == "Processing feature request"
@pytest.mark.asyncio
async def test_update_agent_instance_memory(self, async_test_db, test_agent_instance_crud):
async def test_update_agent_instance_memory(
self, async_test_db, test_agent_instance_crud
):
"""Test updating agent instance short-term memory."""
_test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session:
instance = await agent_instance_crud.get(session, id=str(test_agent_instance_crud.id))
instance = await agent_instance_crud.get(
session, id=str(test_agent_instance_crud.id)
)
new_memory = {"conversation": ["msg1", "msg2"], "decisions": {"key": "value"}}
new_memory = {
"conversation": ["msg1", "msg2"],
"decisions": {"key": "value"},
}
update_data = AgentInstanceUpdate(short_term_memory=new_memory)
result = await agent_instance_crud.update(session, db_obj=instance, obj_in=update_data)
result = await agent_instance_crud.update(
session, db_obj=instance, obj_in=update_data
)
assert result.short_term_memory == new_memory
@@ -172,7 +195,9 @@ class TestAgentInstanceTerminate:
"""Tests for agent instance termination."""
@pytest.mark.asyncio
async def test_terminate_agent_instance(self, async_test_db, test_project_crud, test_agent_type_crud):
async def test_terminate_agent_instance(
self, async_test_db, test_project_crud, test_agent_type_crud
):
"""Test terminating an agent instance."""
_test_engine, AsyncTestingSessionLocal = async_test_db
@@ -189,7 +214,9 @@ class TestAgentInstanceTerminate:
# Terminate
async with AsyncTestingSessionLocal() as session:
result = await agent_instance_crud.terminate(session, instance_id=instance_id)
result = await agent_instance_crud.terminate(
session, instance_id=instance_id
)
assert result is not None
assert result.status == AgentStatus.TERMINATED
@@ -203,7 +230,9 @@ class TestAgentInstanceTerminate:
_test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session:
result = await agent_instance_crud.terminate(session, instance_id=uuid.uuid4())
result = await agent_instance_crud.terminate(
session, instance_id=uuid.uuid4()
)
assert result is None
@@ -211,7 +240,9 @@ class TestAgentInstanceMetrics:
"""Tests for agent instance metrics operations."""
@pytest.mark.asyncio
async def test_record_task_completion(self, async_test_db, test_agent_instance_crud):
async def test_record_task_completion(
self, async_test_db, test_agent_instance_crud
):
"""Test recording task completion with metrics."""
_test_engine, AsyncTestingSessionLocal = async_test_db
@@ -230,7 +261,9 @@ class TestAgentInstanceMetrics:
assert result.last_activity_at is not None
@pytest.mark.asyncio
async def test_record_multiple_task_completions(self, async_test_db, test_project_crud, test_agent_type_crud):
async def test_record_multiple_task_completions(
self, async_test_db, test_project_crud, test_agent_type_crud
):
"""Test recording multiple task completions accumulates metrics."""
_test_engine, AsyncTestingSessionLocal = async_test_db
@@ -267,7 +300,9 @@ class TestAgentInstanceMetrics:
assert result.cost_incurred == Decimal("0.0300")
@pytest.mark.asyncio
async def test_get_project_metrics(self, async_test_db, test_project_crud, test_agent_instance_crud):
async def test_get_project_metrics(
self, async_test_db, test_project_crud, test_agent_instance_crud
):
"""Test getting aggregated metrics for a project."""
_test_engine, AsyncTestingSessionLocal = async_test_db
@@ -290,7 +325,9 @@ class TestAgentInstanceByProject:
"""Tests for getting instances by project."""
@pytest.mark.asyncio
async def test_get_by_project(self, async_test_db, test_project_crud, test_agent_instance_crud):
async def test_get_by_project(
self, async_test_db, test_project_crud, test_agent_instance_crud
):
"""Test getting instances by project."""
_test_engine, AsyncTestingSessionLocal = async_test_db
@@ -304,7 +341,9 @@ class TestAgentInstanceByProject:
assert all(i.project_id == test_project_crud.id for i in instances)
@pytest.mark.asyncio
async def test_get_by_project_with_status(self, async_test_db, test_project_crud, test_agent_type_crud):
async def test_get_by_project_with_status(
self, async_test_db, test_project_crud, test_agent_type_crud
):
"""Test getting instances by project filtered by status."""
_test_engine, AsyncTestingSessionLocal = async_test_db
@@ -340,7 +379,9 @@ class TestAgentInstanceByAgentType:
"""Tests for getting instances by agent type."""
@pytest.mark.asyncio
async def test_get_by_agent_type(self, async_test_db, test_agent_type_crud, test_agent_instance_crud):
async def test_get_by_agent_type(
self, async_test_db, test_agent_type_crud, test_agent_instance_crud
):
"""Test getting instances by agent type."""
_test_engine, AsyncTestingSessionLocal = async_test_db
@@ -358,7 +399,9 @@ class TestBulkTerminate:
"""Tests for bulk termination of instances."""
@pytest.mark.asyncio
async def test_bulk_terminate_by_project(self, async_test_db, test_project_crud, test_agent_type_crud):
async def test_bulk_terminate_by_project(
self, async_test_db, test_project_crud, test_agent_type_crud
):
"""Test bulk terminating all instances in a project."""
_test_engine, AsyncTestingSessionLocal = async_test_db

View File

@@ -9,8 +9,7 @@ import pytest_asyncio
from sqlalchemy.exc import IntegrityError, OperationalError
from app.crud.syndarix.agent_type import agent_type
from app.models.syndarix import AgentInstance, AgentType, Project
from app.models.syndarix.enums import AgentStatus, ProjectStatus
from app.models.syndarix import AgentType
from app.schemas.syndarix import AgentTypeCreate
@@ -95,7 +94,9 @@ class TestAgentTypeCreate:
# Mock IntegrityError with slug in the message
mock_orig = MagicMock()
mock_orig.__str__ = lambda self: "duplicate key value violates unique constraint on slug"
mock_orig.__str__ = (
lambda self: "duplicate key value violates unique constraint on slug"
)
with patch.object(
db_session,
@@ -152,13 +153,13 @@ class TestAgentTypeGetMultiWithFilters:
@pytest.mark.asyncio
async def test_get_multi_with_filters_success(self, db_session, test_agent_type):
"""Test successfully getting agent types with filters."""
results, total = await agent_type.get_multi_with_filters(db_session)
_results, total = await agent_type.get_multi_with_filters(db_session)
assert total >= 1
@pytest.mark.asyncio
async def test_get_multi_with_filters_sort_asc(self, db_session, test_agent_type):
"""Test getting agent types with ascending sort order."""
results, total = await agent_type.get_multi_with_filters(
_results, total = await agent_type.get_multi_with_filters(
db_session,
sort_by="created_at",
sort_order="asc",
@@ -256,14 +257,18 @@ class TestAgentTypeGetByExpertise:
"""Tests for getting agent types by expertise."""
@pytest.mark.asyncio
@pytest.mark.skip(reason="Uses PostgreSQL JSONB contains operator, not available in SQLite")
@pytest.mark.skip(
reason="Uses PostgreSQL JSONB contains operator, not available in SQLite"
)
async def test_get_by_expertise_success(self, db_session, test_agent_type):
"""Test successfully getting agent types by expertise."""
results = await agent_type.get_by_expertise(db_session, expertise="python")
assert len(results) >= 1
@pytest.mark.asyncio
@pytest.mark.skip(reason="Uses PostgreSQL JSONB contains operator, not available in SQLite")
@pytest.mark.skip(
reason="Uses PostgreSQL JSONB contains operator, not available in SQLite"
)
async def test_get_by_expertise_db_error(self, db_session):
"""Test getting agent types by expertise when DB error occurs."""
with patch.object(

View File

@@ -42,7 +42,9 @@ class TestAgentTypeCreate:
assert result.is_active is True
@pytest.mark.asyncio
async def test_create_agent_type_duplicate_slug_fails(self, async_test_db, test_agent_type_crud):
async def test_create_agent_type_duplicate_slug_fails(
self, async_test_db, test_agent_type_crud
):
"""Test creating agent type with duplicate slug raises ValueError."""
_test_engine, AsyncTestingSessionLocal = async_test_db
@@ -109,7 +111,9 @@ class TestAgentTypeRead:
_test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session:
result = await agent_type_crud.get_by_slug(session, slug=test_agent_type_crud.slug)
result = await agent_type_crud.get_by_slug(
session, slug=test_agent_type_crud.slug
)
assert result is not None
assert result.slug == test_agent_type_crud.slug
@@ -120,7 +124,9 @@ class TestAgentTypeRead:
_test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session:
result = await agent_type_crud.get_by_slug(session, slug="non-existent-agent")
result = await agent_type_crud.get_by_slug(
session, slug="non-existent-agent"
)
assert result is None
@@ -128,48 +134,66 @@ class TestAgentTypeUpdate:
"""Tests for agent type update operations."""
@pytest.mark.asyncio
async def test_update_agent_type_basic_fields(self, async_test_db, test_agent_type_crud):
async def test_update_agent_type_basic_fields(
self, async_test_db, test_agent_type_crud
):
"""Test updating basic agent type fields."""
_test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session:
agent_type = await agent_type_crud.get(session, id=str(test_agent_type_crud.id))
agent_type = await agent_type_crud.get(
session, id=str(test_agent_type_crud.id)
)
update_data = AgentTypeUpdate(
name="Updated Agent Name",
description="Updated description",
)
result = await agent_type_crud.update(session, db_obj=agent_type, obj_in=update_data)
result = await agent_type_crud.update(
session, db_obj=agent_type, obj_in=update_data
)
assert result.name == "Updated Agent Name"
assert result.description == "Updated description"
@pytest.mark.asyncio
async def test_update_agent_type_expertise(self, async_test_db, test_agent_type_crud):
async def test_update_agent_type_expertise(
self, async_test_db, test_agent_type_crud
):
"""Test updating agent type expertise."""
_test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session:
agent_type = await agent_type_crud.get(session, id=str(test_agent_type_crud.id))
agent_type = await agent_type_crud.get(
session, id=str(test_agent_type_crud.id)
)
update_data = AgentTypeUpdate(
expertise=["new-skill", "another-skill"],
)
result = await agent_type_crud.update(session, db_obj=agent_type, obj_in=update_data)
result = await agent_type_crud.update(
session, db_obj=agent_type, obj_in=update_data
)
assert "new-skill" in result.expertise
@pytest.mark.asyncio
async def test_update_agent_type_model_params(self, async_test_db, test_agent_type_crud):
async def test_update_agent_type_model_params(
self, async_test_db, test_agent_type_crud
):
"""Test updating agent type model parameters."""
_test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session:
agent_type = await agent_type_crud.get(session, id=str(test_agent_type_crud.id))
agent_type = await agent_type_crud.get(
session, id=str(test_agent_type_crud.id)
)
new_params = {"temperature": 0.9, "max_tokens": 8192}
update_data = AgentTypeUpdate(model_params=new_params)
result = await agent_type_crud.update(session, db_obj=agent_type, obj_in=update_data)
result = await agent_type_crud.update(
session, db_obj=agent_type, obj_in=update_data
)
assert result.model_params == new_params
@@ -311,7 +335,9 @@ class TestAgentTypeSpecialMethods:
# Deactivate
async with AsyncTestingSessionLocal() as session:
result = await agent_type_crud.deactivate(session, agent_type_id=agent_type_id)
result = await agent_type_crud.deactivate(
session, agent_type_id=agent_type_id
)
assert result is not None
assert result.is_active is False
@@ -322,11 +348,15 @@ class TestAgentTypeSpecialMethods:
_test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session:
result = await agent_type_crud.deactivate(session, agent_type_id=uuid.uuid4())
result = await agent_type_crud.deactivate(
session, agent_type_id=uuid.uuid4()
)
assert result is None
@pytest.mark.asyncio
async def test_get_with_instance_count(self, async_test_db, test_agent_type_crud, test_agent_instance_crud):
async def test_get_with_instance_count(
self, async_test_db, test_agent_type_crud, test_agent_instance_crud
):
"""Test getting agent type with instance count."""
_test_engine, AsyncTestingSessionLocal = async_test_db

View File

@@ -3,13 +3,13 @@
import uuid
from datetime import UTC, datetime
from unittest.mock import AsyncMock, MagicMock, patch
from unittest.mock import MagicMock, patch
import pytest
import pytest_asyncio
from sqlalchemy.exc import IntegrityError, OperationalError
from app.crud.syndarix.issue import CRUDIssue, issue
from app.crud.syndarix.issue import issue
from app.models.syndarix import Issue, Project, Sprint
from app.models.syndarix.enums import (
IssuePriority,
@@ -18,7 +18,7 @@ from app.models.syndarix.enums import (
SprintStatus,
SyncStatus,
)
from app.schemas.syndarix import IssueCreate, IssueUpdate
from app.schemas.syndarix import IssueCreate
@pytest_asyncio.fixture
@@ -48,6 +48,7 @@ async def test_project(db_session):
async def test_sprint(db_session, test_project):
"""Create a test sprint."""
from datetime import date
sprint = Sprint(
id=uuid.uuid4(),
project_id=test_project.id,
@@ -203,7 +204,7 @@ class TestIssueGetByProject:
await db_session.commit()
# Test status filter
issues, total = await issue.get_by_project(
issues, _total = await issue.get_by_project(
db_session,
project_id=test_project.id,
status=IssueStatus.IN_PROGRESS,
@@ -212,7 +213,7 @@ class TestIssueGetByProject:
assert issues[0].status == IssueStatus.IN_PROGRESS
# Test priority filter
issues, total = await issue.get_by_project(
issues, _total = await issue.get_by_project(
db_session,
project_id=test_project.id,
priority=IssuePriority.HIGH,
@@ -221,12 +222,14 @@ class TestIssueGetByProject:
assert issues[0].priority == IssuePriority.HIGH
@pytest.mark.asyncio
@pytest.mark.skip(reason="Labels filter uses PostgreSQL @> operator, not available in SQLite")
@pytest.mark.skip(
reason="Labels filter uses PostgreSQL @> operator, not available in SQLite"
)
async def test_get_by_project_with_labels_filter(
self, db_session, test_project, test_issue
):
"""Test getting issues filtered by labels."""
issues, total = await issue.get_by_project(
issues, _total = await issue.get_by_project(
db_session,
project_id=test_project.id,
labels=["bug"],
@@ -249,7 +252,7 @@ class TestIssueGetByProject:
db_session.add(issue2)
await db_session.commit()
issues, total = await issue.get_by_project(
issues, _total = await issue.get_by_project(
db_session,
project_id=test_project.id,
sort_by="created_at",
@@ -257,8 +260,16 @@ class TestIssueGetByProject:
)
assert len(issues) == 2
# Compare without timezone info since DB may strip it
first_time = issues[0].created_at.replace(tzinfo=None) if issues[0].created_at.tzinfo else issues[0].created_at
second_time = issues[1].created_at.replace(tzinfo=None) if issues[1].created_at.tzinfo else issues[1].created_at
first_time = (
issues[0].created_at.replace(tzinfo=None)
if issues[0].created_at.tzinfo
else issues[0].created_at
)
second_time = (
issues[1].created_at.replace(tzinfo=None)
if issues[1].created_at.tzinfo
else issues[1].created_at
)
assert first_time <= second_time
@pytest.mark.asyncio
@@ -561,9 +572,7 @@ class TestIssueExternalTracker:
assert len(issues) >= 1
# Test with project filter
issues = await issue.get_pending_sync(
db_session, project_id=test_project.id
)
issues = await issue.get_pending_sync(db_session, project_id=test_project.id)
assert len(issues) >= 1
@pytest.mark.asyncio

View File

@@ -42,7 +42,9 @@ class TestIssueCreate:
assert result.story_points == 5
@pytest.mark.asyncio
async def test_create_issue_with_external_tracker(self, async_test_db, test_project_crud):
async def test_create_issue_with_external_tracker(
self, async_test_db, test_project_crud
):
"""Test creating issue with external tracker info."""
_test_engine, AsyncTestingSessionLocal = async_test_db
@@ -182,7 +184,9 @@ class TestIssueAssignment:
"""Tests for issue assignment operations."""
@pytest.mark.asyncio
async def test_assign_to_agent(self, async_test_db, test_issue_crud, test_agent_instance_crud):
async def test_assign_to_agent(
self, async_test_db, test_issue_crud, test_agent_instance_crud
):
"""Test assigning issue to an agent."""
_test_engine, AsyncTestingSessionLocal = async_test_db
@@ -198,7 +202,9 @@ class TestIssueAssignment:
assert result.human_assignee is None
@pytest.mark.asyncio
async def test_unassign_agent(self, async_test_db, test_issue_crud, test_agent_instance_crud):
async def test_unassign_agent(
self, async_test_db, test_issue_crud, test_agent_instance_crud
):
"""Test unassigning agent from issue."""
_test_engine, AsyncTestingSessionLocal = async_test_db
@@ -237,7 +243,9 @@ class TestIssueAssignment:
assert result.assigned_agent_id is None
@pytest.mark.asyncio
async def test_assign_to_human_clears_agent(self, async_test_db, test_issue_crud, test_agent_instance_crud):
async def test_assign_to_human_clears_agent(
self, async_test_db, test_issue_crud, test_agent_instance_crud
):
"""Test assigning to human clears agent assignment."""
_test_engine, AsyncTestingSessionLocal = async_test_db
@@ -304,7 +312,9 @@ class TestIssueByProject:
"""Tests for getting issues by project."""
@pytest.mark.asyncio
async def test_get_by_project(self, async_test_db, test_project_crud, test_issue_crud):
async def test_get_by_project(
self, async_test_db, test_project_crud, test_issue_crud
):
"""Test getting issues by project."""
_test_engine, AsyncTestingSessionLocal = async_test_db
@@ -397,7 +407,9 @@ class TestIssueBySprint:
"""Tests for getting issues by sprint."""
@pytest.mark.asyncio
async def test_get_by_sprint(self, async_test_db, test_project_crud, test_sprint_crud):
async def test_get_by_sprint(
self, async_test_db, test_project_crud, test_sprint_crud
):
"""Test getting issues by sprint."""
_test_engine, AsyncTestingSessionLocal = async_test_db
@@ -533,7 +545,11 @@ class TestIssueStats:
# Create issues with various statuses and priorities
async with AsyncTestingSessionLocal() as session:
for status in [IssueStatus.OPEN, IssueStatus.IN_PROGRESS, IssueStatus.CLOSED]:
for status in [
IssueStatus.OPEN,
IssueStatus.IN_PROGRESS,
IssueStatus.CLOSED,
]:
issue_data = IssueCreate(
project_id=test_project_crud.id,
title=f"Stats Issue {status.value}",

View File

@@ -10,7 +10,7 @@ from sqlalchemy.exc import IntegrityError, OperationalError
from app.crud.syndarix.project import project
from app.models.syndarix import Project
from app.models.syndarix.enums import AutonomyLevel, ProjectStatus
from app.models.syndarix.enums import ProjectStatus
from app.schemas.syndarix import ProjectCreate
@@ -88,7 +88,9 @@ class TestProjectCreate:
# Mock IntegrityError with slug in the message
mock_orig = MagicMock()
mock_orig.__str__ = lambda self: "duplicate key value violates unique constraint on slug"
mock_orig.__str__ = (
lambda self: "duplicate key value violates unique constraint on slug"
)
with patch.object(
db_session,
@@ -141,7 +143,7 @@ class TestProjectGetMultiWithFilters:
@pytest.mark.asyncio
async def test_get_multi_with_filters_success(self, db_session, test_project):
"""Test successfully getting projects with filters."""
results, total = await project.get_multi_with_filters(db_session)
_results, total = await project.get_multi_with_filters(db_session)
assert total >= 1
@pytest.mark.asyncio
@@ -162,17 +164,13 @@ class TestProjectGetWithCounts:
@pytest.mark.asyncio
async def test_get_with_counts_not_found(self, db_session):
"""Test getting non-existent project with counts."""
result = await project.get_with_counts(
db_session, project_id=uuid.uuid4()
)
result = await project.get_with_counts(db_session, project_id=uuid.uuid4())
assert result is None
@pytest.mark.asyncio
async def test_get_with_counts_success(self, db_session, test_project):
"""Test successfully getting project with counts."""
result = await project.get_with_counts(
db_session, project_id=test_project.id
)
result = await project.get_with_counts(db_session, project_id=test_project.id)
assert result is not None
assert result["project"].id == test_project.id
assert result["agent_count"] == 0
@@ -187,9 +185,7 @@ class TestProjectGetWithCounts:
side_effect=OperationalError("Connection lost", {}, Exception()),
):
with pytest.raises(OperationalError):
await project.get_with_counts(
db_session, project_id=test_project.id
)
await project.get_with_counts(db_session, project_id=test_project.id)
class TestProjectGetMultiWithCounts:
@@ -233,9 +229,7 @@ class TestProjectGetByOwner:
@pytest.mark.asyncio
async def test_get_projects_by_owner_empty(self, db_session):
"""Test getting projects by owner when none exist."""
results = await project.get_projects_by_owner(
db_session, owner_id=uuid.uuid4()
)
results = await project.get_projects_by_owner(db_session, owner_id=uuid.uuid4())
assert results == []
@pytest.mark.asyncio
@@ -247,9 +241,7 @@ class TestProjectGetByOwner:
side_effect=OperationalError("Connection lost", {}, Exception()),
):
with pytest.raises(OperationalError):
await project.get_projects_by_owner(
db_session, owner_id=uuid.uuid4()
)
await project.get_projects_by_owner(db_session, owner_id=uuid.uuid4())
class TestProjectArchive:
@@ -264,9 +256,7 @@ class TestProjectArchive:
@pytest.mark.asyncio
async def test_archive_project_success(self, db_session, test_project):
"""Test successfully archiving project."""
result = await project.archive_project(
db_session, project_id=test_project.id
)
result = await project.archive_project(db_session, project_id=test_project.id)
assert result is not None
assert result.status == ProjectStatus.ARCHIVED
@@ -279,6 +269,4 @@ class TestProjectArchive:
side_effect=OperationalError("Connection lost", {}, Exception()),
):
with pytest.raises(OperationalError):
await project.archive_project(
db_session, project_id=test_project.id
)
await project.archive_project(db_session, project_id=test_project.id)

View File

@@ -42,7 +42,9 @@ class TestProjectCreate:
assert result.owner_id == test_owner_crud.id
@pytest.mark.asyncio
async def test_create_project_duplicate_slug_fails(self, async_test_db, test_project_crud):
async def test_create_project_duplicate_slug_fails(
self, async_test_db, test_project_crud
):
"""Test creating project with duplicate slug raises ValueError."""
_test_engine, AsyncTestingSessionLocal = async_test_db
@@ -106,7 +108,9 @@ class TestProjectRead:
_test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session:
result = await project_crud.get_by_slug(session, slug=test_project_crud.slug)
result = await project_crud.get_by_slug(
session, slug=test_project_crud.slug
)
assert result is not None
assert result.slug == test_project_crud.slug
@@ -136,7 +140,9 @@ class TestProjectUpdate:
name="Updated Project Name",
description="Updated description",
)
result = await project_crud.update(session, db_obj=project, obj_in=update_data)
result = await project_crud.update(
session, db_obj=project, obj_in=update_data
)
assert result.name == "Updated Project Name"
assert result.description == "Updated description"
@@ -150,12 +156,16 @@ class TestProjectUpdate:
project = await project_crud.get(session, id=str(test_project_crud.id))
update_data = ProjectUpdate(status=ProjectStatus.PAUSED)
result = await project_crud.update(session, db_obj=project, obj_in=update_data)
result = await project_crud.update(
session, db_obj=project, obj_in=update_data
)
assert result.status == ProjectStatus.PAUSED
@pytest.mark.asyncio
async def test_update_project_autonomy_level(self, async_test_db, test_project_crud):
async def test_update_project_autonomy_level(
self, async_test_db, test_project_crud
):
"""Test updating project autonomy level."""
_test_engine, AsyncTestingSessionLocal = async_test_db
@@ -163,7 +173,9 @@ class TestProjectUpdate:
project = await project_crud.get(session, id=str(test_project_crud.id))
update_data = ProjectUpdate(autonomy_level=AutonomyLevel.AUTONOMOUS)
result = await project_crud.update(session, db_obj=project, obj_in=update_data)
result = await project_crud.update(
session, db_obj=project, obj_in=update_data
)
assert result.autonomy_level == AutonomyLevel.AUTONOMOUS
@@ -175,9 +187,14 @@ class TestProjectUpdate:
async with AsyncTestingSessionLocal() as session:
project = await project_crud.get(session, id=str(test_project_crud.id))
new_settings = {"mcp_servers": ["gitea", "slack"], "webhook_url": "https://example.com"}
new_settings = {
"mcp_servers": ["gitea", "slack"],
"webhook_url": "https://example.com",
}
update_data = ProjectUpdate(settings=new_settings)
result = await project_crud.update(session, db_obj=project, obj_in=update_data)
result = await project_crud.update(
session, db_obj=project, obj_in=update_data
)
assert result.settings == new_settings
@@ -273,7 +290,9 @@ class TestProjectFilters:
assert any(p.name == "Searchable Project" for p in projects)
@pytest.mark.asyncio
async def test_get_multi_with_filters_owner(self, async_test_db, test_owner_crud, test_project_crud):
async def test_get_multi_with_filters_owner(
self, async_test_db, test_owner_crud, test_project_crud
):
"""Test filtering projects by owner."""
_test_engine, AsyncTestingSessionLocal = async_test_db
@@ -287,7 +306,9 @@ class TestProjectFilters:
assert all(p.owner_id == test_owner_crud.id for p in projects)
@pytest.mark.asyncio
async def test_get_multi_with_filters_pagination(self, async_test_db, test_owner_crud):
async def test_get_multi_with_filters_pagination(
self, async_test_db, test_owner_crud
):
"""Test pagination of project results."""
_test_engine, AsyncTestingSessionLocal = async_test_db
@@ -348,7 +369,9 @@ class TestProjectSpecialMethods:
_test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session:
result = await project_crud.archive_project(session, project_id=test_project_crud.id)
result = await project_crud.archive_project(
session, project_id=test_project_crud.id
)
assert result is not None
assert result.status == ProjectStatus.ARCHIVED
@@ -359,11 +382,15 @@ class TestProjectSpecialMethods:
_test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session:
result = await project_crud.archive_project(session, project_id=uuid.uuid4())
result = await project_crud.archive_project(
session, project_id=uuid.uuid4()
)
assert result is None
@pytest.mark.asyncio
async def test_get_projects_by_owner(self, async_test_db, test_owner_crud, test_project_crud):
async def test_get_projects_by_owner(
self, async_test_db, test_owner_crud, test_project_crud
):
"""Test getting all projects by owner."""
_test_engine, AsyncTestingSessionLocal = async_test_db
@@ -377,7 +404,9 @@ class TestProjectSpecialMethods:
assert all(p.owner_id == test_owner_crud.id for p in projects)
@pytest.mark.asyncio
async def test_get_projects_by_owner_with_status(self, async_test_db, test_owner_crud):
async def test_get_projects_by_owner_with_status(
self, async_test_db, test_owner_crud
):
"""Test getting projects by owner filtered by status."""
_test_engine, AsyncTestingSessionLocal = async_test_db

View File

@@ -9,7 +9,7 @@ import pytest
import pytest_asyncio
from sqlalchemy.exc import IntegrityError, OperationalError
from app.crud.syndarix.sprint import CRUDSprint, sprint
from app.crud.syndarix.sprint import sprint
from app.models.syndarix import Issue, Project, Sprint
from app.models.syndarix.enums import (
IssueStatus,
@@ -174,7 +174,7 @@ class TestSprintGetByProject:
self, db_session, test_project, test_sprint
):
"""Test getting sprints with status filter."""
sprints, total = await sprint.get_by_project(
sprints, _total = await sprint.get_by_project(
db_session,
project_id=test_project.id,
status=SprintStatus.PLANNED,
@@ -478,7 +478,7 @@ class TestSprintWithIssueCounts:
db_session.add_all([issue1, issue2])
await db_session.commit()
results, total = await sprint.get_sprints_with_issue_counts(
results, _total = await sprint.get_sprints_with_issue_counts(
db_session, project_id=test_project.id
)
assert len(results) == 1

View File

@@ -121,7 +121,9 @@ class TestSprintUpdate:
name="Updated Sprint Name",
goal="Updated goal",
)
result = await sprint_crud.update(session, db_obj=sprint, obj_in=update_data)
result = await sprint_crud.update(
session, db_obj=sprint, obj_in=update_data
)
assert result.name == "Updated Sprint Name"
assert result.goal == "Updated goal"
@@ -139,7 +141,9 @@ class TestSprintUpdate:
start_date=today + timedelta(days=1),
end_date=today + timedelta(days=21),
)
result = await sprint_crud.update(session, db_obj=sprint, obj_in=update_data)
result = await sprint_crud.update(
session, db_obj=sprint, obj_in=update_data
)
assert result.start_date == today + timedelta(days=1)
assert result.end_date == today + timedelta(days=21)
@@ -163,7 +167,9 @@ class TestSprintLifecycle:
assert result.status == SprintStatus.ACTIVE
@pytest.mark.asyncio
async def test_start_sprint_with_custom_date(self, async_test_db, test_project_crud):
async def test_start_sprint_with_custom_date(
self, async_test_db, test_project_crud
):
"""Test starting sprint with custom start date."""
_test_engine, AsyncTestingSessionLocal = async_test_db
@@ -195,7 +201,9 @@ class TestSprintLifecycle:
assert result.start_date == new_start
@pytest.mark.asyncio
async def test_start_sprint_already_active_fails(self, async_test_db, test_project_crud):
async def test_start_sprint_already_active_fails(
self, async_test_db, test_project_crud
):
"""Test starting an already active sprint raises ValueError."""
_test_engine, AsyncTestingSessionLocal = async_test_db
@@ -250,7 +258,9 @@ class TestSprintLifecycle:
assert result.status == SprintStatus.COMPLETED
@pytest.mark.asyncio
async def test_complete_planned_sprint_fails(self, async_test_db, test_project_crud):
async def test_complete_planned_sprint_fails(
self, async_test_db, test_project_crud
):
"""Test completing a planned sprint raises ValueError."""
_test_engine, AsyncTestingSessionLocal = async_test_db
@@ -300,7 +310,9 @@ class TestSprintLifecycle:
assert result.status == SprintStatus.CANCELLED
@pytest.mark.asyncio
async def test_cancel_completed_sprint_fails(self, async_test_db, test_project_crud):
async def test_cancel_completed_sprint_fails(
self, async_test_db, test_project_crud
):
"""Test cancelling a completed sprint raises ValueError."""
_test_engine, AsyncTestingSessionLocal = async_test_db
@@ -329,7 +341,9 @@ class TestSprintByProject:
"""Tests for getting sprints by project."""
@pytest.mark.asyncio
async def test_get_by_project(self, async_test_db, test_project_crud, test_sprint_crud):
async def test_get_by_project(
self, async_test_db, test_project_crud, test_sprint_crud
):
"""Test getting sprints by project."""
_test_engine, AsyncTestingSessionLocal = async_test_db
@@ -506,7 +520,9 @@ class TestSprintWithIssueCounts:
"""Tests for getting sprints with issue counts."""
@pytest.mark.asyncio
async def test_get_sprints_with_issue_counts(self, async_test_db, test_project_crud, test_sprint_crud):
async def test_get_sprints_with_issue_counts(
self, async_test_db, test_project_crud, test_sprint_crud
):
"""Test getting sprints with issue counts."""
_test_engine, AsyncTestingSessionLocal = async_test_db

View File

@@ -12,7 +12,7 @@ from sqlalchemy.exc import DataError, IntegrityError, OperationalError
from sqlalchemy.orm import joinedload
from app.crud.user import user as user_crud
from app.schemas.users import UserCreate, UserUpdate
from app.schemas.users import UserCreate
class TestCRUDBaseGet:

View File

@@ -48,7 +48,9 @@ class TestAgentInstanceModel:
db_session.add(instance)
db_session.commit()
retrieved = db_session.query(AgentInstance).filter_by(project_id=project.id).first()
retrieved = (
db_session.query(AgentInstance).filter_by(project_id=project.id).first()
)
assert retrieved is not None
assert retrieved.agent_type_id == agent_type.id
@@ -92,7 +94,10 @@ class TestAgentInstanceModel:
name="Bob",
status=AgentStatus.WORKING,
current_task="Implementing user authentication",
short_term_memory={"context": "Working on auth", "recent_files": ["auth.py"]},
short_term_memory={
"context": "Working on auth",
"recent_files": ["auth.py"],
},
long_term_memory_ref="project-123/agent-456",
session_id="session-abc-123",
last_activity_at=now,
@@ -107,7 +112,10 @@ class TestAgentInstanceModel:
assert retrieved.status == AgentStatus.WORKING
assert retrieved.current_task == "Implementing user authentication"
assert retrieved.short_term_memory == {"context": "Working on auth", "recent_files": ["auth.py"]}
assert retrieved.short_term_memory == {
"context": "Working on auth",
"recent_files": ["auth.py"],
}
assert retrieved.long_term_memory_ref == "project-123/agent-456"
assert retrieved.session_id == "session-abc-123"
assert retrieved.tasks_completed == 5
@@ -116,7 +124,9 @@ class TestAgentInstanceModel:
def test_agent_instance_timestamps(self, db_session):
"""Test that timestamps are automatically set."""
project = Project(id=uuid.uuid4(), name="Timestamp Project", slug="timestamp-project-ai")
project = Project(
id=uuid.uuid4(), name="Timestamp Project", slug="timestamp-project-ai"
)
agent_type = AgentType(
id=uuid.uuid4(),
name="Timestamp Agent",
@@ -176,7 +186,9 @@ class TestAgentInstanceStatus:
def test_all_agent_statuses(self, db_session):
"""Test that all agent statuses can be stored."""
project = Project(id=uuid.uuid4(), name="Status Project", slug="status-project-ai")
project = Project(
id=uuid.uuid4(), name="Status Project", slug="status-project-ai"
)
agent_type = AgentType(
id=uuid.uuid4(),
name="Status Agent",
@@ -199,12 +211,18 @@ class TestAgentInstanceStatus:
db_session.add(instance)
db_session.commit()
retrieved = db_session.query(AgentInstance).filter_by(id=instance.id).first()
retrieved = (
db_session.query(AgentInstance).filter_by(id=instance.id).first()
)
assert retrieved.status == status
def test_status_update(self, db_session):
"""Test updating agent instance status."""
project = Project(id=uuid.uuid4(), name="Update Status Project", slug="update-status-project-ai")
project = Project(
id=uuid.uuid4(),
name="Update Status Project",
slug="update-status-project-ai",
)
agent_type = AgentType(
id=uuid.uuid4(),
name="Update Status Agent",
@@ -237,7 +255,9 @@ class TestAgentInstanceStatus:
def test_terminate_agent_instance(self, db_session):
"""Test terminating an agent instance."""
project = Project(id=uuid.uuid4(), name="Terminate Project", slug="terminate-project-ai")
project = Project(
id=uuid.uuid4(), name="Terminate Project", slug="terminate-project-ai"
)
agent_type = AgentType(
id=uuid.uuid4(),
name="Terminate Agent",
@@ -281,7 +301,9 @@ class TestAgentInstanceMetrics:
def test_increment_metrics(self, db_session):
"""Test incrementing usage metrics."""
project = Project(id=uuid.uuid4(), name="Metrics Project", slug="metrics-project-ai")
project = Project(
id=uuid.uuid4(), name="Metrics Project", slug="metrics-project-ai"
)
agent_type = AgentType(
id=uuid.uuid4(),
name="Metrics Agent",
@@ -326,7 +348,9 @@ class TestAgentInstanceMetrics:
def test_large_token_count(self, db_session):
"""Test handling large token counts."""
project = Project(id=uuid.uuid4(), name="Large Tokens Project", slug="large-tokens-project-ai")
project = Project(
id=uuid.uuid4(), name="Large Tokens Project", slug="large-tokens-project-ai"
)
agent_type = AgentType(
id=uuid.uuid4(),
name="Large Tokens Agent",
@@ -359,7 +383,9 @@ class TestAgentInstanceShortTermMemory:
def test_store_complex_memory(self, db_session):
"""Test storing complex short-term memory."""
project = Project(id=uuid.uuid4(), name="Memory Project", slug="memory-project-ai")
project = Project(
id=uuid.uuid4(), name="Memory Project", slug="memory-project-ai"
)
agent_type = AgentType(
id=uuid.uuid4(),
name="Memory Agent",
@@ -402,7 +428,11 @@ class TestAgentInstanceShortTermMemory:
def test_update_memory(self, db_session):
"""Test updating short-term memory."""
project = Project(id=uuid.uuid4(), name="Update Memory Project", slug="update-memory-project-ai")
project = Project(
id=uuid.uuid4(),
name="Update Memory Project",
slug="update-memory-project-ai",
)
agent_type = AgentType(
id=uuid.uuid4(),
name="Update Memory Agent",

View File

@@ -70,7 +70,10 @@ class TestAgentTypeModel:
assert retrieved.fallback_models == ["claude-sonnet-4-20250514", "gpt-4o"]
assert retrieved.model_params == {"temperature": 0.7, "max_tokens": 4096}
assert retrieved.mcp_servers == ["gitea", "file-system", "slack"]
assert retrieved.tool_permissions == {"allowed": ["*"], "denied": ["dangerous_tool"]}
assert retrieved.tool_permissions == {
"allowed": ["*"],
"denied": ["dangerous_tool"],
}
assert retrieved.is_active is True
def test_agent_type_unique_slug_constraint(self, db_session):
@@ -111,7 +114,9 @@ class TestAgentTypeModel:
db_session.add(agent_type)
db_session.commit()
retrieved = db_session.query(AgentType).filter_by(slug="timestamp-agent").first()
retrieved = (
db_session.query(AgentType).filter_by(slug="timestamp-agent").first()
)
assert isinstance(retrieved.created_at, datetime)
assert isinstance(retrieved.updated_at, datetime)
@@ -252,7 +257,9 @@ class TestAgentTypeJsonFields:
db_session.add(agent_type)
db_session.commit()
retrieved = db_session.query(AgentType).filter_by(slug="permissions-agent").first()
retrieved = (
db_session.query(AgentType).filter_by(slug="permissions-agent").first()
)
assert retrieved.tool_permissions == tool_permissions
assert "file:read" in retrieved.tool_permissions["allowed"]
assert retrieved.tool_permissions["limits"]["file:write"]["max_size_mb"] == 10
@@ -269,7 +276,9 @@ class TestAgentTypeJsonFields:
db_session.add(agent_type)
db_session.commit()
retrieved = db_session.query(AgentType).filter_by(slug="empty-json-agent").first()
retrieved = (
db_session.query(AgentType).filter_by(slug="empty-json-agent").first()
)
assert retrieved.expertise == []
assert retrieved.fallback_models == []
assert retrieved.model_params == {}

View File

@@ -107,7 +107,11 @@ class TestIssueModel:
def test_issue_timestamps(self, db_session):
"""Test that timestamps are automatically set."""
project = Project(id=uuid.uuid4(), name="Timestamp Issue Project", slug="timestamp-issue-project")
project = Project(
id=uuid.uuid4(),
name="Timestamp Issue Project",
slug="timestamp-issue-project",
)
db_session.add(project)
db_session.commit()
@@ -124,7 +128,9 @@ class TestIssueModel:
def test_issue_string_representation(self, db_session):
"""Test the string representation of an issue."""
project = Project(id=uuid.uuid4(), name="Repr Issue Project", slug="repr-issue-project")
project = Project(
id=uuid.uuid4(), name="Repr Issue Project", slug="repr-issue-project"
)
db_session.add(project)
db_session.commit()
@@ -147,7 +153,9 @@ class TestIssueStatus:
def test_all_issue_statuses(self, db_session):
"""Test that all issue statuses can be stored."""
project = Project(id=uuid.uuid4(), name="Status Issue Project", slug="status-issue-project")
project = Project(
id=uuid.uuid4(), name="Status Issue Project", slug="status-issue-project"
)
db_session.add(project)
db_session.commit()
@@ -170,7 +178,11 @@ class TestIssuePriority:
def test_all_issue_priorities(self, db_session):
"""Test that all issue priorities can be stored."""
project = Project(id=uuid.uuid4(), name="Priority Issue Project", slug="priority-issue-project")
project = Project(
id=uuid.uuid4(),
name="Priority Issue Project",
slug="priority-issue-project",
)
db_session.add(project)
db_session.commit()
@@ -193,7 +205,9 @@ class TestIssueSyncStatus:
def test_all_sync_statuses(self, db_session):
"""Test that all sync statuses can be stored."""
project = Project(id=uuid.uuid4(), name="Sync Issue Project", slug="sync-issue-project")
project = Project(
id=uuid.uuid4(), name="Sync Issue Project", slug="sync-issue-project"
)
db_session.add(project)
db_session.commit()
@@ -218,7 +232,9 @@ class TestIssueLabels:
def test_store_labels(self, db_session):
"""Test storing labels list."""
project = Project(id=uuid.uuid4(), name="Labels Issue Project", slug="labels-issue-project")
project = Project(
id=uuid.uuid4(), name="Labels Issue Project", slug="labels-issue-project"
)
db_session.add(project)
db_session.commit()
@@ -239,7 +255,9 @@ class TestIssueLabels:
def test_update_labels(self, db_session):
"""Test updating labels."""
project = Project(id=uuid.uuid4(), name="Update Labels Project", slug="update-labels-project")
project = Project(
id=uuid.uuid4(), name="Update Labels Project", slug="update-labels-project"
)
db_session.add(project)
db_session.commit()
@@ -255,7 +273,9 @@ class TestIssueLabels:
issue.labels = ["updated", "new-label"]
db_session.commit()
retrieved = db_session.query(Issue).filter_by(title="Update Labels Issue").first()
retrieved = (
db_session.query(Issue).filter_by(title="Update Labels Issue").first()
)
assert "initial" not in retrieved.labels
assert "updated" in retrieved.labels
@@ -265,7 +285,9 @@ class TestIssueAssignment:
def test_assign_to_agent(self, db_session):
"""Test assigning an issue to an agent."""
project = Project(id=uuid.uuid4(), name="Agent Assign Project", slug="agent-assign-project")
project = Project(
id=uuid.uuid4(), name="Agent Assign Project", slug="agent-assign-project"
)
agent_type = AgentType(
id=uuid.uuid4(),
name="Test Agent Type",
@@ -295,13 +317,17 @@ class TestIssueAssignment:
db_session.add(issue)
db_session.commit()
retrieved = db_session.query(Issue).filter_by(title="Agent Assignment Issue").first()
retrieved = (
db_session.query(Issue).filter_by(title="Agent Assignment Issue").first()
)
assert retrieved.assigned_agent_id == agent_instance.id
assert retrieved.human_assignee is None
def test_assign_to_human(self, db_session):
"""Test assigning an issue to a human."""
project = Project(id=uuid.uuid4(), name="Human Assign Project", slug="human-assign-project")
project = Project(
id=uuid.uuid4(), name="Human Assign Project", slug="human-assign-project"
)
db_session.add(project)
db_session.commit()
@@ -314,7 +340,9 @@ class TestIssueAssignment:
db_session.add(issue)
db_session.commit()
retrieved = db_session.query(Issue).filter_by(title="Human Assignment Issue").first()
retrieved = (
db_session.query(Issue).filter_by(title="Human Assignment Issue").first()
)
assert retrieved.human_assignee == "developer@example.com"
assert retrieved.assigned_agent_id is None
@@ -324,7 +352,9 @@ class TestIssueSprintAssociation:
def test_assign_issue_to_sprint(self, db_session):
"""Test assigning an issue to a sprint."""
project = Project(id=uuid.uuid4(), name="Sprint Assign Project", slug="sprint-assign-project")
project = Project(
id=uuid.uuid4(), name="Sprint Assign Project", slug="sprint-assign-project"
)
db_session.add(project)
db_session.commit()
@@ -381,7 +411,9 @@ class TestIssueExternalTracker:
db_session.add(issue)
db_session.commit()
retrieved = db_session.query(Issue).filter_by(title="Gitea Synced Issue").first()
retrieved = (
db_session.query(Issue).filter_by(title="Gitea Synced Issue").first()
)
assert retrieved.external_tracker_type == "gitea"
assert retrieved.external_issue_id == "abc123xyz"
assert retrieved.external_issue_number == 42
@@ -405,7 +437,9 @@ class TestIssueExternalTracker:
db_session.add(issue)
db_session.commit()
retrieved = db_session.query(Issue).filter_by(title="GitHub Synced Issue").first()
retrieved = (
db_session.query(Issue).filter_by(title="GitHub Synced Issue").first()
)
assert retrieved.external_tracker_type == "github"
assert retrieved.external_issue_number == 100
@@ -415,7 +449,9 @@ class TestIssueLifecycle:
def test_close_issue(self, db_session):
"""Test closing an issue."""
project = Project(id=uuid.uuid4(), name="Close Issue Project", slug="close-issue-project")
project = Project(
id=uuid.uuid4(), name="Close Issue Project", slug="close-issue-project"
)
db_session.add(project)
db_session.commit()
@@ -440,7 +476,9 @@ class TestIssueLifecycle:
def test_reopen_issue(self, db_session):
"""Test reopening a closed issue."""
project = Project(id=uuid.uuid4(), name="Reopen Issue Project", slug="reopen-issue-project")
project = Project(
id=uuid.uuid4(), name="Reopen Issue Project", slug="reopen-issue-project"
)
db_session.add(project)
db_session.commit()

View File

@@ -100,7 +100,9 @@ class TestProjectModel:
db_session.add(project)
db_session.commit()
retrieved = db_session.query(Project).filter_by(slug="timestamp-project").first()
retrieved = (
db_session.query(Project).filter_by(slug="timestamp-project").first()
)
assert isinstance(retrieved.created_at, datetime)
assert isinstance(retrieved.updated_at, datetime)
@@ -177,7 +179,11 @@ class TestProjectEnums:
db_session.add(project)
db_session.commit()
retrieved = db_session.query(Project).filter_by(slug=f"project-{level.value}").first()
retrieved = (
db_session.query(Project)
.filter_by(slug=f"project-{level.value}")
.first()
)
assert retrieved.autonomy_level == level
def test_all_project_statuses(self, db_session):
@@ -192,7 +198,11 @@ class TestProjectEnums:
db_session.add(project)
db_session.commit()
retrieved = db_session.query(Project).filter_by(slug=f"project-status-{status.value}").first()
retrieved = (
db_session.query(Project)
.filter_by(slug=f"project-status-{status.value}")
.first()
)
assert retrieved.status == status
@@ -227,7 +237,10 @@ class TestProjectSettings:
assert retrieved.settings == complex_settings
assert retrieved.settings["mcp_servers"] == ["gitea", "slack", "file-system"]
assert retrieved.settings["webhook_urls"]["on_issue_created"] == "https://example.com/issue"
assert (
retrieved.settings["webhook_urls"]["on_issue_created"]
== "https://example.com/issue"
)
assert "important" in retrieved.settings["tags"]
def test_empty_settings(self, db_session):

View File

@@ -91,7 +91,11 @@ class TestSprintModel:
def test_sprint_timestamps(self, db_session):
"""Test that timestamps are automatically set."""
project = Project(id=uuid.uuid4(), name="Timestamp Sprint Project", slug="timestamp-sprint-project")
project = Project(
id=uuid.uuid4(),
name="Timestamp Sprint Project",
slug="timestamp-sprint-project",
)
db_session.add(project)
db_session.commit()
@@ -112,7 +116,9 @@ class TestSprintModel:
def test_sprint_string_representation(self, db_session):
"""Test the string representation of a sprint."""
project = Project(id=uuid.uuid4(), name="Repr Sprint Project", slug="repr-sprint-project")
project = Project(
id=uuid.uuid4(), name="Repr Sprint Project", slug="repr-sprint-project"
)
db_session.add(project)
db_session.commit()
@@ -139,7 +145,9 @@ class TestSprintStatus:
def test_all_sprint_statuses(self, db_session):
"""Test that all sprint statuses can be stored."""
project = Project(id=uuid.uuid4(), name="Status Sprint Project", slug="status-sprint-project")
project = Project(
id=uuid.uuid4(), name="Status Sprint Project", slug="status-sprint-project"
)
db_session.add(project)
db_session.commit()
@@ -166,7 +174,9 @@ class TestSprintLifecycle:
def test_start_sprint(self, db_session):
"""Test starting a planned sprint."""
project = Project(id=uuid.uuid4(), name="Start Sprint Project", slug="start-sprint-project")
project = Project(
id=uuid.uuid4(), name="Start Sprint Project", slug="start-sprint-project"
)
db_session.add(project)
db_session.commit()
@@ -194,7 +204,11 @@ class TestSprintLifecycle:
def test_complete_sprint(self, db_session):
"""Test completing an active sprint."""
project = Project(id=uuid.uuid4(), name="Complete Sprint Project", slug="complete-sprint-project")
project = Project(
id=uuid.uuid4(),
name="Complete Sprint Project",
slug="complete-sprint-project",
)
db_session.add(project)
db_session.commit()
@@ -217,13 +231,17 @@ class TestSprintLifecycle:
sprint.velocity = 18
db_session.commit()
retrieved = db_session.query(Sprint).filter_by(name="Sprint to Complete").first()
retrieved = (
db_session.query(Sprint).filter_by(name="Sprint to Complete").first()
)
assert retrieved.status == SprintStatus.COMPLETED
assert retrieved.velocity == 18
def test_cancel_sprint(self, db_session):
"""Test cancelling a sprint."""
project = Project(id=uuid.uuid4(), name="Cancel Sprint Project", slug="cancel-sprint-project")
project = Project(
id=uuid.uuid4(), name="Cancel Sprint Project", slug="cancel-sprint-project"
)
db_session.add(project)
db_session.commit()
@@ -254,7 +272,9 @@ class TestSprintDates:
def test_sprint_date_range(self, db_session):
"""Test storing sprint date range."""
project = Project(id=uuid.uuid4(), name="Date Range Project", slug="date-range-project")
project = Project(
id=uuid.uuid4(), name="Date Range Project", slug="date-range-project"
)
db_session.add(project)
db_session.commit()
@@ -278,7 +298,9 @@ class TestSprintDates:
def test_one_day_sprint(self, db_session):
"""Test creating a one-day sprint."""
project = Project(id=uuid.uuid4(), name="One Day Project", slug="one-day-project")
project = Project(
id=uuid.uuid4(), name="One Day Project", slug="one-day-project"
)
db_session.add(project)
db_session.commit()
@@ -299,7 +321,9 @@ class TestSprintDates:
def test_long_sprint(self, db_session):
"""Test creating a long sprint (e.g., 4 weeks)."""
project = Project(id=uuid.uuid4(), name="Long Sprint Project", slug="long-sprint-project")
project = Project(
id=uuid.uuid4(), name="Long Sprint Project", slug="long-sprint-project"
)
db_session.add(project)
db_session.commit()
@@ -325,7 +349,9 @@ class TestSprintPoints:
def test_sprint_with_zero_points(self, db_session):
"""Test sprint with zero planned points."""
project = Project(id=uuid.uuid4(), name="Zero Points Project", slug="zero-points-project")
project = Project(
id=uuid.uuid4(), name="Zero Points Project", slug="zero-points-project"
)
db_session.add(project)
db_session.commit()
@@ -343,13 +369,17 @@ class TestSprintPoints:
db_session.add(sprint)
db_session.commit()
retrieved = db_session.query(Sprint).filter_by(name="Zero Points Sprint").first()
retrieved = (
db_session.query(Sprint).filter_by(name="Zero Points Sprint").first()
)
assert retrieved.planned_points == 0
assert retrieved.velocity == 0
def test_sprint_velocity_calculation(self, db_session):
"""Test that we can calculate velocity from points."""
project = Project(id=uuid.uuid4(), name="Velocity Project", slug="velocity-project")
project = Project(
id=uuid.uuid4(), name="Velocity Project", slug="velocity-project"
)
db_session.add(project)
db_session.commit()
@@ -376,7 +406,9 @@ class TestSprintPoints:
def test_sprint_overdelivery(self, db_session):
"""Test sprint where completed > planned (stretch goals)."""
project = Project(id=uuid.uuid4(), name="Overdelivery Project", slug="overdelivery-project")
project = Project(
id=uuid.uuid4(), name="Overdelivery Project", slug="overdelivery-project"
)
db_session.add(project)
db_session.commit()
@@ -395,7 +427,9 @@ class TestSprintPoints:
db_session.add(sprint)
db_session.commit()
retrieved = db_session.query(Sprint).filter_by(name="Overdelivery Sprint").first()
retrieved = (
db_session.query(Sprint).filter_by(name="Overdelivery Sprint").first()
)
assert retrieved.velocity > retrieved.planned_points
@@ -404,7 +438,9 @@ class TestSprintNumber:
def test_sequential_sprint_numbers(self, db_session):
"""Test creating sprints with sequential numbers."""
project = Project(id=uuid.uuid4(), name="Sequential Project", slug="sequential-project")
project = Project(
id=uuid.uuid4(), name="Sequential Project", slug="sequential-project"
)
db_session.add(project)
db_session.commit()
@@ -421,14 +457,21 @@ class TestSprintNumber:
db_session.add(sprint)
db_session.commit()
sprints = db_session.query(Sprint).filter_by(project_id=project.id).order_by(Sprint.number).all()
sprints = (
db_session.query(Sprint)
.filter_by(project_id=project.id)
.order_by(Sprint.number)
.all()
)
assert len(sprints) == 5
for i, sprint in enumerate(sprints, 1):
assert sprint.number == i
def test_large_sprint_number(self, db_session):
"""Test sprint with large number (e.g., long-running project)."""
project = Project(id=uuid.uuid4(), name="Large Number Project", slug="large-number-project")
project = Project(
id=uuid.uuid4(), name="Large Number Project", slug="large-number-project"
)
db_session.add(project)
db_session.commit()
@@ -453,7 +496,9 @@ class TestSprintUpdate:
def test_update_sprint_goal(self, db_session):
"""Test updating sprint goal."""
project = Project(id=uuid.uuid4(), name="Update Goal Project", slug="update-goal-project")
project = Project(
id=uuid.uuid4(), name="Update Goal Project", slug="update-goal-project"
)
db_session.add(project)
db_session.commit()
@@ -475,14 +520,18 @@ class TestSprintUpdate:
sprint.goal = "Updated goal with more detail"
db_session.commit()
retrieved = db_session.query(Sprint).filter_by(name="Update Goal Sprint").first()
retrieved = (
db_session.query(Sprint).filter_by(name="Update Goal Sprint").first()
)
assert retrieved.goal == "Updated goal with more detail"
assert retrieved.created_at == original_created_at
assert retrieved.updated_at > original_created_at
def test_update_sprint_dates(self, db_session):
"""Test updating sprint dates."""
project = Project(id=uuid.uuid4(), name="Update Dates Project", slug="update-dates-project")
project = Project(
id=uuid.uuid4(), name="Update Dates Project", slug="update-dates-project"
)
db_session.add(project)
db_session.commit()
@@ -502,6 +551,8 @@ class TestSprintUpdate:
sprint.end_date = today + timedelta(days=21)
db_session.commit()
retrieved = db_session.query(Sprint).filter_by(name="Update Dates Sprint").first()
retrieved = (
db_session.query(Sprint).filter_by(name="Update Dates Sprint").first()
)
delta = retrieved.end_date - retrieved.start_date
assert delta.days == 21

View File

@@ -10,7 +10,6 @@ These tests verify:
"""
class TestCeleryAppConfiguration:
"""Tests for the Celery application instance configuration."""

View File

@@ -134,9 +134,7 @@ class TestRecordLlmUsageTask:
]
for model, cost in models:
result = record_llm_usage(
agent_id, project_id, model, 1000, 500, cost
)
result = record_llm_usage(agent_id, project_id, model, 1000, 500, cost)
assert result["status"] == "pending"
def test_record_llm_usage_with_zero_tokens(self):

View File

@@ -8,7 +8,8 @@
'use client';
import { useCallback, useState } from 'react';
import { useRouter, useParams } from 'next/navigation';
import { useParams } from 'next/navigation';
import { useRouter } from '@/lib/i18n/routing';
import { toast } from 'sonner';
import { AgentTypeDetail, AgentTypeForm } from '@/components/agents';
import {

View File

@@ -8,7 +8,7 @@
'use client';
import { useState, useCallback, useMemo } from 'react';
import { useRouter } from 'next/navigation';
import { useRouter } from '@/lib/i18n/routing';
import { toast } from 'sonner';
import { AgentTypeList } from '@/components/agents';
import { useAgentTypes } from '@/lib/api/hooks/useAgentTypes';

View File

@@ -10,7 +10,7 @@
*/
import { useState, use } from 'react';
import { useRouter } from 'next/navigation';
import { useRouter } from '@/lib/i18n/routing';
import { Plus, Upload } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
@@ -25,7 +25,7 @@ interface ProjectIssuesPageProps {
}
export default function ProjectIssuesPage({ params }: ProjectIssuesPageProps) {
const { locale, id: projectId } = use(params);
const { id: projectId } = use(params);
const router = useRouter();
// Filter state
@@ -49,7 +49,7 @@ export default function ProjectIssuesPage({ params }: ProjectIssuesPageProps) {
const { data, isLoading, error } = useIssues(projectId, filters, sort);
const handleIssueClick = (issueId: string) => {
router.push(`/${locale}/projects/${projectId}/issues/${issueId}`);
router.push(`/projects/${projectId}/issues/${issueId}`);
};
const handleBulkChangeStatus = () => {

View File

@@ -13,17 +13,11 @@ export const metadata: Metadata = {
description: 'Create a new Syndarix project with AI-powered agents',
};
interface NewProjectPageProps {
params: Promise<{ locale: string }>;
}
export default async function NewProjectPage({ params }: NewProjectPageProps) {
const { locale } = await params;
export default function NewProjectPage() {
return (
<div className="min-h-screen bg-background">
<div className="container mx-auto px-4 py-8">
<ProjectWizard locale={locale} />
<ProjectWizard />
</div>
</div>
);

View File

@@ -10,7 +10,7 @@
'use client';
import { useState, useCallback, useMemo } from 'react';
import { useRouter } from 'next/navigation';
import { useRouter } from '@/lib/i18n/routing';
import { toast } from 'sonner';
import { Plus } from 'lucide-react';
import { Button } from '@/components/ui/button';

View File

@@ -3,12 +3,16 @@
* Homepage / Landing Page
* Main landing page for the Syndarix project
* Showcases features, tech stack, and provides demos for developers
*
* If user is authenticated, redirects to dashboard
*/
'use client';
import { useState } from 'react';
import { Link } from '@/lib/i18n/routing';
import { useState, useEffect } from 'react';
import { Link, useRouter } from '@/lib/i18n/routing';
import { useAuth } from '@/lib/auth/AuthContext';
import config from '@/config/app.config';
import { Header } from '@/components/home/Header';
import { HeroSection } from '@/components/home/HeroSection';
import { ContextSection } from '@/components/home/ContextSection';
@@ -24,6 +28,20 @@ import { DemoCredentialsModal } from '@/components/home/DemoCredentialsModal';
export default function Home() {
const [demoModalOpen, setDemoModalOpen] = useState(false);
const router = useRouter();
const { isAuthenticated, isLoading } = useAuth();
// Redirect authenticated users to dashboard
useEffect(() => {
if (!isLoading && isAuthenticated) {
router.push(config.routes.dashboard);
}
}, [isLoading, isAuthenticated, router]);
// Show nothing while checking auth or redirecting
if (isLoading || isAuthenticated) {
return null;
}
return (
<div className="min-h-screen">

View File

@@ -25,7 +25,11 @@ export interface DashboardQuickStatsProps {
className?: string;
}
export function DashboardQuickStats({ stats, isLoading = false, className }: DashboardQuickStatsProps) {
export function DashboardQuickStats({
stats,
isLoading = false,
className,
}: DashboardQuickStatsProps) {
return (
<div className={className}>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">

View File

@@ -31,8 +31,8 @@ export function EmptyState({ userName = 'there', className }: EmptyStateProps) {
<h2 className="text-2xl font-bold">Welcome to Syndarix, {userName}!</h2>
<p className="mx-auto mt-2 max-w-md text-muted-foreground">
Get started by creating your first project. Our AI agents will help you
turn your ideas into reality.
Get started by creating your first project. Our AI agents will help you turn your ideas
into reality.
</p>
<Button size="lg" asChild className="mt-6">

View File

@@ -66,7 +66,10 @@ const typeConfig: Record<
},
};
const priorityConfig: Record<PendingApproval['priority'], { label: string; variant: 'default' | 'secondary' | 'destructive' | 'outline' }> = {
const priorityConfig: Record<
PendingApproval['priority'],
{ label: string; variant: 'default' | 'secondary' | 'destructive' | 'outline' }
> = {
low: { label: 'Low', variant: 'outline' },
medium: { label: 'Medium', variant: 'secondary' },
high: { label: 'High', variant: 'default' },
@@ -105,7 +108,12 @@ function ApprovalItem({ approval, onApprove, onReject }: ApprovalItemProps) {
return (
<div className="flex flex-col gap-4 rounded-lg border p-4 sm:flex-row sm:items-start">
<div className={cn('flex h-10 w-10 items-center justify-center rounded-full bg-muted', config.color)}>
<div
className={cn(
'flex h-10 w-10 items-center justify-center rounded-full bg-muted',
config.color
)}
>
<Icon className="h-5 w-5" />
</div>

View File

@@ -118,10 +118,7 @@ export function RecentProjects({ projects, isLoading = false, className }: Recen
{isLoading ? (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{[1, 2, 3, 4, 5, 6].map((i) => (
<div
key={i}
className={cn(i > 3 && 'hidden lg:block')}
>
<div key={i} className={cn(i > 3 && 'hidden lg:block')}>
<ProjectCardSkeleton />
</div>
))}
@@ -138,10 +135,7 @@ export function RecentProjects({ projects, isLoading = false, className }: Recen
) : (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{displayProjects.map((project, index) => (
<div
key={project.id}
className={cn(index >= 3 && 'hidden lg:block')}
>
<div key={project.id} className={cn(index >= 3 && 'hidden lg:block')}>
<ProjectCard project={project} />
</div>
))}

View File

@@ -49,10 +49,7 @@ function ComplexityIndicator({ complexity }: { complexity: 'low' | 'medium' | 'h
{[1, 2, 3].map((i) => (
<div
key={i}
className={cn(
'h-1.5 w-1.5 rounded-full',
i <= level ? 'bg-primary' : 'bg-muted'
)}
className={cn('h-1.5 w-1.5 rounded-full', i <= level ? 'bg-primary' : 'bg-muted')}
/>
))}
</div>
@@ -140,10 +137,7 @@ export function ProjectCard({ project, onClick, onAction, className }: ProjectCa
Archive Project
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive"
onClick={() => onAction('delete')}
>
<DropdownMenuItem className="text-destructive" onClick={() => onAction('delete')}>
Delete Project
</DropdownMenuItem>
</DropdownMenuContent>

View File

@@ -10,7 +10,7 @@
'use client';
import { useCallback, useMemo, useState } from 'react';
import { useRouter } from 'next/navigation';
import { useRouter } from '@/lib/i18n/routing';
import { AlertCircle, RefreshCw } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';

View File

@@ -14,14 +14,7 @@
'use client';
import { useState } from 'react';
import {
Search,
Filter,
LayoutGrid,
List,
ChevronDown,
X,
} from 'lucide-react';
import { Search, Filter, LayoutGrid, List, ChevronDown, X } from 'lucide-react';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';

View File

@@ -27,7 +27,10 @@ export interface ProjectsGridProps {
/** Called when a project card is clicked */
onProjectClick?: (project: ProjectListItem) => void;
/** Called when a project action is selected */
onProjectAction?: (project: ProjectListItem, action: 'archive' | 'pause' | 'resume' | 'delete') => void;
onProjectAction?: (
project: ProjectListItem,
action: 'archive' | 'pause' | 'resume' | 'delete'
) => void;
/** Whether filters are currently applied (affects empty state message) */
hasFilters?: boolean;
/** Additional CSS classes */
@@ -67,11 +70,7 @@ function EmptyState({ hasFilters }: { hasFilters: boolean }) {
function LoadingSkeleton({ viewMode }: { viewMode: ViewMode }) {
return (
<div
className={cn(
viewMode === 'grid'
? 'grid gap-4 sm:grid-cols-2 lg:grid-cols-3'
: 'space-y-4'
)}
className={cn(viewMode === 'grid' ? 'grid gap-4 sm:grid-cols-2 lg:grid-cols-3' : 'space-y-4')}
>
{[1, 2, 3, 4, 5, 6].map((i) => (
<ProjectCardSkeleton key={i} />
@@ -100,9 +99,7 @@ export function ProjectsGrid({
return (
<div
className={cn(
viewMode === 'grid'
? 'grid gap-4 sm:grid-cols-2 lg:grid-cols-3'
: 'space-y-4',
viewMode === 'grid' ? 'grid gap-4 sm:grid-cols-2 lg:grid-cols-3' : 'space-y-4',
className
)}
>

View File

@@ -51,5 +51,11 @@ export type {
} from './wizard';
export type { ProjectCardProps } from './ProjectCard';
export type { ProjectFiltersProps, ViewMode, SortBy, SortOrder, Complexity } from './ProjectFilters';
export type {
ProjectFiltersProps,
ViewMode,
SortBy,
SortOrder,
Complexity,
} from './ProjectFilters';
export type { ProjectsGridProps } from './ProjectsGrid';

View File

@@ -8,7 +8,7 @@
*/
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { useRouter } from '@/lib/i18n/routing';
import { ArrowLeft, ArrowRight, Check, CheckCircle2, Loader2 } from 'lucide-react';
import { useMutation } from '@tanstack/react-query';
@@ -49,11 +49,10 @@ interface ProjectResponse {
}
interface ProjectWizardProps {
locale: string;
className?: string;
}
export function ProjectWizard({ locale, className }: ProjectWizardProps) {
export function ProjectWizard({ className }: ProjectWizardProps) {
const router = useRouter();
const [isCreated, setIsCreated] = useState(false);
@@ -106,9 +105,9 @@ export function ProjectWizard({ locale, className }: ProjectWizardProps) {
const handleGoToProject = () => {
// Navigate to project dashboard - using slug from successful creation
if (createProjectMutation.data) {
router.push(`/${locale}/projects/${createProjectMutation.data.slug}`);
router.push(`/projects/${createProjectMutation.data.slug}`);
} else {
router.push(`/${locale}/projects`);
router.push(`/projects`);
}
};

View File

@@ -63,7 +63,8 @@ const mockProjects: ProjectListItem[] = [
{
id: 'proj-001',
name: 'E-Commerce Platform Redesign',
description: 'Complete redesign of the e-commerce platform with modern UI/UX and improved checkout flow',
description:
'Complete redesign of the e-commerce platform with modern UI/UX and improved checkout flow',
status: 'active',
complexity: 'high',
progress: 67,
@@ -78,7 +79,8 @@ const mockProjects: ProjectListItem[] = [
{
id: 'proj-002',
name: 'Mobile Banking App',
description: 'Native mobile app for banking services with biometric authentication and real-time notifications',
description:
'Native mobile app for banking services with biometric authentication and real-time notifications',
status: 'active',
complexity: 'high',
progress: 45,
@@ -93,7 +95,8 @@ const mockProjects: ProjectListItem[] = [
{
id: 'proj-003',
name: 'Internal HR Portal',
description: 'Employee self-service portal for HR operations including leave requests and performance reviews',
description:
'Employee self-service portal for HR operations including leave requests and performance reviews',
status: 'paused',
complexity: 'medium',
progress: 23,
@@ -108,7 +111,8 @@ const mockProjects: ProjectListItem[] = [
{
id: 'proj-004',
name: 'API Gateway Modernization',
description: 'Migrate legacy API gateway to cloud-native architecture with improved rate limiting and caching',
description:
'Migrate legacy API gateway to cloud-native architecture with improved rate limiting and caching',
status: 'active',
complexity: 'high',
progress: 82,
@@ -123,7 +127,8 @@ const mockProjects: ProjectListItem[] = [
{
id: 'proj-005',
name: 'Customer Analytics Dashboard',
description: 'Real-time analytics dashboard for customer behavior insights with ML-powered predictions',
description:
'Real-time analytics dashboard for customer behavior insights with ML-powered predictions',
status: 'completed',
complexity: 'medium',
progress: 100,

View File

@@ -55,6 +55,15 @@ jest.mock('@/lib/api/hooks/useAuth', () => ({
})),
}));
// Mock AuthContext - Home page uses useAuth to check if user is authenticated
jest.mock('@/lib/auth/AuthContext', () => ({
useAuth: jest.fn(() => ({
isAuthenticated: false,
isLoading: false,
user: null,
})),
}));
// Mock Theme components
jest.mock('@/components/theme', () => ({
ThemeToggle: () => <div data-testid="theme-toggle">Theme Toggle</div>,

View File

@@ -244,7 +244,9 @@ describe('AgentTypeForm', () => {
await user.click(screen.getByRole('tab', { name: /model/i }));
expect(screen.getByText('Model Selection')).toBeInTheDocument();
expect(screen.getByText('Choose the AI models that power this agent type')).toBeInTheDocument();
expect(
screen.getByText('Choose the AI models that power this agent type')
).toBeInTheDocument();
expect(screen.getByLabelText(/primary model/i)).toBeInTheDocument();
expect(screen.getByLabelText(/fallover model/i)).toBeInTheDocument();
});
@@ -496,7 +498,9 @@ describe('AgentTypeForm', () => {
const user = userEvent.setup();
render(<AgentTypeForm {...defaultProps} />);
const expertiseInput = screen.getByPlaceholderText(/e.g., system design/i) as HTMLInputElement;
const expertiseInput = screen.getByPlaceholderText(
/e.g., system design/i
) as HTMLInputElement;
await user.type(expertiseInput, 'new skill');
await user.click(screen.getByRole('button', { name: /^add$/i }));
@@ -545,14 +549,14 @@ describe('AgentTypeForm', () => {
});
});
describe('Null Model Params Handling', () => {
it('handles null model_params gracefully', () => {
const agentTypeWithNullParams: AgentTypeResponse = {
describe('Empty Model Params Handling', () => {
it('handles empty model_params gracefully', () => {
const agentTypeWithEmptyParams: AgentTypeResponse = {
...mockAgentType,
model_params: null,
model_params: {},
};
render(<AgentTypeForm {...defaultProps} agentType={agentTypeWithNullParams} />);
render(<AgentTypeForm {...defaultProps} agentType={agentTypeWithEmptyParams} />);
// Should render without errors
expect(screen.getByText('Edit Agent Type')).toBeInTheDocument();

View File

@@ -248,7 +248,7 @@ describe('ErrorBoundary', () => {
describe('error without message', () => {
it('handles error with null message gracefully', () => {
function ThrowNullError() {
function ThrowNullError(): never {
const error = new Error();
error.message = '';
throw error;
@@ -338,7 +338,7 @@ describe('ErrorBoundary', () => {
describe('edge cases', () => {
it('handles deeply nested errors', () => {
function DeepChild() {
function DeepChild(): never {
throw new Error('Deep error');
}
@@ -377,7 +377,7 @@ describe('ErrorBoundary', () => {
});
it('allows nested error boundaries', () => {
function InnerThrowing() {
function InnerThrowing(): never {
throw new Error('Inner error');
}

View File

@@ -113,7 +113,9 @@ describe('RecentProjects', () => {
});
it('applies custom className', () => {
const { container } = render(<RecentProjects projects={mockProjects} className="custom-class" />);
const { container } = render(
<RecentProjects projects={mockProjects} className="custom-class" />
);
expect(container.firstChild).toHaveClass('custom-class');
});

View File

@@ -27,7 +27,14 @@ describe('WelcomeHeader', () => {
it('displays greeting with user first name', () => {
mockUseAuth.mockReturnValue({
user: { id: '1', email: 'john@example.com', first_name: 'John', is_active: true, is_superuser: false, created_at: '' },
user: {
id: '1',
email: 'john@example.com',
first_name: 'John',
is_active: true,
is_superuser: false,
created_at: '',
},
isAuthenticated: true,
isLoading: false,
error: null,
@@ -44,7 +51,14 @@ describe('WelcomeHeader', () => {
it('falls back to email prefix when first_name is empty', () => {
mockUseAuth.mockReturnValue({
user: { id: '1', email: 'jane@example.com', first_name: '', is_active: true, is_superuser: false, created_at: '' },
user: {
id: '1',
email: 'jane@example.com',
first_name: '',
is_active: true,
is_superuser: false,
created_at: '',
},
isAuthenticated: true,
isLoading: false,
error: null,
@@ -78,7 +92,14 @@ describe('WelcomeHeader', () => {
it('displays subtitle text', () => {
mockUseAuth.mockReturnValue({
user: { id: '1', email: 'test@example.com', first_name: 'Test', is_active: true, is_superuser: false, created_at: '' },
user: {
id: '1',
email: 'test@example.com',
first_name: 'Test',
is_active: true,
is_superuser: false,
created_at: '',
},
isAuthenticated: true,
isLoading: false,
error: null,
@@ -95,7 +116,14 @@ describe('WelcomeHeader', () => {
it('displays Create Project button', () => {
mockUseAuth.mockReturnValue({
user: { id: '1', email: 'test@example.com', first_name: 'Test', is_active: true, is_superuser: false, created_at: '' },
user: {
id: '1',
email: 'test@example.com',
first_name: 'Test',
is_active: true,
is_superuser: false,
created_at: '',
},
isAuthenticated: true,
isLoading: false,
error: null,

View File

@@ -124,7 +124,7 @@ describe('Header', () => {
render(<Header />);
const homeLink = screen.getByRole('link', { name: /home/i });
expect(homeLink).toHaveAttribute('href', '/');
expect(homeLink).toHaveAttribute('href', '/dashboard');
});
it('renders admin link for superusers', () => {

View File

@@ -78,7 +78,7 @@ describe('ProjectCard', () => {
// Menu button should exist with sr-only text
const menuButtons = screen.getAllByRole('button');
const menuButton = menuButtons.find(btn => btn.querySelector('.sr-only'));
const menuButton = menuButtons.find((btn) => btn.querySelector('.sr-only'));
expect(menuButton).toBeDefined();
expect(menuButton!.querySelector('.sr-only')).toHaveTextContent('Project actions');
});

View File

@@ -92,18 +92,8 @@ jest.mock('@/lib/hooks/useProjectEvents', () => ({
useProjectEvents: jest.fn(() => mockUseProjectEventsResult),
}));
// Mock next/navigation
const mockPush = jest.fn();
jest.mock('next/navigation', () => ({
useRouter: () => ({
push: mockPush,
back: jest.fn(),
forward: jest.fn(),
refresh: jest.fn(),
replace: jest.fn(),
prefetch: jest.fn(),
}),
}));
// Import mock from next-intl/navigation mock (used by @/lib/i18n/routing)
import { mockPush } from 'next-intl/navigation';
describe('ProjectDashboard', () => {
const projectId = 'test-project-123';

View File

@@ -98,9 +98,7 @@ describe('ProjectsGrid', () => {
});
it('applies custom className', () => {
const { container } = render(
<ProjectsGrid projects={mockProjects} className="custom-class" />
);
const { container } = render(<ProjectsGrid projects={mockProjects} className="custom-class" />);
expect(container.firstChild).toHaveClass('custom-class');
});

View File

@@ -77,13 +77,8 @@ jest.mock('@/components/projects/wizard/useWizardState', () => ({
})),
}));
// Mock router
const mockPush = jest.fn();
jest.mock('next/navigation', () => ({
useRouter: () => ({
push: mockPush,
}),
}));
// Import mock from next-intl/navigation mock (used by @/lib/i18n/routing)
import { mockPush } from 'next-intl/navigation';
// Mock API client
const mockPost = jest.fn();
@@ -131,36 +126,36 @@ describe('ProjectWizard', () => {
describe('Rendering', () => {
it('renders the step indicator', () => {
render(<ProjectWizard locale="en" />, { wrapper: createWrapper() });
render(<ProjectWizard />, { wrapper: createWrapper() });
expect(screen.getByTestId('step-indicator')).toBeInTheDocument();
});
it('renders BasicInfoStep on step 1', () => {
mockWizardState.step = 1;
render(<ProjectWizard locale="en" />, { wrapper: createWrapper() });
render(<ProjectWizard />, { wrapper: createWrapper() });
expect(screen.getByTestId('basic-info-step')).toBeInTheDocument();
});
it('renders ComplexityStep on step 2', () => {
mockWizardState.step = 2;
render(<ProjectWizard locale="en" />, { wrapper: createWrapper() });
render(<ProjectWizard />, { wrapper: createWrapper() });
expect(screen.getByTestId('complexity-step')).toBeInTheDocument();
});
it('renders AgentChatStep on step 5', () => {
mockWizardState.step = 5;
render(<ProjectWizard locale="en" />, { wrapper: createWrapper() });
render(<ProjectWizard />, { wrapper: createWrapper() });
expect(screen.getByTestId('agent-chat-step')).toBeInTheDocument();
});
it('renders ReviewStep on step 6', () => {
mockWizardState.step = 6;
render(<ProjectWizard locale="en" />, { wrapper: createWrapper() });
render(<ProjectWizard />, { wrapper: createWrapper() });
expect(screen.getByTestId('review-step')).toBeInTheDocument();
});
it('applies custom className', () => {
const { container } = render(<ProjectWizard locale="en" className="custom-class" />, {
const { container } = render(<ProjectWizard className="custom-class" />, {
wrapper: createWrapper(),
});
expect(container.firstChild).toHaveClass('custom-class');
@@ -171,7 +166,7 @@ describe('ProjectWizard', () => {
it('calls goNext when Next button is clicked', async () => {
const user = userEvent.setup();
mockWizardState.step = 1;
render(<ProjectWizard locale="en" />, { wrapper: createWrapper() });
render(<ProjectWizard />, { wrapper: createWrapper() });
await user.click(screen.getByRole('button', { name: /next/i }));
expect(mockGoNext).toHaveBeenCalled();
@@ -180,7 +175,7 @@ describe('ProjectWizard', () => {
it('calls goBack when Back button is clicked', async () => {
const user = userEvent.setup();
mockWizardState.step = 2;
render(<ProjectWizard locale="en" />, { wrapper: createWrapper() });
render(<ProjectWizard />, { wrapper: createWrapper() });
await user.click(screen.getByRole('button', { name: /back/i }));
expect(mockGoBack).toHaveBeenCalled();
@@ -188,21 +183,21 @@ describe('ProjectWizard', () => {
it('hides Back button on step 1', () => {
mockWizardState.step = 1;
render(<ProjectWizard locale="en" />, { wrapper: createWrapper() });
render(<ProjectWizard />, { wrapper: createWrapper() });
const backButton = screen.getByRole('button', { name: /back/i });
expect(backButton).toHaveClass('invisible');
});
it('shows Back button visible on step 2', () => {
mockWizardState.step = 2;
render(<ProjectWizard locale="en" />, { wrapper: createWrapper() });
render(<ProjectWizard />, { wrapper: createWrapper() });
const backButton = screen.getByRole('button', { name: /back/i });
expect(backButton).not.toHaveClass('invisible');
});
it('shows Create Project button on review step', () => {
mockWizardState.step = 6;
render(<ProjectWizard locale="en" />, { wrapper: createWrapper() });
render(<ProjectWizard />, { wrapper: createWrapper() });
expect(screen.getByRole('button', { name: /create project/i })).toBeInTheDocument();
});
});
@@ -211,7 +206,7 @@ describe('ProjectWizard', () => {
it('skips client mode step in script mode', () => {
mockWizardState.step = 3;
mockWizardState.complexity = 'script';
render(<ProjectWizard locale="en" />, { wrapper: createWrapper() });
render(<ProjectWizard />, { wrapper: createWrapper() });
// ClientModeStep should not render for script mode
expect(screen.queryByTestId('client-mode-step')).not.toBeInTheDocument();
});
@@ -219,14 +214,14 @@ describe('ProjectWizard', () => {
it('skips autonomy step in script mode', () => {
mockWizardState.step = 4;
mockWizardState.complexity = 'script';
render(<ProjectWizard locale="en" />, { wrapper: createWrapper() });
render(<ProjectWizard />, { wrapper: createWrapper() });
// AutonomyStep should not render for script mode
expect(screen.queryByTestId('autonomy-step')).not.toBeInTheDocument();
});
it('shows script mode indicator', () => {
mockWizardState.complexity = 'script';
render(<ProjectWizard locale="en" />, { wrapper: createWrapper() });
render(<ProjectWizard />, { wrapper: createWrapper() });
expect(screen.getByText(/script mode/i)).toBeInTheDocument();
});
});
@@ -235,7 +230,7 @@ describe('ProjectWizard', () => {
it('shows success screen after creation', async () => {
const user = userEvent.setup();
mockWizardState.step = 6;
render(<ProjectWizard locale="en" />, { wrapper: createWrapper() });
render(<ProjectWizard />, { wrapper: createWrapper() });
await user.click(screen.getByRole('button', { name: /create project/i }));
@@ -247,7 +242,7 @@ describe('ProjectWizard', () => {
it('displays project name in success message', async () => {
const user = userEvent.setup();
mockWizardState.step = 6;
render(<ProjectWizard locale="en" />, { wrapper: createWrapper() });
render(<ProjectWizard />, { wrapper: createWrapper() });
await user.click(screen.getByRole('button', { name: /create project/i }));
@@ -259,7 +254,7 @@ describe('ProjectWizard', () => {
it('navigates to project dashboard on success', async () => {
const user = userEvent.setup();
mockWizardState.step = 6;
render(<ProjectWizard locale="en" />, { wrapper: createWrapper() });
render(<ProjectWizard />, { wrapper: createWrapper() });
await user.click(screen.getByRole('button', { name: /create project/i }));
@@ -270,13 +265,14 @@ describe('ProjectWizard', () => {
});
await user.click(screen.getByRole('button', { name: /go to project dashboard/i }));
expect(mockPush).toHaveBeenCalledWith('/en/projects/test-project');
// Locale-aware router adds locale prefix automatically
expect(mockPush).toHaveBeenCalledWith('/projects/test-project');
});
it('allows creating another project', async () => {
const user = userEvent.setup();
mockWizardState.step = 6;
render(<ProjectWizard locale="en" />, { wrapper: createWrapper() });
render(<ProjectWizard />, { wrapper: createWrapper() });
await user.click(screen.getByRole('button', { name: /create project/i }));
@@ -294,7 +290,7 @@ describe('ProjectWizard', () => {
mockPost.mockRejectedValue(new Error('Network error'));
const user = userEvent.setup();
mockWizardState.step = 6;
render(<ProjectWizard locale="en" />, { wrapper: createWrapper() });
render(<ProjectWizard />, { wrapper: createWrapper() });
await user.click(screen.getByRole('button', { name: /create project/i }));
@@ -307,13 +303,13 @@ describe('ProjectWizard', () => {
describe('Button States', () => {
it('disables Next button when cannot proceed', () => {
mockWizardState.projectName = '';
render(<ProjectWizard locale="en" />, { wrapper: createWrapper() });
render(<ProjectWizard />, { wrapper: createWrapper() });
expect(screen.getByRole('button', { name: /next/i })).toBeDisabled();
});
it('enables Next button when can proceed', () => {
mockWizardState.projectName = 'Valid Name';
render(<ProjectWizard locale="en" />, { wrapper: createWrapper() });
render(<ProjectWizard />, { wrapper: createWrapper() });
expect(screen.getByRole('button', { name: /next/i })).not.toBeDisabled();
});
@@ -323,7 +319,7 @@ describe('ProjectWizard', () => {
);
const user = userEvent.setup();
mockWizardState.step = 6;
render(<ProjectWizard locale="en" />, { wrapper: createWrapper() });
render(<ProjectWizard />, { wrapper: createWrapper() });
await user.click(screen.getByRole('button', { name: /create project/i }));
expect(screen.getByText(/creating/i)).toBeInTheDocument();