Securite
- RequireAppKey : filtre qui exige une cle d'API valide ou un utilisateur du
manager. Pose sur les routes de contenu consommees par les apps visiteur
(Configuration, Section, Resource, SectionMap, SectionEvent, SectionAgenda,
SectionParcours, SectionQuiz, ApplicationInstance). Un [Authorize] d'action
ne peut pas assouplir celui de la classe ; seul [AllowAnonymous] le
court-circuite, et le filtre redevient le controle d'acces. Il ferme
l'enumeration par identifiant, pas la confidentialite : la cle s'obtient
par le slug ou le pincode.
- Instance/slug/{slug} rendait le pinCode, l'adresse de facturation, la TVA et
les quotas a qui lit l'URL du site visiteur. StripCommercialFields est
desormais applique sur slug et byPin ; isTrialActive reste expose pour le
filigrane d'essai.
- Device.Create et Device/{id}/detail repondaient 403 a toute tablette depuis
a452f4a (13/03) : la classe exige InstanceAdmin, une cle ne porte que
AppRead. Ouverts a la cle, le cloisonnement par instance etait deja ecrit.
Canal VR
- Device.AppType (defaut Tablet, backfill a 1) ; Create resout
l'ApplicationInstance sur ce type au lieu de Tablet en dur.
- Get filtre optionnellement par appType ; DeviceDetailDTO expose appVersion
et lastSeen.
- PUT Device/{id}/heartbeat : batterie, version, connexion. Volontairement
etroit, une app ne peut ni se renommer ni changer d'instance.
- ApiKeyAppType.VrApp en fin d'enum.
Contenu immersif
- ResourceType : Image360 (11), Video360 (12), Model3D (13), en fin d'enum.
- Section Scene3D : un modele GLB et ses points d'interet, en objet manipule
ou en decor habite. Les points sont des GeoPoint, avec une LocalTransform
en jsonb (convention glTF) ; CRUD dans SectionScene3DController.
- Instance.HasImmersiveContent : l'add-on ajoute 100 Go au quota de stockage,
repose apres un changement de plan et retire a la desactivation.
- ImmersiveBackground (owned) sur Configuration et ApplicationInstance, avec
une image de repli pour les canaux qui ne rendent pas l'immersif.
Export de configuration
- exportVersion (1) et generatedAt : le JSON devient un contrat, lu tel quel
par l'app Unity.
- Section.ToDTO() n'etant pas virtuelle, l'export ne portait aucun champ
specifique de sous-type. Passe par SectionFactory.ToDTO, et charge les
points des Map et des Scene3D.
- Le fond immersif et son repli partent avec les ressources, URL resolues.
Migrations : AddAppTypeToDevice, AddScene3DSectionAndImmersiveAddon,
AddImmersiveBackground.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
188 lines
7.7 KiB
C#
188 lines
7.7 KiB
C#
using Manager.DTOs;
|
|
using ManagerService.Data;
|
|
using ManagerService.Data.SubSection;
|
|
using ManagerService.DTOs;
|
|
using System.Collections.Generic;
|
|
using System.Linq;
|
|
using Xunit;
|
|
|
|
namespace ManagerService.Tests.Controllers
|
|
{
|
|
/// <summary>
|
|
/// L'export d'une configuration doit embarquer **toutes** les ressources que ses
|
|
/// sections référencent — c'est ce qui rend une visite lisible hors ligne.
|
|
///
|
|
/// Le `switch` qui les collectait était commenté depuis un moment : seules l'image
|
|
/// de la configuration, celle du loader et l'image de chaque section partaient, donc
|
|
/// une visite téléchargée n'avait ni contenus d'articles ni audios. Ces tests portent
|
|
/// sur `GetReferencedResourceIds`, à qui la collecte a été confiée : c'est elle qui
|
|
/// garantit qu'un nouveau type de section n'ouvrira pas le même trou en silence.
|
|
/// </summary>
|
|
public class ConfigurationExportTests
|
|
{
|
|
private static SectionArticle ArticleWithMedia() => new SectionArticle
|
|
{
|
|
Id = "sec-article",
|
|
ConfigurationId = "cfg-1",
|
|
InstanceId = "inst-1",
|
|
Label = "Article",
|
|
Type = SectionType.Article,
|
|
Title = new List<TranslationDTO>(),
|
|
Description = new List<TranslationDTO>(),
|
|
ImageId = "res-image-section",
|
|
ArticleContent = new List<TranslationDTO>(),
|
|
ArticleAudioIds = new List<TranslationDTO>
|
|
{
|
|
new TranslationDTO { language = "FR", value = "res-audio-fr" },
|
|
new TranslationDTO { language = "NL", value = "res-audio-nl" },
|
|
},
|
|
ArticleContents = new List<ContentDTO>
|
|
{
|
|
new ContentDTO { resourceId = "res-content-1" },
|
|
new ContentDTO { resourceId = "res-content-2" },
|
|
},
|
|
};
|
|
|
|
/// <summary>
|
|
/// ⚠️ <b>Le second trou du même endroit, trouvé le 12/09.</b> Les ressources
|
|
/// partaient bien, mais <b>pas les champs qui les référencent</b> :
|
|
/// <c>Section.ToDTO()</c> n'est pas virtuelle, et l'export appelait
|
|
/// <c>s.ToDTO()</c> sur une variable de type <c>Section</c>. Le JSON ne portait
|
|
/// donc que les champs communs — une galerie sans ses images, une carte sans ses
|
|
/// points, une maquette sans son modèle. L'export est censé être « tout le
|
|
/// contenu en un appel » ; il l'est depuis que <c>SectionFactory.ToDTO</c> fait
|
|
/// le sous-type réel.
|
|
/// </summary>
|
|
[Theory]
|
|
[InlineData(typeof(SectionMap), typeof(MapDTO))]
|
|
[InlineData(typeof(SectionSlider), typeof(SliderDTO))]
|
|
[InlineData(typeof(SectionVideo), typeof(VideoDTO))]
|
|
[InlineData(typeof(SectionScene3D), typeof(Scene3DDTO))]
|
|
[InlineData(typeof(SectionArticle), typeof(ArticleDTO))]
|
|
public void Export_ProducesTheRealSubTypeDto(System.Type sectionType, System.Type expectedDto)
|
|
{
|
|
var section = (Section)System.Activator.CreateInstance(sectionType)!;
|
|
section.Title = new List<TranslationDTO>();
|
|
section.Description = new List<TranslationDTO>();
|
|
|
|
var dto = ManagerService.Services.SectionFactory.ToDTO(section);
|
|
|
|
Assert.IsType(expectedDto, dto);
|
|
Assert.IsAssignableFrom<SectionDTO>(dto);
|
|
}
|
|
|
|
[Fact]
|
|
public void Export_KeepsModel3DFields()
|
|
{
|
|
var section = new SectionScene3D
|
|
{
|
|
Id = "s1",
|
|
Title = new List<TranslationDTO>(),
|
|
Description = new List<TranslationDTO>(),
|
|
Model3DResourceId = "res-glb",
|
|
Model3DSource = "https://cdn/model.glb",
|
|
Points = new List<GeoPoint>
|
|
{
|
|
new GeoPoint
|
|
{
|
|
Id = 1,
|
|
Title = new List<TranslationDTO>(),
|
|
Description = new List<TranslationDTO>(),
|
|
Contents = new List<ContentDTO>(),
|
|
LocalTransform = new Position3D { x = 1.5f, y = 0.2f, z = -3f }
|
|
}
|
|
}
|
|
};
|
|
|
|
var dto = Assert.IsType<Scene3DDTO>(ManagerService.Services.SectionFactory.ToDTO(section));
|
|
|
|
Assert.Equal("res-glb", dto.model3DResourceId);
|
|
Assert.Equal("https://cdn/model.glb", dto.model3DSource);
|
|
|
|
var point = Assert.Single(dto.points);
|
|
Assert.Equal(1.5f, point.localTransform.x);
|
|
Assert.Equal(-3f, point.localTransform.z);
|
|
}
|
|
|
|
[Fact]
|
|
public void Article_ExposesItsContentAndAudioResources()
|
|
{
|
|
var ids = ArticleWithMedia().GetReferencedResourceIds().ToList();
|
|
|
|
// Sans ces trois familles, la visite hors ligne s'ouvre sans ses médias.
|
|
Assert.Contains("res-image-section", ids);
|
|
Assert.Contains("res-content-1", ids);
|
|
Assert.Contains("res-content-2", ids);
|
|
Assert.Contains("res-audio-fr", ids);
|
|
}
|
|
|
|
[Fact]
|
|
public void Article_FiltersAudioByLanguage_WhenOneIsAsked()
|
|
{
|
|
var ids = ArticleWithMedia().GetReferencedResourceIds("FR").ToList();
|
|
|
|
Assert.Contains("res-audio-fr", ids);
|
|
// Télécharger une visite en français ne doit pas embarquer les audios NL :
|
|
// c'est du poids sur le device du visiteur, pas du contenu.
|
|
Assert.DoesNotContain("res-audio-nl", ids);
|
|
}
|
|
|
|
[Fact]
|
|
public void Map_ExposesIconCategoriesAndPointResources()
|
|
{
|
|
var map = new SectionMap
|
|
{
|
|
Id = "sec-map",
|
|
ConfigurationId = "cfg-1",
|
|
InstanceId = "inst-1",
|
|
Label = "Map",
|
|
Type = SectionType.Map,
|
|
Title = new List<TranslationDTO>(),
|
|
Description = new List<TranslationDTO>(),
|
|
IconResourceId = "res-map-icon",
|
|
MapCategories = new List<CategorieDTO>
|
|
{
|
|
new CategorieDTO { resourceDTO = new ResourceDTO { id = "res-categorie" } },
|
|
},
|
|
MapPoints = new List<GeoPoint>
|
|
{
|
|
new GeoPoint
|
|
{
|
|
Id = 1,
|
|
ImageResourceId = "res-point-image",
|
|
Contents = new List<ContentDTO> { new ContentDTO { resourceId = "res-point-content" } },
|
|
},
|
|
},
|
|
};
|
|
|
|
var ids = map.GetReferencedResourceIds().ToList();
|
|
|
|
Assert.Contains("res-map-icon", ids);
|
|
Assert.Contains("res-categorie", ids);
|
|
Assert.Contains("res-point-image", ids);
|
|
Assert.Contains("res-point-content", ids);
|
|
}
|
|
|
|
[Fact]
|
|
public void EverySectionType_ImplementsTheCollection()
|
|
{
|
|
// Le trou d'origine venait d'un `switch` : un type oublié passait dans le
|
|
// `default` sans bruit. La méthode étant abstraite sur Section, le compilateur
|
|
// impose désormais l'implémentation — ce test le constate plutôt qu'il ne
|
|
// l'espère, et échouera si quelqu'un réintroduit une collecte par switch.
|
|
var sectionType = typeof(Section);
|
|
var subTypes = sectionType.Assembly.GetTypes()
|
|
.Where(t => t.IsSubclassOf(sectionType) && !t.IsAbstract)
|
|
.ToList();
|
|
|
|
Assert.NotEmpty(subTypes);
|
|
foreach (var t in subTypes)
|
|
{
|
|
var method = t.GetMethod(nameof(Section.GetReferencedResourceIds));
|
|
Assert.NotNull(method);
|
|
Assert.Equal(t, method.DeclaringType);
|
|
}
|
|
}
|
|
}
|
|
}
|