Home > Net >  Django Rest Framework give me validation error that shouldn't be raised when using Unit Tests
Django Rest Framework give me validation error that shouldn't be raised when using Unit Tests

Time:01-04

I am creating this API and in determined point of my tests (contract creation endpoint) I am receiving an invalid error.

The error says that I am not passing some required attribute to the API when creating a contract, but I am. The weirdest thing is that when I tried to create from the Web browser by hand, the issue wasn't raised and the contract was created

I am puting here a lot of codes just for replication purposes, but the code that is really important to see it is the ContractSerializer and the test_contract_creation function

Here is my code:

models.py

from django.core.exceptions import ValidationError
from django.db import models
from django.core.validators import (
    MaxValueValidator,
    MinValueValidator,
    RegexValidator,
)

from core.validators import GreaterThanValidator, luhn_validator


class Planet(models.Model):
    name = models.CharField(
        max_length=50, unique=True, null=False, blank=False
    )

    def __str__(self) -> str:
        return self.name


class Route(models.Model):
    origin_planet = models.ForeignKey(
        Planet,
        on_delete=models.CASCADE,
        related_name="origin",
    )
    destination_planet = models.ForeignKey(
        Planet,
        on_delete=models.CASCADE,
        related_name="destination",
    )
    fuel_cost = models.IntegerField(validators=[GreaterThanValidator(0)])

    class Meta:
        unique_together = (("origin_planet", "destination_planet"),)

    def clean(self) -> None:
        # super().full_clean()
        if self.origin_planet == self.destination_planet:
            raise ValidationError(
                "Origin and destination planets must be different."
            )

    def save(self, *args, **kwargs):
        self.full_clean()
        super().save(*args, **kwargs)

    def __str__(self) -> str:
        return f"{self.origin_planet} - {self.destination_planet}"


class Ship(models.Model):
    ship_model = models.CharField(max_length=50, null=False, unique=True)
    fuel_capacity = models.IntegerField(validators=[GreaterThanValidator(0)])
    weight_capacity = models.IntegerField(validators=[GreaterThanValidator(0)])

    def __str__(self) -> str:
        return self.ship_model


class Pilot(models.Model):
    name = models.CharField(max_length=50, null=False)
    age = models.IntegerField(
        validators=[MinValueValidator(18), MaxValueValidator(60)]
    )
    certification = models.CharField(
        max_length=7,
        validators=[
            RegexValidator(
                r"^[0-9]{7}$", "Only digit characters and length 7"
            ),
            luhn_validator,
        ],
        null=False,
        blank=False,
        unique=True,
    )
    credits = models.PositiveIntegerField(default=0, editable=False)
    location_planet = models.ForeignKey(Planet, on_delete=models.CASCADE)
    ships = models.ManyToManyField(Ship, through="Ownership")

    def __str__(self) -> str:
        return f"{self.name}:{self.certification}"


class Ownership(models.Model):
    """Third table for Pilot and Ship relation"""

    pilot = models.ForeignKey(Pilot, on_delete=models.CASCADE)
    ship = models.ForeignKey(Ship, on_delete=models.CASCADE)
    fuel_level = fuel_level = models.PositiveIntegerField(
        default=100, validators=[MaxValueValidator(100)]
    )

    def __str__(self) -> str:
        return f"{self.pilot} -> {self.ship} fuel_level:{self.fuel_level}"


class Resource(models.Model):
    name = models.CharField(max_length=50, unique=True)

    def save(self, *args, **kwargs):
        self.name = self.name.lower()
        super().save(*args, **kwargs)

    def __str__(self) -> str:
        return self.name


class Contract(models.Model):
    description = models.CharField(max_length=50, null=False)
    route = models.ForeignKey(Route, on_delete=models.CASCADE)
    value = models.IntegerField(validators=[GreaterThanValidator(0)])
    completed = models.BooleanField(default=False)
    resources = models.ManyToManyField(Resource, through="Cargo")

    def cargo_weight(self) -> int:
        return sum([cargo.weight for cargo in self.cargo_set.all()])

    def __str__(self) -> str:
        return f"{self.route} contract - R${self.value}"


class Cargo(models.Model):
    resource = models.ForeignKey(Resource, on_delete=models.CASCADE)
    contract = models.ForeignKey(Contract, on_delete=models.CASCADE)
    weight = models.IntegerField(
        validators=[GreaterThanValidator(0)], null=False
    )

view.py

from django.forms.models import model_to_dict
from django.db.utils import IntegrityError

