karaoke-gen 0.96.0__py3-none-any.whl → 0.101.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- backend/api/routes/admin.py +696 -92
- backend/api/routes/audio_search.py +29 -8
- backend/api/routes/file_upload.py +99 -22
- backend/api/routes/health.py +65 -0
- backend/api/routes/internal.py +6 -0
- backend/api/routes/jobs.py +28 -1
- backend/api/routes/review.py +13 -6
- backend/api/routes/tenant.py +120 -0
- backend/api/routes/users.py +472 -51
- backend/main.py +31 -2
- backend/middleware/__init__.py +7 -1
- backend/middleware/tenant.py +192 -0
- backend/models/job.py +19 -3
- backend/models/tenant.py +208 -0
- backend/models/user.py +18 -0
- backend/services/email_service.py +253 -6
- backend/services/encoding_service.py +128 -31
- backend/services/firestore_service.py +6 -0
- backend/services/job_manager.py +44 -2
- backend/services/langfuse_preloader.py +98 -0
- backend/services/nltk_preloader.py +122 -0
- backend/services/spacy_preloader.py +65 -0
- backend/services/stripe_service.py +133 -11
- backend/services/tenant_service.py +285 -0
- backend/services/user_service.py +85 -7
- backend/tests/emulator/conftest.py +22 -1
- backend/tests/emulator/test_made_for_you_integration.py +167 -0
- backend/tests/test_admin_job_files.py +337 -0
- backend/tests/test_admin_job_reset.py +384 -0
- backend/tests/test_admin_job_update.py +326 -0
- backend/tests/test_email_service.py +233 -0
- backend/tests/test_impersonation.py +223 -0
- backend/tests/test_job_creation_regression.py +4 -0
- backend/tests/test_job_manager.py +171 -9
- backend/tests/test_jobs_api.py +11 -1
- backend/tests/test_made_for_you.py +2086 -0
- backend/tests/test_models.py +139 -0
- backend/tests/test_spacy_preloader.py +119 -0
- backend/tests/test_tenant_api.py +350 -0
- backend/tests/test_tenant_middleware.py +345 -0
- backend/tests/test_tenant_models.py +406 -0
- backend/tests/test_tenant_service.py +418 -0
- backend/utils/test_data.py +27 -0
- backend/workers/screens_worker.py +16 -6
- backend/workers/video_worker.py +8 -3
- {karaoke_gen-0.96.0.dist-info → karaoke_gen-0.101.0.dist-info}/METADATA +1 -1
- {karaoke_gen-0.96.0.dist-info → karaoke_gen-0.101.0.dist-info}/RECORD +58 -39
- lyrics_transcriber/correction/agentic/agent.py +17 -6
- lyrics_transcriber/correction/agentic/providers/langchain_bridge.py +96 -43
- lyrics_transcriber/correction/agentic/providers/model_factory.py +27 -6
- lyrics_transcriber/correction/anchor_sequence.py +151 -37
- lyrics_transcriber/correction/handlers/syllables_match.py +44 -2
- lyrics_transcriber/correction/phrase_analyzer.py +18 -0
- lyrics_transcriber/frontend/src/api.ts +13 -5
- lyrics_transcriber/frontend/src/components/PreviewVideoSection.tsx +90 -57
- {karaoke_gen-0.96.0.dist-info → karaoke_gen-0.101.0.dist-info}/WHEEL +0 -0
- {karaoke_gen-0.96.0.dist-info → karaoke_gen-0.101.0.dist-info}/entry_points.txt +0 -0
- {karaoke_gen-0.96.0.dist-info → karaoke_gen-0.101.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tenant service for managing white-label B2B portal configurations.
|
|
3
|
+
|
|
4
|
+
Tenant configs are stored in GCS at tenants/{tenant_id}/config.json.
|
|
5
|
+
The service provides:
|
|
6
|
+
- Tenant config loading with caching
|
|
7
|
+
- Subdomain to tenant ID resolution
|
|
8
|
+
- Tenant validation
|
|
9
|
+
|
|
10
|
+
Similar to ThemeService, configs are cached for 5 minutes to reduce GCS reads.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import logging
|
|
14
|
+
import threading
|
|
15
|
+
from datetime import datetime
|
|
16
|
+
from typing import Dict, Optional
|
|
17
|
+
|
|
18
|
+
from backend.models.tenant import TenantConfig, TenantPublicConfig
|
|
19
|
+
from backend.services.storage_service import StorageService
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
# GCS paths for tenant configs
|
|
24
|
+
TENANTS_PREFIX = "tenants"
|
|
25
|
+
|
|
26
|
+
# Default sender email (shared with EmailService for consistency)
|
|
27
|
+
DEFAULT_SENDER_EMAIL = "gen@nomadkaraoke.com"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class TenantService:
|
|
31
|
+
"""Service for managing tenant configurations from GCS."""
|
|
32
|
+
|
|
33
|
+
def __init__(self, storage: Optional[StorageService] = None):
|
|
34
|
+
"""
|
|
35
|
+
Initialize the tenant service.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
storage: StorageService instance (creates new one if not provided)
|
|
39
|
+
"""
|
|
40
|
+
self.storage = storage or StorageService()
|
|
41
|
+
self._config_cache: Dict[str, TenantConfig] = {}
|
|
42
|
+
self._cache_times: Dict[str, datetime] = {}
|
|
43
|
+
self._subdomain_map: Dict[str, str] = {} # subdomain -> tenant_id
|
|
44
|
+
self._subdomain_map_time: Optional[datetime] = None
|
|
45
|
+
self.CACHE_TTL_SECONDS = 300 # 5 minute cache
|
|
46
|
+
|
|
47
|
+
def _get_config_path(self, tenant_id: str) -> str:
|
|
48
|
+
"""Get the GCS path for a tenant's config file."""
|
|
49
|
+
return f"{TENANTS_PREFIX}/{tenant_id}/config.json"
|
|
50
|
+
|
|
51
|
+
def _is_cache_valid(self, tenant_id: str) -> bool:
|
|
52
|
+
"""Check if the config cache for a tenant is still valid."""
|
|
53
|
+
if tenant_id not in self._config_cache or tenant_id not in self._cache_times:
|
|
54
|
+
return False
|
|
55
|
+
age = datetime.now() - self._cache_times[tenant_id]
|
|
56
|
+
return age.total_seconds() < self.CACHE_TTL_SECONDS
|
|
57
|
+
|
|
58
|
+
def _is_subdomain_map_valid(self) -> bool:
|
|
59
|
+
"""Check if the subdomain map cache is still valid."""
|
|
60
|
+
if not self._subdomain_map or self._subdomain_map_time is None:
|
|
61
|
+
return False
|
|
62
|
+
age = datetime.now() - self._subdomain_map_time
|
|
63
|
+
return age.total_seconds() < self.CACHE_TTL_SECONDS
|
|
64
|
+
|
|
65
|
+
def get_tenant_config(
|
|
66
|
+
self, tenant_id: str, force_refresh: bool = False
|
|
67
|
+
) -> Optional[TenantConfig]:
|
|
68
|
+
"""
|
|
69
|
+
Load tenant configuration from GCS with caching.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
tenant_id: The tenant identifier
|
|
73
|
+
force_refresh: Force reload from GCS even if cache is valid
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
TenantConfig if found, None otherwise
|
|
77
|
+
"""
|
|
78
|
+
if not force_refresh and self._is_cache_valid(tenant_id):
|
|
79
|
+
return self._config_cache[tenant_id]
|
|
80
|
+
|
|
81
|
+
try:
|
|
82
|
+
config_path = self._get_config_path(tenant_id)
|
|
83
|
+
if not self.storage.file_exists(config_path):
|
|
84
|
+
logger.debug(f"Tenant config not found: {tenant_id}")
|
|
85
|
+
return None
|
|
86
|
+
|
|
87
|
+
data = self.storage.download_json(config_path)
|
|
88
|
+
config = TenantConfig(**data)
|
|
89
|
+
|
|
90
|
+
# Update cache
|
|
91
|
+
self._config_cache[tenant_id] = config
|
|
92
|
+
self._cache_times[tenant_id] = datetime.now()
|
|
93
|
+
|
|
94
|
+
# Update subdomain map and its timestamp
|
|
95
|
+
self._subdomain_map[config.subdomain.lower()] = tenant_id
|
|
96
|
+
self._subdomain_map_time = datetime.now()
|
|
97
|
+
|
|
98
|
+
logger.info(f"Loaded tenant config: {tenant_id}")
|
|
99
|
+
return config
|
|
100
|
+
except Exception as e:
|
|
101
|
+
logger.error(f"Failed to load tenant config {tenant_id}: {e}")
|
|
102
|
+
return None
|
|
103
|
+
|
|
104
|
+
def get_tenant_by_subdomain(self, subdomain: str) -> Optional[TenantConfig]:
|
|
105
|
+
"""
|
|
106
|
+
Get tenant config by subdomain.
|
|
107
|
+
|
|
108
|
+
Args:
|
|
109
|
+
subdomain: Full subdomain (e.g., 'vocalstar.nomadkaraoke.com')
|
|
110
|
+
|
|
111
|
+
Returns:
|
|
112
|
+
TenantConfig if found, None otherwise
|
|
113
|
+
"""
|
|
114
|
+
subdomain_lower = subdomain.lower()
|
|
115
|
+
|
|
116
|
+
# Check cache first
|
|
117
|
+
if subdomain_lower in self._subdomain_map:
|
|
118
|
+
tenant_id = self._subdomain_map[subdomain_lower]
|
|
119
|
+
return self.get_tenant_config(tenant_id)
|
|
120
|
+
|
|
121
|
+
# Try to find tenant by listing all configs
|
|
122
|
+
# This is a fallback for when we don't have the subdomain cached
|
|
123
|
+
tenant_id = self._resolve_subdomain_to_tenant_id(subdomain_lower)
|
|
124
|
+
if tenant_id:
|
|
125
|
+
return self.get_tenant_config(tenant_id)
|
|
126
|
+
|
|
127
|
+
return None
|
|
128
|
+
|
|
129
|
+
def _resolve_subdomain_to_tenant_id(self, subdomain: str) -> Optional[str]:
|
|
130
|
+
"""
|
|
131
|
+
Resolve a subdomain to tenant ID by checking GCS.
|
|
132
|
+
|
|
133
|
+
This extracts the tenant ID from the subdomain pattern:
|
|
134
|
+
- 'vocalstar.nomadkaraoke.com' -> 'vocalstar'
|
|
135
|
+
- 'vocalstar.gen.nomadkaraoke.com' -> 'vocalstar'
|
|
136
|
+
"""
|
|
137
|
+
# Extract potential tenant ID from subdomain
|
|
138
|
+
# Pattern: {tenant}.nomadkaraoke.com or {tenant}.gen.nomadkaraoke.com
|
|
139
|
+
parts = subdomain.split(".")
|
|
140
|
+
|
|
141
|
+
if len(parts) >= 3:
|
|
142
|
+
# First part is likely the tenant ID
|
|
143
|
+
potential_tenant_id = parts[0]
|
|
144
|
+
|
|
145
|
+
# Check if config exists
|
|
146
|
+
config_path = self._get_config_path(potential_tenant_id)
|
|
147
|
+
if self.storage.file_exists(config_path):
|
|
148
|
+
return potential_tenant_id
|
|
149
|
+
|
|
150
|
+
return None
|
|
151
|
+
|
|
152
|
+
def get_public_config(self, tenant_id: str) -> Optional[TenantPublicConfig]:
|
|
153
|
+
"""
|
|
154
|
+
Get the public (frontend-safe) tenant configuration.
|
|
155
|
+
|
|
156
|
+
Args:
|
|
157
|
+
tenant_id: The tenant identifier
|
|
158
|
+
|
|
159
|
+
Returns:
|
|
160
|
+
TenantPublicConfig if found, None otherwise
|
|
161
|
+
"""
|
|
162
|
+
config = self.get_tenant_config(tenant_id)
|
|
163
|
+
if config:
|
|
164
|
+
return TenantPublicConfig.from_config(config)
|
|
165
|
+
return None
|
|
166
|
+
|
|
167
|
+
def get_public_config_by_subdomain(
|
|
168
|
+
self, subdomain: str
|
|
169
|
+
) -> Optional[TenantPublicConfig]:
|
|
170
|
+
"""
|
|
171
|
+
Get the public tenant configuration by subdomain.
|
|
172
|
+
|
|
173
|
+
Args:
|
|
174
|
+
subdomain: Full subdomain
|
|
175
|
+
|
|
176
|
+
Returns:
|
|
177
|
+
TenantPublicConfig if found, None otherwise
|
|
178
|
+
"""
|
|
179
|
+
config = self.get_tenant_by_subdomain(subdomain)
|
|
180
|
+
if config:
|
|
181
|
+
return TenantPublicConfig.from_config(config)
|
|
182
|
+
return None
|
|
183
|
+
|
|
184
|
+
def tenant_exists(self, tenant_id: str) -> bool:
|
|
185
|
+
"""
|
|
186
|
+
Check if a tenant exists.
|
|
187
|
+
|
|
188
|
+
Args:
|
|
189
|
+
tenant_id: The tenant identifier
|
|
190
|
+
|
|
191
|
+
Returns:
|
|
192
|
+
True if tenant config exists, False otherwise
|
|
193
|
+
"""
|
|
194
|
+
config_path = self._get_config_path(tenant_id)
|
|
195
|
+
return self.storage.file_exists(config_path)
|
|
196
|
+
|
|
197
|
+
def is_email_allowed_for_tenant(self, tenant_id: str, email: str) -> bool:
|
|
198
|
+
"""
|
|
199
|
+
Check if an email is allowed for a specific tenant.
|
|
200
|
+
|
|
201
|
+
Args:
|
|
202
|
+
tenant_id: The tenant identifier
|
|
203
|
+
email: Email address to check
|
|
204
|
+
|
|
205
|
+
Returns:
|
|
206
|
+
True if email is allowed, False otherwise
|
|
207
|
+
"""
|
|
208
|
+
config = self.get_tenant_config(tenant_id)
|
|
209
|
+
if not config:
|
|
210
|
+
return False
|
|
211
|
+
return config.is_email_allowed(email)
|
|
212
|
+
|
|
213
|
+
def get_tenant_sender_email(self, tenant_id: str) -> str:
|
|
214
|
+
"""
|
|
215
|
+
Get the email sender address for a tenant.
|
|
216
|
+
|
|
217
|
+
Args:
|
|
218
|
+
tenant_id: The tenant identifier
|
|
219
|
+
|
|
220
|
+
Returns:
|
|
221
|
+
Sender email address
|
|
222
|
+
"""
|
|
223
|
+
config = self.get_tenant_config(tenant_id)
|
|
224
|
+
if config:
|
|
225
|
+
return config.get_sender_email()
|
|
226
|
+
# Default fallback (consistent with EmailService)
|
|
227
|
+
return DEFAULT_SENDER_EMAIL
|
|
228
|
+
|
|
229
|
+
def invalidate_cache(self, tenant_id: Optional[str] = None) -> None:
|
|
230
|
+
"""
|
|
231
|
+
Invalidate tenant config cache.
|
|
232
|
+
|
|
233
|
+
Args:
|
|
234
|
+
tenant_id: Specific tenant to invalidate, or None for all
|
|
235
|
+
"""
|
|
236
|
+
if tenant_id:
|
|
237
|
+
self._config_cache.pop(tenant_id, None)
|
|
238
|
+
self._cache_times.pop(tenant_id, None)
|
|
239
|
+
# Also remove any subdomain map entries pointing to this tenant
|
|
240
|
+
subdomains_to_remove = [
|
|
241
|
+
subdomain
|
|
242
|
+
for subdomain, tid in self._subdomain_map.items()
|
|
243
|
+
if tid == tenant_id
|
|
244
|
+
]
|
|
245
|
+
for subdomain in subdomains_to_remove:
|
|
246
|
+
self._subdomain_map.pop(subdomain, None)
|
|
247
|
+
logger.info(f"Tenant config cache invalidated: {tenant_id}")
|
|
248
|
+
else:
|
|
249
|
+
self._config_cache.clear()
|
|
250
|
+
self._cache_times.clear()
|
|
251
|
+
self._subdomain_map.clear()
|
|
252
|
+
self._subdomain_map_time = None
|
|
253
|
+
logger.info("All tenant config caches invalidated")
|
|
254
|
+
|
|
255
|
+
def get_asset_url(self, tenant_id: str, asset_name: str) -> Optional[str]:
|
|
256
|
+
"""
|
|
257
|
+
Get a signed URL for a tenant asset (logo, favicon, etc.).
|
|
258
|
+
|
|
259
|
+
Args:
|
|
260
|
+
tenant_id: The tenant identifier
|
|
261
|
+
asset_name: Asset filename (e.g., 'logo.png')
|
|
262
|
+
|
|
263
|
+
Returns:
|
|
264
|
+
Signed URL if asset exists, None otherwise
|
|
265
|
+
"""
|
|
266
|
+
asset_path = f"{TENANTS_PREFIX}/{tenant_id}/{asset_name}"
|
|
267
|
+
if self.storage.file_exists(asset_path):
|
|
268
|
+
return self.storage.generate_signed_url(asset_path, expiration_minutes=60)
|
|
269
|
+
return None
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
# Singleton instance with thread-safe initialization
|
|
273
|
+
_tenant_service: Optional[TenantService] = None
|
|
274
|
+
_tenant_service_lock = threading.Lock()
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def get_tenant_service() -> TenantService:
|
|
278
|
+
"""Get or create the singleton TenantService instance (thread-safe)."""
|
|
279
|
+
global _tenant_service
|
|
280
|
+
if _tenant_service is None:
|
|
281
|
+
with _tenant_service_lock:
|
|
282
|
+
# Double-check after acquiring lock
|
|
283
|
+
if _tenant_service is None:
|
|
284
|
+
_tenant_service = TenantService()
|
|
285
|
+
return _tenant_service
|
backend/services/user_service.py
CHANGED
|
@@ -73,16 +73,26 @@ class UserService:
|
|
|
73
73
|
logger.exception(f"Error getting user {email}")
|
|
74
74
|
return None
|
|
75
75
|
|
|
76
|
-
def get_or_create_user(self, email: str) -> User:
|
|
76
|
+
def get_or_create_user(self, email: str, tenant_id: Optional[str] = None) -> User:
|
|
77
77
|
"""
|
|
78
78
|
Get existing user or create a new one.
|
|
79
79
|
|
|
80
80
|
New users receive a welcome credit to try the service.
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
email: User's email address
|
|
84
|
+
tenant_id: Tenant ID for white-label portals (None = default Nomad Karaoke)
|
|
85
|
+
|
|
86
|
+
Note: If user exists but has a different tenant_id, the existing user is returned.
|
|
87
|
+
Users are uniquely identified by email, not email+tenant.
|
|
81
88
|
"""
|
|
82
89
|
email = email.lower()
|
|
83
90
|
user = self.get_user(email)
|
|
84
91
|
|
|
85
92
|
if user:
|
|
93
|
+
# If user exists but tenant_id differs, we still return the user
|
|
94
|
+
# This allows users to access multiple tenants with one account
|
|
95
|
+
# (though features may be restricted per-tenant)
|
|
86
96
|
return user
|
|
87
97
|
|
|
88
98
|
# Create new user with welcome credit
|
|
@@ -97,9 +107,10 @@ class UserService:
|
|
|
97
107
|
email=email,
|
|
98
108
|
credits=welcome_credit,
|
|
99
109
|
credit_transactions=[welcome_transaction],
|
|
110
|
+
tenant_id=tenant_id, # Associate with tenant on creation
|
|
100
111
|
)
|
|
101
112
|
self._save_user(user)
|
|
102
|
-
logger.info(f"Created new user: {email} with {welcome_credit} welcome credit(s)")
|
|
113
|
+
logger.info(f"Created new user: {email} with {welcome_credit} welcome credit(s) (tenant: {tenant_id or 'default'})")
|
|
103
114
|
return user
|
|
104
115
|
|
|
105
116
|
def _save_user(self, user: User) -> None:
|
|
@@ -153,6 +164,7 @@ class UserService:
|
|
|
153
164
|
display_name=user.display_name,
|
|
154
165
|
total_jobs_created=user.total_jobs_created,
|
|
155
166
|
total_jobs_completed=user.total_jobs_completed,
|
|
167
|
+
tenant_id=user.tenant_id,
|
|
156
168
|
)
|
|
157
169
|
|
|
158
170
|
# =========================================================================
|
|
@@ -163,18 +175,25 @@ class UserService:
|
|
|
163
175
|
self,
|
|
164
176
|
email: str,
|
|
165
177
|
ip_address: Optional[str] = None,
|
|
166
|
-
user_agent: Optional[str] = None
|
|
178
|
+
user_agent: Optional[str] = None,
|
|
179
|
+
tenant_id: Optional[str] = None
|
|
167
180
|
) -> MagicLinkToken:
|
|
168
181
|
"""
|
|
169
182
|
Create a magic link token for email authentication.
|
|
170
183
|
|
|
171
184
|
Returns the token object. The actual sending of the email
|
|
172
185
|
should be handled by the caller (or an email service).
|
|
186
|
+
|
|
187
|
+
Args:
|
|
188
|
+
email: User's email address
|
|
189
|
+
ip_address: Client IP for auditing
|
|
190
|
+
user_agent: Client user agent for auditing
|
|
191
|
+
tenant_id: Tenant ID for white-label portals (None = default Nomad Karaoke)
|
|
173
192
|
"""
|
|
174
193
|
email = email.lower()
|
|
175
194
|
|
|
176
|
-
# Ensure user exists
|
|
177
|
-
self.get_or_create_user(email)
|
|
195
|
+
# Ensure user exists (with tenant association)
|
|
196
|
+
self.get_or_create_user(email, tenant_id=tenant_id)
|
|
178
197
|
|
|
179
198
|
# Generate secure token
|
|
180
199
|
token = secrets.token_urlsafe(32)
|
|
@@ -185,6 +204,7 @@ class UserService:
|
|
|
185
204
|
expires_at=datetime.utcnow() + timedelta(minutes=MAGIC_LINK_EXPIRY_MINUTES),
|
|
186
205
|
ip_address=ip_address,
|
|
187
206
|
user_agent=user_agent,
|
|
207
|
+
tenant_id=tenant_id,
|
|
188
208
|
)
|
|
189
209
|
|
|
190
210
|
# Save to Firestore
|
|
@@ -194,6 +214,54 @@ class UserService:
|
|
|
194
214
|
logger.info(f"Created magic link for {email}")
|
|
195
215
|
return magic_link
|
|
196
216
|
|
|
217
|
+
def create_admin_login_token(
|
|
218
|
+
self,
|
|
219
|
+
email: str,
|
|
220
|
+
expiry_hours: int = 24,
|
|
221
|
+
) -> MagicLinkToken:
|
|
222
|
+
"""
|
|
223
|
+
Create an admin login token for email-embedded authentication links.
|
|
224
|
+
|
|
225
|
+
Similar to magic links but with configurable expiry (default 24 hours).
|
|
226
|
+
Used for made-for-you order notification emails to allow admin one-click login.
|
|
227
|
+
|
|
228
|
+
Args:
|
|
229
|
+
email: Admin's email address to authenticate as
|
|
230
|
+
expiry_hours: Hours until token expires (default: 24, max: 168)
|
|
231
|
+
|
|
232
|
+
Returns:
|
|
233
|
+
MagicLinkToken object containing the token
|
|
234
|
+
|
|
235
|
+
Raises:
|
|
236
|
+
ValueError: If expiry_hours is out of valid range (1-168)
|
|
237
|
+
"""
|
|
238
|
+
# Validate expiry_hours (1 hour to 7 days)
|
|
239
|
+
if not 1 <= expiry_hours <= 168:
|
|
240
|
+
raise ValueError(f"expiry_hours must be between 1 and 168, got {expiry_hours}")
|
|
241
|
+
|
|
242
|
+
email = email.lower()
|
|
243
|
+
|
|
244
|
+
# Ensure user exists
|
|
245
|
+
self.get_or_create_user(email)
|
|
246
|
+
|
|
247
|
+
# Generate secure token
|
|
248
|
+
token = secrets.token_urlsafe(32)
|
|
249
|
+
|
|
250
|
+
admin_login = MagicLinkToken(
|
|
251
|
+
token=token,
|
|
252
|
+
email=email,
|
|
253
|
+
expires_at=datetime.utcnow() + timedelta(hours=expiry_hours),
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
# Save to Firestore (same collection as magic links for unified verification)
|
|
257
|
+
doc_ref = self.db.collection(MAGIC_LINKS_COLLECTION).document(token)
|
|
258
|
+
doc_ref.set(admin_login.model_dump(mode='json'))
|
|
259
|
+
|
|
260
|
+
# Log with redacted email (show only domain) for PII protection
|
|
261
|
+
domain = email.split('@')[-1] if '@' in email else 'unknown'
|
|
262
|
+
logger.info(f"Created admin login token for ***@{domain} (expires in {expiry_hours}h)")
|
|
263
|
+
return admin_login
|
|
264
|
+
|
|
197
265
|
def verify_magic_link(self, token: str) -> Tuple[bool, Optional[User], str]:
|
|
198
266
|
"""
|
|
199
267
|
Verify a magic link token using a Firestore transaction to prevent race conditions.
|
|
@@ -262,9 +330,18 @@ class UserService:
|
|
|
262
330
|
self,
|
|
263
331
|
user_email: str,
|
|
264
332
|
ip_address: Optional[str] = None,
|
|
265
|
-
user_agent: Optional[str] = None
|
|
333
|
+
user_agent: Optional[str] = None,
|
|
334
|
+
tenant_id: Optional[str] = None
|
|
266
335
|
) -> Session:
|
|
267
|
-
"""
|
|
336
|
+
"""
|
|
337
|
+
Create a new session for an authenticated user.
|
|
338
|
+
|
|
339
|
+
Args:
|
|
340
|
+
user_email: User's email address
|
|
341
|
+
ip_address: Client IP for auditing
|
|
342
|
+
user_agent: Client user agent for auditing
|
|
343
|
+
tenant_id: Tenant ID for white-label portals (None = default Nomad Karaoke)
|
|
344
|
+
"""
|
|
268
345
|
token = secrets.token_urlsafe(32)
|
|
269
346
|
token_prefix = token[:12]
|
|
270
347
|
|
|
@@ -274,6 +351,7 @@ class UserService:
|
|
|
274
351
|
expires_at=datetime.utcnow() + timedelta(days=SESSION_ABSOLUTE_EXPIRY_DAYS),
|
|
275
352
|
ip_address=ip_address,
|
|
276
353
|
user_agent=user_agent,
|
|
354
|
+
tenant_id=tenant_id,
|
|
277
355
|
)
|
|
278
356
|
|
|
279
357
|
# Serialize and write to Firestore
|
|
@@ -39,6 +39,24 @@ os.environ["ADMIN_TOKENS"] = "test-admin-token"
|
|
|
39
39
|
|
|
40
40
|
# Only import app if emulators are running
|
|
41
41
|
if emulators_running():
|
|
42
|
+
from unittest.mock import Mock
|
|
43
|
+
|
|
44
|
+
# Mock theme service BEFORE importing app to ensure all jobs get theme_id="nomad"
|
|
45
|
+
_mock_theme_service = Mock()
|
|
46
|
+
_mock_theme_service.get_default_theme_id.return_value = "nomad"
|
|
47
|
+
_mock_theme_service.get_theme.return_value = None
|
|
48
|
+
|
|
49
|
+
# Apply patch at module load time (before app imports the service)
|
|
50
|
+
_theme_patches = [
|
|
51
|
+
patch("backend.services.theme_service.get_theme_service", return_value=_mock_theme_service),
|
|
52
|
+
patch("backend.api.routes.audio_search.get_theme_service", return_value=_mock_theme_service),
|
|
53
|
+
patch("backend.api.routes.file_upload.get_theme_service", return_value=_mock_theme_service),
|
|
54
|
+
patch("backend.api.routes.users.get_theme_service", return_value=_mock_theme_service),
|
|
55
|
+
patch("backend.api.routes.jobs.get_theme_service", return_value=_mock_theme_service),
|
|
56
|
+
]
|
|
57
|
+
for p in _theme_patches:
|
|
58
|
+
p.start()
|
|
59
|
+
|
|
42
60
|
from fastapi.testclient import TestClient
|
|
43
61
|
from backend.main import app
|
|
44
62
|
else:
|
|
@@ -76,7 +94,10 @@ def mock_worker_service():
|
|
|
76
94
|
|
|
77
95
|
@pytest.fixture(scope="session")
|
|
78
96
|
def client(mock_worker_service):
|
|
79
|
-
"""Create FastAPI test client with mocked workers.
|
|
97
|
+
"""Create FastAPI test client with mocked workers.
|
|
98
|
+
|
|
99
|
+
Theme service is mocked at module load time (see above).
|
|
100
|
+
"""
|
|
80
101
|
with patch("backend.api.routes.file_upload.worker_service", mock_worker_service):
|
|
81
102
|
return TestClient(app)
|
|
82
103
|
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Emulator integration tests for made-for-you order features.
|
|
3
|
+
|
|
4
|
+
Tests with real Firestore emulator via API endpoints to verify:
|
|
5
|
+
1. Admin login token creation and verification
|
|
6
|
+
2. Made-for-you job fields are properly stored
|
|
7
|
+
|
|
8
|
+
Run with: scripts/run-emulator-tests.sh
|
|
9
|
+
"""
|
|
10
|
+
import pytest
|
|
11
|
+
import time
|
|
12
|
+
|
|
13
|
+
from .conftest import emulators_running
|
|
14
|
+
|
|
15
|
+
# Skip all tests if emulators not running
|
|
16
|
+
pytestmark = pytest.mark.skipif(
|
|
17
|
+
not emulators_running(),
|
|
18
|
+
reason="GCP emulators not running. Start with: scripts/start-emulators.sh"
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class TestAdminLoginTokenIntegration:
|
|
23
|
+
"""
|
|
24
|
+
Integration tests for admin login token feature via API.
|
|
25
|
+
|
|
26
|
+
These tests verify that admin login tokens can be verified
|
|
27
|
+
through the actual API endpoint.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def test_verify_invalid_token_returns_401(self, client):
|
|
31
|
+
"""Invalid token returns 401 unauthorized."""
|
|
32
|
+
response = client.get("/api/users/auth/verify?token=invalid-token-xyz")
|
|
33
|
+
assert response.status_code == 401
|
|
34
|
+
|
|
35
|
+
def test_verify_missing_token_returns_422(self, client):
|
|
36
|
+
"""Missing token parameter returns 422 validation error."""
|
|
37
|
+
response = client.get("/api/users/auth/verify")
|
|
38
|
+
assert response.status_code == 422
|
|
39
|
+
|
|
40
|
+
def test_verify_empty_token_returns_401(self, client):
|
|
41
|
+
"""Empty token returns 401."""
|
|
42
|
+
response = client.get("/api/users/auth/verify?token=")
|
|
43
|
+
assert response.status_code == 401
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class TestMadeForYouJobModel:
|
|
47
|
+
"""
|
|
48
|
+
Tests for made-for-you job model fields.
|
|
49
|
+
|
|
50
|
+
Verifies that the Job model properly supports made_for_you fields.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
def test_job_model_has_made_for_you_fields(self):
|
|
54
|
+
"""Job model includes made_for_you and customer_email fields."""
|
|
55
|
+
from backend.models.job import Job, JobStatus
|
|
56
|
+
from datetime import datetime, timezone
|
|
57
|
+
|
|
58
|
+
# Create a made-for-you job directly
|
|
59
|
+
job = Job(
|
|
60
|
+
job_id="test-mfy-model",
|
|
61
|
+
status=JobStatus.PENDING,
|
|
62
|
+
created_at=datetime.now(timezone.utc),
|
|
63
|
+
updated_at=datetime.now(timezone.utc),
|
|
64
|
+
user_email="admin@nomadkaraoke.com",
|
|
65
|
+
made_for_you=True,
|
|
66
|
+
customer_email="customer@example.com",
|
|
67
|
+
customer_notes="Please make it perfect!",
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
assert job.made_for_you is True
|
|
71
|
+
assert job.customer_email == "customer@example.com"
|
|
72
|
+
assert job.customer_notes == "Please make it perfect!"
|
|
73
|
+
|
|
74
|
+
def test_job_model_defaults_made_for_you_false(self):
|
|
75
|
+
"""Regular jobs default made_for_you to False."""
|
|
76
|
+
from backend.models.job import Job, JobStatus
|
|
77
|
+
from datetime import datetime, timezone
|
|
78
|
+
|
|
79
|
+
job = Job(
|
|
80
|
+
job_id="test-regular-model",
|
|
81
|
+
status=JobStatus.PENDING,
|
|
82
|
+
created_at=datetime.now(timezone.utc),
|
|
83
|
+
updated_at=datetime.now(timezone.utc),
|
|
84
|
+
user_email="user@example.com",
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
# Default should be False or None
|
|
88
|
+
assert job.made_for_you in [False, None]
|
|
89
|
+
assert job.customer_email is None
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class TestOwnershipTransferLogic:
|
|
93
|
+
"""
|
|
94
|
+
Tests for ownership transfer logic (unit-style, runs in emulator context).
|
|
95
|
+
|
|
96
|
+
These tests verify the conditional logic for made-for-you ownership transfer
|
|
97
|
+
by importing the job_manager and testing its behavior directly.
|
|
98
|
+
"""
|
|
99
|
+
|
|
100
|
+
def test_ownership_transfer_condition_made_for_you_true(self):
|
|
101
|
+
"""
|
|
102
|
+
Verify the condition: if made_for_you=True and customer_email exists,
|
|
103
|
+
ownership should transfer.
|
|
104
|
+
"""
|
|
105
|
+
# Replicate the exact logic from _schedule_completion_email
|
|
106
|
+
class MockJob:
|
|
107
|
+
made_for_you = True
|
|
108
|
+
customer_email = "customer@example.com"
|
|
109
|
+
user_email = "admin@nomadkaraoke.com"
|
|
110
|
+
job_id = "test-123"
|
|
111
|
+
|
|
112
|
+
job = MockJob()
|
|
113
|
+
|
|
114
|
+
# The logic being tested
|
|
115
|
+
recipient_email = job.user_email
|
|
116
|
+
should_transfer = False
|
|
117
|
+
|
|
118
|
+
if job.made_for_you and job.customer_email:
|
|
119
|
+
recipient_email = job.customer_email
|
|
120
|
+
should_transfer = True
|
|
121
|
+
|
|
122
|
+
assert should_transfer is True
|
|
123
|
+
assert recipient_email == "customer@example.com"
|
|
124
|
+
|
|
125
|
+
def test_ownership_transfer_condition_made_for_you_false(self):
|
|
126
|
+
"""
|
|
127
|
+
Verify: if made_for_you=False, no ownership transfer.
|
|
128
|
+
"""
|
|
129
|
+
class MockJob:
|
|
130
|
+
made_for_you = False
|
|
131
|
+
customer_email = "customer@example.com"
|
|
132
|
+
user_email = "user@example.com"
|
|
133
|
+
job_id = "test-456"
|
|
134
|
+
|
|
135
|
+
job = MockJob()
|
|
136
|
+
|
|
137
|
+
recipient_email = job.user_email
|
|
138
|
+
should_transfer = False
|
|
139
|
+
|
|
140
|
+
if job.made_for_you and job.customer_email:
|
|
141
|
+
recipient_email = job.customer_email
|
|
142
|
+
should_transfer = True
|
|
143
|
+
|
|
144
|
+
assert should_transfer is False
|
|
145
|
+
assert recipient_email == "user@example.com"
|
|
146
|
+
|
|
147
|
+
def test_ownership_transfer_condition_missing_customer_email(self):
|
|
148
|
+
"""
|
|
149
|
+
Verify: if made_for_you=True but no customer_email, no transfer.
|
|
150
|
+
"""
|
|
151
|
+
class MockJob:
|
|
152
|
+
made_for_you = True
|
|
153
|
+
customer_email = None
|
|
154
|
+
user_email = "admin@nomadkaraoke.com"
|
|
155
|
+
job_id = "test-789"
|
|
156
|
+
|
|
157
|
+
job = MockJob()
|
|
158
|
+
|
|
159
|
+
recipient_email = job.user_email
|
|
160
|
+
should_transfer = False
|
|
161
|
+
|
|
162
|
+
if job.made_for_you and job.customer_email:
|
|
163
|
+
recipient_email = job.customer_email
|
|
164
|
+
should_transfer = True
|
|
165
|
+
|
|
166
|
+
assert should_transfer is False
|
|
167
|
+
assert recipient_email == "admin@nomadkaraoke.com"
|