totelegram 0.9.10__tar.gz
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.
- totelegram-0.9.10/PKG-INFO +70 -0
- totelegram-0.9.10/README.md +45 -0
- totelegram-0.9.10/setup.cfg +4 -0
- totelegram-0.9.10/setup.py +34 -0
- totelegram-0.9.10/tests/test_adr.py +42 -0
- totelegram-0.9.10/tests/test_chunking.py +21 -0
- totelegram-0.9.10/tests/test_command_config.py +116 -0
- totelegram-0.9.10/tests/test_command_profile.py +134 -0
- totelegram-0.9.10/tests/test_models.py +141 -0
- totelegram-0.9.10/tests/test_snapshot.py +100 -0
- totelegram-0.9.10/tests/test_stream.py +103 -0
- totelegram-0.9.10/totelegram/__init__.py +13 -0
- totelegram-0.9.10/totelegram/cli/__init__.py +0 -0
- totelegram-0.9.10/totelegram/cli/__main__.py +108 -0
- totelegram-0.9.10/totelegram/cli/commands/__init__.py +0 -0
- totelegram-0.9.10/totelegram/cli/commands/backup.py +107 -0
- totelegram-0.9.10/totelegram/cli/commands/config.py +343 -0
- totelegram-0.9.10/totelegram/cli/commands/profile.py +401 -0
- totelegram-0.9.10/totelegram/cli/commands/send.py +99 -0
- totelegram-0.9.10/totelegram/cli/logic.py +208 -0
- totelegram-0.9.10/totelegram/cli/ui.py +654 -0
- totelegram-0.9.10/totelegram/database.py +152 -0
- totelegram-0.9.10/totelegram/discovery.py +153 -0
- totelegram-0.9.10/totelegram/identity.py +599 -0
- totelegram-0.9.10/totelegram/logging_config.py +69 -0
- totelegram-0.9.10/totelegram/migration.py +73 -0
- totelegram-0.9.10/totelegram/models.py +527 -0
- totelegram-0.9.10/totelegram/packaging.py +339 -0
- totelegram-0.9.10/totelegram/schemas.py +326 -0
- totelegram-0.9.10/totelegram/stream.py +157 -0
- totelegram-0.9.10/totelegram/telegram/__init__.py +0 -0
- totelegram-0.9.10/totelegram/telegram/access.py +226 -0
- totelegram-0.9.10/totelegram/telegram/auth.py +108 -0
- totelegram-0.9.10/totelegram/telegram/client.py +202 -0
- totelegram-0.9.10/totelegram/telegram/patches.py +373 -0
- totelegram-0.9.10/totelegram/telegram/search.py +90 -0
- totelegram-0.9.10/totelegram/types.py +41 -0
- totelegram-0.9.10/totelegram/uploader.py +314 -0
- totelegram-0.9.10/totelegram/utils.py +389 -0
- totelegram-0.9.10/totelegram.egg-info/PKG-INFO +70 -0
- totelegram-0.9.10/totelegram.egg-info/SOURCES.txt +43 -0
- totelegram-0.9.10/totelegram.egg-info/dependency_links.txt +1 -0
- totelegram-0.9.10/totelegram.egg-info/entry_points.txt +2 -0
- totelegram-0.9.10/totelegram.egg-info/requires.txt +9 -0
- totelegram-0.9.10/totelegram.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: totelegram
|
|
3
|
+
Version: 0.9.10
|
|
4
|
+
Summary: toTelegram sube archivos a telegram sin importar el tamaño.
|
|
5
|
+
Home-page: https://github.com/CalumRakk/toTelegram
|
|
6
|
+
Author: Leo
|
|
7
|
+
Requires-Python: >=3.10.0
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
Requires-Dist: filetype==1.2.0
|
|
10
|
+
Requires-Dist: peewee>=3.17.9
|
|
11
|
+
Requires-Dist: pydantic>=2.11.7
|
|
12
|
+
Requires-Dist: pydantic-settings>=2.10.1
|
|
13
|
+
Requires-Dist: pyrogram==2.0.106
|
|
14
|
+
Requires-Dist: TgCrypto>=1.2.5
|
|
15
|
+
Requires-Dist: typer>=0.21.1
|
|
16
|
+
Requires-Dist: filelock>=3.20.3
|
|
17
|
+
Requires-Dist: tartape>=2.2.0
|
|
18
|
+
Dynamic: author
|
|
19
|
+
Dynamic: description
|
|
20
|
+
Dynamic: description-content-type
|
|
21
|
+
Dynamic: home-page
|
|
22
|
+
Dynamic: requires-dist
|
|
23
|
+
Dynamic: requires-python
|
|
24
|
+
Dynamic: summary
|
|
25
|
+
|
|
26
|
+
## toTelegram
|
|
27
|
+
|
|
28
|
+
Este proyecto es una herramienta de CLI en Python diseñada para subir archivos a Telegram sin preocuparse por los límites de tamaño (2GB/4GB). La idea no es solo tener un uploader, sino un gestor de disponibilidad que entiende qué archivos ya están en la "nube" de tus chats para evitar subidas redundantes.
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
### ¿De qué va esto?
|
|
32
|
+
A diferencia de un uploader simple, `toTelegram` usa una base de datos local (SQLite) para trackear los archivos por su MD5. Si intentas subir algo que el sistema ya detectó en otro chat al que tienes acceso, intentará hacer un forward (mirror) o reconstruir el archivo desde piezas sueltas (puzzle) en lugar de gastar ancho de banda volviendo a subir los bytes.
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
---
|
|
36
|
+
|
|
37
|
+
### Cómo probarlo
|
|
38
|
+
|
|
39
|
+
Puedes instalar el paquete directamente desde el repositorio sin necesidad de clonarlo manualmente:
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
**1. Instala usando pip desde GitHub:**
|
|
43
|
+
|
|
44
|
+
```
|
|
45
|
+
pip install totelegram
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
**2. Crea un perfil:**
|
|
49
|
+
|
|
50
|
+
Necesitas tu **API_ID** y **API_HASH** de [Telegram](https://my.telegram.org/).
|
|
51
|
+
|
|
52
|
+
```
|
|
53
|
+
totelegram profile create
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
**3. Configura un chat de destino**
|
|
57
|
+
|
|
58
|
+
```
|
|
59
|
+
totelegram config search "Mi Nube Privada" --apply
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
**4. Envía archivos o carpetas:**
|
|
63
|
+
|
|
64
|
+
```
|
|
65
|
+
# Enviar archivos individuales
|
|
66
|
+
totelegram send video.mp4
|
|
67
|
+
|
|
68
|
+
# Archivar una carpeta completa como un backup estructurado
|
|
69
|
+
totelegram backup ./mis_fotos
|
|
70
|
+
```
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
## toTelegram
|
|
2
|
+
|
|
3
|
+
Este proyecto es una herramienta de CLI en Python diseñada para subir archivos a Telegram sin preocuparse por los límites de tamaño (2GB/4GB). La idea no es solo tener un uploader, sino un gestor de disponibilidad que entiende qué archivos ya están en la "nube" de tus chats para evitar subidas redundantes.
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### ¿De qué va esto?
|
|
7
|
+
A diferencia de un uploader simple, `toTelegram` usa una base de datos local (SQLite) para trackear los archivos por su MD5. Si intentas subir algo que el sistema ya detectó en otro chat al que tienes acceso, intentará hacer un forward (mirror) o reconstruir el archivo desde piezas sueltas (puzzle) en lugar de gastar ancho de banda volviendo a subir los bytes.
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
### Cómo probarlo
|
|
13
|
+
|
|
14
|
+
Puedes instalar el paquete directamente desde el repositorio sin necesidad de clonarlo manualmente:
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
**1. Instala usando pip desde GitHub:**
|
|
18
|
+
|
|
19
|
+
```
|
|
20
|
+
pip install totelegram
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
**2. Crea un perfil:**
|
|
24
|
+
|
|
25
|
+
Necesitas tu **API_ID** y **API_HASH** de [Telegram](https://my.telegram.org/).
|
|
26
|
+
|
|
27
|
+
```
|
|
28
|
+
totelegram profile create
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
**3. Configura un chat de destino**
|
|
32
|
+
|
|
33
|
+
```
|
|
34
|
+
totelegram config search "Mi Nube Privada" --apply
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
**4. Envía archivos o carpetas:**
|
|
38
|
+
|
|
39
|
+
```
|
|
40
|
+
# Enviar archivos individuales
|
|
41
|
+
totelegram send video.mp4
|
|
42
|
+
|
|
43
|
+
# Archivar una carpeta completa como un backup estructurado
|
|
44
|
+
totelegram backup ./mis_fotos
|
|
45
|
+
```
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
from setuptools import find_packages, setup
|
|
4
|
+
|
|
5
|
+
from totelegram import __version__
|
|
6
|
+
|
|
7
|
+
this_directory = Path(__file__).parent
|
|
8
|
+
long_description = (this_directory / "README.md").read_text(encoding="utf-8")
|
|
9
|
+
|
|
10
|
+
setup(
|
|
11
|
+
name="totelegram",
|
|
12
|
+
version=__version__,
|
|
13
|
+
description="toTelegram sube archivos a telegram sin importar el tamaño.",
|
|
14
|
+
author="Leo",
|
|
15
|
+
url="https://github.com/CalumRakk/toTelegram",
|
|
16
|
+
long_description=long_description,
|
|
17
|
+
long_description_content_type="text/markdown",
|
|
18
|
+
install_requires=[
|
|
19
|
+
"filetype==1.2.0",
|
|
20
|
+
"peewee>=3.17.9",
|
|
21
|
+
"pydantic>=2.11.7",
|
|
22
|
+
"pydantic-settings>=2.10.1",
|
|
23
|
+
"pyrogram==2.0.106",
|
|
24
|
+
"TgCrypto>=1.2.5",
|
|
25
|
+
"typer>=0.21.1",
|
|
26
|
+
"filelock>=3.20.3",
|
|
27
|
+
"tartape>=2.2.0",
|
|
28
|
+
],
|
|
29
|
+
packages=find_packages(),
|
|
30
|
+
entry_points={
|
|
31
|
+
"console_scripts": ["totelegram=totelegram.cli.__main__:run_script"],
|
|
32
|
+
},
|
|
33
|
+
python_requires=">=3.10.0",
|
|
34
|
+
)
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import unittest
|
|
2
|
+
|
|
3
|
+
from totelegram.identity import Settings
|
|
4
|
+
from totelegram.schemas import AccessLevel
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class TestArchitectureRules(unittest.TestCase):
|
|
8
|
+
def test_adr_001_immutable_profile_credentials(self):
|
|
9
|
+
"""
|
|
10
|
+
ADR-001: Perfil Inmutable.
|
|
11
|
+
|
|
12
|
+
Las credenciales base (API_ID, API_HASH) y el nombre del perfil
|
|
13
|
+
DEBEN ser inmutables para garantizar la integridad de la sesión de Pyrogram.
|
|
14
|
+
|
|
15
|
+
Si este test falla, lee: docs/adr/001-perfil-inmutable.md
|
|
16
|
+
"""
|
|
17
|
+
required_internal_fields = {"API_ID", "API_HASH", "PROFILE_NAME"}
|
|
18
|
+
|
|
19
|
+
api_id = Settings.get_info("API_ID")
|
|
20
|
+
api_hash = Settings.get_info("API_HASH")
|
|
21
|
+
profile_name = Settings.get_info("PROFILE_NAME")
|
|
22
|
+
|
|
23
|
+
error_message = (
|
|
24
|
+
f"\n\n[VIOLACIÓN DE ARQUITECTURA - ADR-001]\n"
|
|
25
|
+
f"Se intentó hacer editables campos críticos: {required_internal_fields}\n"
|
|
26
|
+
f"Estos campos deben permanecer en Settings.INTERNAL_FIELDS.\n"
|
|
27
|
+
f"Consulta 'docs/adr/001-perfil-inmutable.md' para más detalles.\n"
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
self.assertTrue(
|
|
31
|
+
api_id is not None and api_id.level == AccessLevel.DEBUG_READONLY,
|
|
32
|
+
error_message,
|
|
33
|
+
)
|
|
34
|
+
self.assertTrue(
|
|
35
|
+
api_hash is not None and api_hash.level == AccessLevel.DEBUG_READONLY,
|
|
36
|
+
error_message,
|
|
37
|
+
)
|
|
38
|
+
self.assertTrue(
|
|
39
|
+
profile_name is not None
|
|
40
|
+
and profile_name.level == AccessLevel.DEBUG_READONLY,
|
|
41
|
+
error_message,
|
|
42
|
+
)
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import unittest
|
|
2
|
+
|
|
3
|
+
from totelegram.packaging import chunk_ranges
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class TestChunkingMath(unittest.TestCase):
|
|
7
|
+
def test_chunk_ranges_exact_division(self):
|
|
8
|
+
"""Caso: 10MB archivo, 5MB chunk -> 2 partes exactas"""
|
|
9
|
+
file_size = 10 * 1024 * 1024
|
|
10
|
+
chunk_size = 5 * 1024 * 1024
|
|
11
|
+
ranges = chunk_ranges(file_size, chunk_size)
|
|
12
|
+
|
|
13
|
+
self.assertEqual(len(ranges), 2)
|
|
14
|
+
self.assertEqual(ranges[0], (0, 5242880))
|
|
15
|
+
self.assertEqual(ranges[1], (5242880, 10485760))
|
|
16
|
+
|
|
17
|
+
def test_chunk_ranges_remainder(self):
|
|
18
|
+
"""Caso: 10 bytes archivo, 3 bytes chunk -> 4 partes (3, 3, 3, 1)"""
|
|
19
|
+
ranges = chunk_ranges(10, 3)
|
|
20
|
+
expected = [(0, 3), (3, 6), (6, 9), (9, 10)]
|
|
21
|
+
self.assertEqual(ranges, expected)
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import unittest
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from tempfile import TemporaryDirectory
|
|
4
|
+
|
|
5
|
+
from typer.testing import CliRunner
|
|
6
|
+
|
|
7
|
+
from totelegram.cli.commands.config import app
|
|
8
|
+
from totelegram.identity import SettingsManager
|
|
9
|
+
from totelegram.schemas import CLIState
|
|
10
|
+
|
|
11
|
+
runner = CliRunner()
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class TestCliConfig(unittest.TestCase):
|
|
15
|
+
def setUp(self):
|
|
16
|
+
self.tmp_dir = TemporaryDirectory()
|
|
17
|
+
self.config_path = Path(self.tmp_dir.name)
|
|
18
|
+
|
|
19
|
+
self.profile_name = "test_user"
|
|
20
|
+
manager = SettingsManager(self.config_path)
|
|
21
|
+
manager._write_all_settings(
|
|
22
|
+
"test_user",
|
|
23
|
+
{
|
|
24
|
+
"profile_name": self.profile_name,
|
|
25
|
+
"chat_id": "123456789",
|
|
26
|
+
"upload_limit_rate_kbps": "1000",
|
|
27
|
+
"api_id": "123456789",
|
|
28
|
+
"api_hash": "123456789",
|
|
29
|
+
},
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
self.state = CLIState(manager=manager, profile_name=self.profile_name)
|
|
33
|
+
self.manager = manager
|
|
34
|
+
|
|
35
|
+
def tearDown(self):
|
|
36
|
+
try:
|
|
37
|
+
self.tmp_dir.cleanup()
|
|
38
|
+
except Exception:
|
|
39
|
+
pass
|
|
40
|
+
|
|
41
|
+
def test_config_display_table(self):
|
|
42
|
+
"""Verificar que el comando base muestra la tabla de configuración."""
|
|
43
|
+
result = runner.invoke(app, obj=self.state)
|
|
44
|
+
self.assertEqual(result.exit_code, 0)
|
|
45
|
+
self.assertIn("Configuración", result.stdout)
|
|
46
|
+
self.assertIn("test_user", result.stdout)
|
|
47
|
+
self.assertIn("chat_id", result.stdout)
|
|
48
|
+
|
|
49
|
+
def test_set_valid_value(self):
|
|
50
|
+
"""Probar que un cambio válido se persiste correctamente."""
|
|
51
|
+
|
|
52
|
+
result = runner.invoke(
|
|
53
|
+
app,
|
|
54
|
+
["set", "upload_limit_rate_kbps", "500"],
|
|
55
|
+
obj=self.state,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
self.assertEqual(result.exit_code, 0)
|
|
59
|
+
settings = self.manager.get_settings(self.profile_name)
|
|
60
|
+
current_value = getattr(settings, "upload_limit_rate_kbps", None)
|
|
61
|
+
self.assertEqual(current_value, 500)
|
|
62
|
+
|
|
63
|
+
def test_set_invalid_type_safety(self):
|
|
64
|
+
"""
|
|
65
|
+
Si meto basura en un campo numérico,
|
|
66
|
+
Pydantic debe saltar y el comando debe fallar.
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
result = runner.invoke(
|
|
70
|
+
app, ["set", "MAX_FILESIZE_BYTES", "no_soy_un_numero"], obj=self.state
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
self.assertEqual(result.exit_code, 1)
|
|
74
|
+
self.assertIn("debe ser de tipo", result.stdout)
|
|
75
|
+
|
|
76
|
+
# El valor original no debería haber cambiado
|
|
77
|
+
settings = self.manager.get_settings(self.profile_name)
|
|
78
|
+
current_value = getattr(settings, "MAX_FILESIZE_BYTES", None)
|
|
79
|
+
self.assertNotEqual(current_value, "no_soy_un_numero")
|
|
80
|
+
|
|
81
|
+
def test_set_internal_field_protection(self):
|
|
82
|
+
"""
|
|
83
|
+
ADR-001: API_ID y otros campos internos son inmutables tras la creación.
|
|
84
|
+
"""
|
|
85
|
+
result = runner.invoke(app, ["set", "api_id", "99999"], obj=self.state)
|
|
86
|
+
|
|
87
|
+
self.assertIn("Solo Lectura", result.stdout.replace("\n", ""))
|
|
88
|
+
|
|
89
|
+
def test_list_add(self):
|
|
90
|
+
"""Proba añadir elementos de una lista."""
|
|
91
|
+
# Añadir (confirmando con 'y')
|
|
92
|
+
runner.invoke(app, ["add", "exclude_files", "*.tmp", "*.bak"], obj=self.state)
|
|
93
|
+
|
|
94
|
+
settings = self.manager.get_settings(self.profile_name)
|
|
95
|
+
self.assertIn("*.tmp", settings.exclude_files)
|
|
96
|
+
self.assertIn("*.bak", settings.exclude_files)
|
|
97
|
+
|
|
98
|
+
def test_list_add_remove_flow(self):
|
|
99
|
+
"""Probar el flujo de añadir y quitar elementos de una lista (exclude_files)."""
|
|
100
|
+
# Añadir (confirmando con 'y')
|
|
101
|
+
runner.invoke(
|
|
102
|
+
app, ["add", "exclude_files", "*.tmp", "*.bak"], input="y\n", obj=self.state
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
settings = self.manager.get_settings(self.profile_name)
|
|
106
|
+
self.assertIn("*.tmp", settings.exclude_files) # type: ignore
|
|
107
|
+
self.assertIn("*.bak", settings.exclude_files) # type: ignore
|
|
108
|
+
|
|
109
|
+
# Quitar uno
|
|
110
|
+
runner.invoke(
|
|
111
|
+
app, ["remove", "exclude_files", "*.tmp"], input="y\n", obj=self.state
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
settings = self.manager.get_settings(self.profile_name)
|
|
115
|
+
self.assertNotIn("*.tmp", settings.exclude_files) # type: ignore
|
|
116
|
+
self.assertIn("*.bak", settings.exclude_files) # type: ignore
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import shutil
|
|
2
|
+
import tempfile
|
|
3
|
+
import unittest
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from unittest.mock import patch
|
|
6
|
+
|
|
7
|
+
from typer.testing import CliRunner
|
|
8
|
+
|
|
9
|
+
from totelegram.cli.commands.profile import app
|
|
10
|
+
from totelegram.identity import SettingsManager
|
|
11
|
+
from totelegram.schemas import AccessReport, AccessStatus, ChatMatch, CLIState
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class TestCliProfile(unittest.TestCase):
|
|
15
|
+
def setUp(self):
|
|
16
|
+
"""Configuración inicial antes de cada test."""
|
|
17
|
+
self.runner = CliRunner()
|
|
18
|
+
self.test_dir = tempfile.mkdtemp()
|
|
19
|
+
self.workdir = Path(self.test_dir)
|
|
20
|
+
|
|
21
|
+
self.manager = SettingsManager(self.workdir)
|
|
22
|
+
self.manager.profiles_dir.mkdir(parents=True, exist_ok=True)
|
|
23
|
+
self.manager.inventories_dir.mkdir(parents=True, exist_ok=True)
|
|
24
|
+
|
|
25
|
+
self.state = CLIState(manager=self.manager, profile_name=None, is_debug=False)
|
|
26
|
+
|
|
27
|
+
def tearDown(self):
|
|
28
|
+
"""Limpieza después de cada test."""
|
|
29
|
+
shutil.rmtree(self.test_dir)
|
|
30
|
+
|
|
31
|
+
def test_list_profiles_empty(self):
|
|
32
|
+
"""Debe informar que no hay perfiles si la carpeta está vacía."""
|
|
33
|
+
result = self.runner.invoke(app, ["list"], obj=self.state)
|
|
34
|
+
self.assertEqual(result.exit_code, 0)
|
|
35
|
+
self.assertIn("No se encontraron perfiles", result.stdout)
|
|
36
|
+
|
|
37
|
+
@patch("totelegram.telegram.auth.AuthLogic.initialize_profile")
|
|
38
|
+
@patch("totelegram.telegram.client.TelegramSession.from_profile")
|
|
39
|
+
@patch("totelegram.telegram.access.ChatAccessService.verify_access")
|
|
40
|
+
def test_create_profile_success_with_chat_id(
|
|
41
|
+
self, mock_verify, mock_session, mock_auth
|
|
42
|
+
):
|
|
43
|
+
"""Prueba la creación de un perfil con chat_id directo (sin wizard)."""
|
|
44
|
+
|
|
45
|
+
profile_name = "test_profile"
|
|
46
|
+
|
|
47
|
+
# Simulamos que el acceso al chat es válido
|
|
48
|
+
mock_verify.return_value = AccessReport(
|
|
49
|
+
status=AccessStatus.READY,
|
|
50
|
+
chat=ChatMatch(id=-100123456, title="Destino Test", type="channel"),
|
|
51
|
+
reason="Acceso verificado",
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
# Ejecutamos el comando pasando argumentos para evitar prompts interactivos
|
|
55
|
+
result = self.runner.invoke(
|
|
56
|
+
app,
|
|
57
|
+
[
|
|
58
|
+
"create",
|
|
59
|
+
"--profile-name",
|
|
60
|
+
profile_name,
|
|
61
|
+
"--api-id",
|
|
62
|
+
"123456",
|
|
63
|
+
"--api-hash",
|
|
64
|
+
"abcdef",
|
|
65
|
+
"--chat-id",
|
|
66
|
+
"-100123456",
|
|
67
|
+
],
|
|
68
|
+
obj=self.state,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
self.assertEqual(result.exit_code, 0)
|
|
72
|
+
self.assertIn(f"Creando perfil", result.stdout)
|
|
73
|
+
|
|
74
|
+
# Verificamos que se guardó la configuración en el .env
|
|
75
|
+
settings = self.manager._load_and_sanitize(profile_name)
|
|
76
|
+
self.assertEqual(settings["chat_id"], -100123456)
|
|
77
|
+
|
|
78
|
+
def test_switch_profile_fails_if_incomplete(self):
|
|
79
|
+
"""No debe permitir cambiar a un perfil que no sea 'trinity' (incompleto)."""
|
|
80
|
+
profile_name = "orphan"
|
|
81
|
+
|
|
82
|
+
# Creamos solo el .env, pero no el archivo .session
|
|
83
|
+
env_path = self.manager.get_settings_path(profile_name)
|
|
84
|
+
env_path.parent.mkdir(parents=True, exist_ok=True)
|
|
85
|
+
env_path.write_text("profile_name=orphan")
|
|
86
|
+
|
|
87
|
+
result = self.runner.invoke(app, ["switch", profile_name], obj=self.state)
|
|
88
|
+
|
|
89
|
+
self.assertEqual(result.exit_code, 1)
|
|
90
|
+
self.assertIn("está incompleto", result.stdout)
|
|
91
|
+
|
|
92
|
+
def test_switch_profile_success(self):
|
|
93
|
+
"""Cambio exitoso de perfil cuando existen .env y .session."""
|
|
94
|
+
profile_name = "active_user"
|
|
95
|
+
|
|
96
|
+
# 1. Crear .env
|
|
97
|
+
env_path = self.manager.get_settings_path(profile_name)
|
|
98
|
+
env_path.parent.mkdir(parents=True, exist_ok=True)
|
|
99
|
+
env_path.write_text("profile_name=active_user\nchat_id=12345")
|
|
100
|
+
|
|
101
|
+
# 2. Crear .session (archivo vacío para simular existencia)
|
|
102
|
+
session_path = self.manager.get_session_path(profile_name)
|
|
103
|
+
session_path.write_text("fake session data")
|
|
104
|
+
|
|
105
|
+
result = self.runner.invoke(app, ["switch", profile_name], obj=self.state)
|
|
106
|
+
|
|
107
|
+
self.assertEqual(result.exit_code, 0)
|
|
108
|
+
self.assertIn("Perfil cambiado exitosamente", result.stdout)
|
|
109
|
+
self.assertEqual(self.manager.get_active_profile_name(), profile_name)
|
|
110
|
+
|
|
111
|
+
def test_delete_profile_full(self):
|
|
112
|
+
"""Eliminación física de un perfil."""
|
|
113
|
+
profile_name = "target_delete"
|
|
114
|
+
|
|
115
|
+
# Crear rastro
|
|
116
|
+
env_path = self.manager.get_settings_path(profile_name)
|
|
117
|
+
env_path.parent.mkdir(parents=True, exist_ok=True)
|
|
118
|
+
env_path.write_text("data")
|
|
119
|
+
session_path = self.manager.get_session_path(profile_name)
|
|
120
|
+
session_path.write_text("data")
|
|
121
|
+
|
|
122
|
+
# Usamos --yes para saltar la confirmación interactiva
|
|
123
|
+
result = self.runner.invoke(
|
|
124
|
+
app, ["delete", profile_name, "--yes"], obj=self.state
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
self.assertEqual(result.exit_code, 0)
|
|
128
|
+
self.assertFalse(env_path.exists())
|
|
129
|
+
self.assertFalse(session_path.exists())
|
|
130
|
+
self.assertIn(f"eliminado correctamente", result.stdout)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
if __name__ == "__main__":
|
|
134
|
+
unittest.main()
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import unittest
|
|
2
|
+
|
|
3
|
+
import peewee
|
|
4
|
+
|
|
5
|
+
from totelegram.database import DatabaseSession # type: ignore
|
|
6
|
+
from totelegram.models import (
|
|
7
|
+
Job,
|
|
8
|
+
Source,
|
|
9
|
+
TelegramChat,
|
|
10
|
+
)
|
|
11
|
+
from totelegram.schemas import Strategy
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class TestModelsArchitecture(unittest.TestCase):
|
|
15
|
+
def setUp(self):
|
|
16
|
+
self.db_manager = DatabaseSession(":memory:")
|
|
17
|
+
self.db_manager.start()
|
|
18
|
+
self.chat = TelegramChat.create(
|
|
19
|
+
id=-100123456, title="Test Chat", type="channel"
|
|
20
|
+
)
|
|
21
|
+
self.chat_alternate = TelegramChat.create(
|
|
22
|
+
id=-100123801, title="Test Chat", type="channel"
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
def tearDown(self):
|
|
26
|
+
self.db_manager.close()
|
|
27
|
+
|
|
28
|
+
def test_source_file_uniqueness_by_md5(self):
|
|
29
|
+
"""Prueba la unicidad de Source por MD5."""
|
|
30
|
+
Source.create(
|
|
31
|
+
path_str="video.mp4",
|
|
32
|
+
md5sum="abc123",
|
|
33
|
+
size=500,
|
|
34
|
+
mtime=1.0,
|
|
35
|
+
mimetype="video/mp4",
|
|
36
|
+
)
|
|
37
|
+
with self.assertRaises(peewee.IntegrityError):
|
|
38
|
+
Source.create(
|
|
39
|
+
path_str="otra_ruta/video.mp4",
|
|
40
|
+
md5sum="abc123",
|
|
41
|
+
size=500,
|
|
42
|
+
mtime=1.0,
|
|
43
|
+
mimetype="video/mp4",
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
def test_job_contract_strategy_assignment(self):
|
|
47
|
+
"""
|
|
48
|
+
Prueba la lógica de ADR-002:
|
|
49
|
+
El Job determina la estrategia al nacer basado en el límite de bytes.
|
|
50
|
+
"""
|
|
51
|
+
source = Source.create(
|
|
52
|
+
path_str="data.bin",
|
|
53
|
+
md5sum="hash1",
|
|
54
|
+
size=150,
|
|
55
|
+
mtime=1.0,
|
|
56
|
+
mimetype="application/octet-stream",
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
# CASO : El archivo (150b) es mayor que el limite (100b) -> CHUNKED
|
|
60
|
+
|
|
61
|
+
job_chunked = Job.formalize_intent(
|
|
62
|
+
source, self.chat, is_premium=False, tg_limit=100
|
|
63
|
+
)
|
|
64
|
+
self.assertEqual(job_chunked.strategy, Strategy.CHUNKED)
|
|
65
|
+
self.assertEqual(job_chunked.config.tg_max_size, 100)
|
|
66
|
+
|
|
67
|
+
# CASO : El archivo (150b) es menor que el limite (200b) -> SINGLE
|
|
68
|
+
job_single = Job.formalize_intent(
|
|
69
|
+
source, self.chat_alternate, is_premium=True, tg_limit=200
|
|
70
|
+
)
|
|
71
|
+
self.assertEqual(job_single.strategy, Strategy.SINGLE)
|
|
72
|
+
self.assertEqual(job_single.config.tg_max_size, 200)
|
|
73
|
+
|
|
74
|
+
def test_job_immutability_integrity(self):
|
|
75
|
+
"""
|
|
76
|
+
Verifica que una vez creado el Job, su config queda persistida en JSON
|
|
77
|
+
y no cambia aunque cambien los parámetros externos (ADR-002).
|
|
78
|
+
"""
|
|
79
|
+
source = Source.create(
|
|
80
|
+
path_str="test.zip",
|
|
81
|
+
md5sum="hash_imm",
|
|
82
|
+
size=150,
|
|
83
|
+
mtime=1.0,
|
|
84
|
+
mimetype="app/zip",
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
job = Job.formalize_intent(source, self.chat, is_premium=False, tg_limit=100)
|
|
88
|
+
|
|
89
|
+
# Recuperamos de la DB para asegurar que el JSONField funcionó
|
|
90
|
+
job_from_db = Job.get_by_id(job.id)
|
|
91
|
+
self.assertEqual(job_from_db.config.tg_max_size, 100)
|
|
92
|
+
|
|
93
|
+
# def test_payload_relation_and_status(self):
|
|
94
|
+
# """Valida que los payloads se vinculen correctamente y el Job cambie de estado."""
|
|
95
|
+
# source = Source.create(
|
|
96
|
+
# path_str="doc.pdf", md5sum="h_pdf", size=10, mtime=1.0, mimetype="app/pdf"
|
|
97
|
+
# )
|
|
98
|
+
|
|
99
|
+
# job = Job.formalize_intent(source, self.chat, is_premium=False, tg_limit=100)
|
|
100
|
+
|
|
101
|
+
# self.assertEqual(job.status, JobStatus.PENDING)
|
|
102
|
+
|
|
103
|
+
# # Simular creación de un payload (SINGLE)
|
|
104
|
+
# Payload.create_payloads(job, [Path("doc.pdf")])
|
|
105
|
+
# self.assertEqual(job.payloads.count(), 1)
|
|
106
|
+
|
|
107
|
+
# job.set_uploaded()
|
|
108
|
+
# self.assertEqual(job.status, JobStatus.UPLOADED)
|
|
109
|
+
|
|
110
|
+
# def test_remote_payload_register_upload(self):
|
|
111
|
+
# """Valida el registro del 'Vínculo de Acceso' (RemotePayload)."""
|
|
112
|
+
# source = Source.create(
|
|
113
|
+
# path_str="a.txt", md5sum="h1", size=10, mtime=1, mimetype="t"
|
|
114
|
+
# )
|
|
115
|
+
|
|
116
|
+
# job = Job.formalize_intent(source, self.chat, False, 100)
|
|
117
|
+
# payload = Payload.create(job=job, sequence_index=0, md5sum="hp1", size=10)
|
|
118
|
+
|
|
119
|
+
# user = TelegramUser.create(id=123, first_name="Tester")
|
|
120
|
+
|
|
121
|
+
# # Mock de objeto Message de Pyrogram que cumpla con json.loads(str(msg))
|
|
122
|
+
# class MockTgMessage:
|
|
123
|
+
# def __init__(self):
|
|
124
|
+
# self.id = 999
|
|
125
|
+
# self.chat = type("obj", (object,), {"id": -100123456})
|
|
126
|
+
|
|
127
|
+
# def __str__(self):
|
|
128
|
+
# return json.dumps(
|
|
129
|
+
# {
|
|
130
|
+
# "message_id": self.id,
|
|
131
|
+
# "chat": {"id": self.chat.id, "type": "channel"}, # type: ignore
|
|
132
|
+
# }
|
|
133
|
+
# )
|
|
134
|
+
|
|
135
|
+
# remote = RemotePayload.register_upload(
|
|
136
|
+
# payload=payload, tg_message=MockTgMessage(), owner=user
|
|
137
|
+
# )
|
|
138
|
+
|
|
139
|
+
# self.assertEqual(remote.message_id, 999)
|
|
140
|
+
# self.assertEqual(remote.chat_id, -100123456)
|
|
141
|
+
# self.assertEqual(remote.owner.id, 123)
|