from rest_framework import viewsets
from rest_framework.reverse import reverse
from rest_framework.exceptions import ValidationError

from space_travel import serializers
from core import models


class ResourceViewSet(viewsets.ModelViewSet):
    queryset = models.Resource.objects.all()
    serializer_class = serializers.ResourceSerializer


class ShipViewSet(viewsets.ModelViewSet):
    queryset = models.Ship.objects.all()
    serializer_class = serializers.ShipSerializer


class PlanetViewSet(viewsets.ModelViewSet):
    queryset = models.Planet.objects.all()
    serializer_class = serializers.PlanetSerializer


class PilotViewSet(viewsets.ModelViewSet):
    queryset = models.Pilot.objects.all()
    serializer_class = serializers.PilotSerializer


class RouteViewSet(viewsets.ModelViewSet):
    queryset = models.Route.objects.all()

    def get_serializer_class(self):
        if self.action in ["retrieve", "list"]:
            # This serializer will show a hyperlinked
            # representation for origin/destination fields
            return serializers.RouteReadSerializer

        # This serializer will use th planet names for writing
        # instead of hyperlinked representations
        return serializers.RouteWriteSerializer


class ContractViewSet(viewsets.ModelViewSet):
    queryset = models.Contract.objects.all()
    serializer_class = serializers.ContractSerializer

serializers.py

from typing import Dict

from django.urls.base import reverse

from rest_framework import serializers
from rest_framework.reverse import reverse

from core import models


class ResourceSerializer(serializers.HyperlinkedModelSerializer):
    class Meta:
        model = models.Resource
        fields = "__all__"


class PlanetSerializer(serializers.HyperlinkedModelSerializer):
    class Meta:
        model = models.Planet
        fields = "__all__"


class RouteWriteSerializer(serializers.ModelSerializer):
    """This serializers will be used only for write operations
    like update, create"""

    origin_planet = serializers.SlugRelatedField(
        slug_field="name",
        queryset=models.Planet.objects.all(),
    )
    destination_planet = serializers.SlugRelatedField(
        slug_field="name",
        queryset=models.Planet.objects.all(),
    )

    class Meta:
        model = models.Route
        fields = "__all__"


class RouteReadSerializer(serializers.HyperlinkedModelSerializer):
    class Meta:
        model = models.Route
        fields = "__all__"


class PilotSerializer(serializers.HyperlinkedModelSerializer):
    location_planet = serializers.SlugRelatedField(
        many=False, queryset=models.Planet.objects.all(), slug_field="name"
    )
    ships = serializers.SlugRelatedField(
        queryset=models.Ship.objects.all(), many=True, slug_field="ship_model"
    )

    class Meta:
        model = models.Pilot
        fields = "__all__"

    def to_representation(self, instance: models.Pilot) -> Dict:
        """This method is responsible for represent the ship list
        in a payload mode (should access the ownership table):
            {
                url: {schema}://{domain}/{path to the ship},
                fuel_level: {fuel level} (comes from the ownership table)
            }
        """
        representation = super().to_representation(instance)
        representation["ships"] = []

        for ownership in instance.ownership_set.all():
            ship_url = reverse(  # Get the ship url
                "space_travel:ship-detail",
                args=[ownership.ship.pk],
                request=self._context["request"],
            )
            representation["ships"].append(
                {"url": ship_url, "fuel_level": ownership.fuel_level}
            )

        return representation


class ShipSerializer(serializers.HyperlinkedModelSerializer):
    class Meta:
        model = models.Ship
        fields = "__all__"


class CargoSerializer(serializers.ModelSerializer):
    resource = serializers.SlugRelatedField(
        queryset=models.Resource.objects.all(),
        slug_field="name",
        read_only=False,
    )

    class Meta:
        model = models.Cargo
        fields = ["resource", "weight"]


class ContractSerializer(serializers.HyperlinkedModelSerializer):
    origin_planet = serializers.SlugRelatedField(
        slug_field="name",
        queryset=models.Planet.objects.all(),
        many=False,
        write_only=True,
    )
    destination_planet = serializers.SlugRelatedField(
        slug_field="name",
        queryset=models.Planet.objects.all(),
        many=False,
        write_only=True,
    )
    route = RouteReadSerializer(many=False, read_only=True)
    cargo_set = CargoSerializer(many=True)

    class Meta:
        model = models.Contract
        fields = [
            "description",
            "value",
            "route",
            "origin_planet",
            "destination_planet",
            "cargo_set",
        ]

    def to_representation(self, instance: models.Contract) -> Dict:
        representation = super().to_representation(instance)
        representation["cargos"] = representation.pop("cargo_set")
        representation["total_weight"] = instance.cargo_weight()

        return representation

    def create(self, validated_data: Dict) -> models.Contract:
        route = models.Route.objects.get(
            origin_planet=validated_data.pop("origin_planet"),
            destination_planet=validated_data.pop("destination_planet"),
        )
        cargos = validated_data.pop("cargo_set")
        contract = models.Contract.objects.create(
            **validated_data, route=route
        )
        for cargo in cargos:
            contract.resources.add(
                cargo.pop("resource"), through_defaults=cargo
            )

        return contract

test_contract_endpoint.py

import random

from django.test import TestCase
from django.urls import reverse

from rest_framework import status
from rest_framework.test import APIClient

from core.models import Cargo, Contract, Planet, Resource, Route


def link_list(endpoint: str, domain: bool = False):
    link_path = reverse(f"space_travel:{endpoint}-list")
    return link_path if not domain else f"http://testserver{link_path}"


def link_details(endpoint: str, _id: int, domain: bool = False):
    link_path = reverse(f"space_travel:{endpoint}-detail", args=[_id])
    return link_path if not domain else f"http://testserver{link_path}"


class TestContractEndpoint(TestCase):
    def setUp(self):
        self.client = APIClient()
        self.endpoints = {
            "contract": "contract",
            "route": "route",
            "planet": "planet",
        }

        self.sample_pl1 = Planet.objects.get_or_create(name="Andvari")[0]
        self.sample_pl2 = Planet.objects.get_or_create(name="Demeter")[0]

        self.sample_resources = [
            Resource.objects.create(name=f"resource {n}") for n in range(4)
        ]
        self.sample_route = Route.objects.create(
            origin_planet=self.sample_pl1,
            destination_planet=self.sample_pl2,
            fuel_cost=100,
        )
        self.sample_contract = Contract.objects.create(
            route=self.sample_route,
            value=1000,
            description="Contract to deliver water and food to Demeter",
        )
        self.sample_contract.resources.set(
            self.sample_resources, through_defaults={"weight": 500}
        )

    def test_contract_creation(self):
        """Test if the contract creation are automatically setting the route
        passing the origin and destination planets names and connecting the
        resources to the contract through the Cargo model."""

        resources = [
            Resource.objects.get_or_create(name="minerals")[0],
            random.choice(self.sample_resources),
        ]
        payload = {
            "description": "Contract to deliver minerals to Saturn",
            "origin_planet": self.sample_pl1.name,
            "destination_planet": self.sample_pl2.name,
            "value": 1000,
            "cargo_set": [
                {"resource": resources[0].name, "weight": 545},
                {"resource": resources[1].name, "weight": 876},
            ],
        }
        res = self.client.post(link_list(self.endpoints["contract"]), payload)

        self.assertEqual(res.status_code, status.HTTP_201_CREATED)

        contract = Contract.objects.filter(
            description=payload["description"],
            route=self.sample_route,
            value=payload["value"],
            resources__in=[resource.id for resource in resources],
        ).first()
        self.assertTrue(contract)

        filtered_cargos = Cargo.objects.filter(contract=contract)
        self.assertEqual(filtered_cargos.count(), 2)
        for idx, cargo in enumerate(filtered_cargos):
            self.assertEqual(cargo.weight, payload["cargo"][idx]["weight"])

Now this is the validation error I am receiving: {'cargo_set': [ErrorDetail(string='This field is required.', code='required')]}

Here is a print of the contract that I created by hand in web browser and the payload passed to it was the same from the unit test: image

After some research in the source code I noticed that my cargo_set are being instatiated as a ListSerializer and the problem is when the field.get_value() is called inside the .to_internal_data(). I actually don't know why, but the regex inside the parse_html_list() isn't being matched. This was the only thing that I noticed that could be the problem

I would be glad for any help. Thanks!

CodePudding user response:

This seems to be the same Problem I had Nested deserialisation fails with "This field is required"

Make sure to use the APITest which sends the request JSON encoded.

To fix it I changed my testcase to use the APIClient provided by DRF:

from rest_framework.test import APIClient

client = APIClient()

In my settings.py I added the following to the configuration:

REST_FRAMEWORK = {
    ...
    'TEST_REQUEST_DEFAULT_FORMAT': 'json'
}
  •  Tags:  
  • Related